Code

relaxed CVS importing (sf feature 693277)
[roundup.git] / roundup / backends / back_metakit.py
1 '''
2    Metakit backend for Roundup, originally by Gordon McMillan.
4    Notes by Richard:
6    This backend has some behaviour specific to metakit:
8     - there's no concept of an explicit "unset" in metakit, so all types
9       have some "unset" value:
11       ========= ===== ====================================================
12       Type      Value Action when fetching from mk
13       ========= ===== ====================================================
14       Strings   ''    convert to None
15       Date      0     (seconds since 1970-01-01.00:00:00) convert to None
16       Interval  ''    convert to None
17       Number    0     ambiguious :( - do nothing
18       Boolean   0     ambiguious :( - do nothing
19       Link      ''    convert to None
20       Multilink []    actually, mk can handle this one ;)
21       Passowrd  ''    convert to None
22       ========= ===== ====================================================
24       The get/set routines handle these values accordingly by converting
25       to/from None where they can. The Number/Boolean types are not able
26       to handle an "unset" at all, so they default the "unset" to 0.
28     - probably a bunch of stuff that I'm not aware of yet because I haven't
29       fully read through the source. One of these days....
30 '''
31 from roundup import hyperdb, date, password, roundupdb, security
32 import metakit
33 from sessions import Sessions, OneTimeKeys
34 import re, marshal, os, sys, weakref, time, calendar
35 from roundup import indexer
36 import locking
38 _dbs = {}
40 def Database(config, journaltag=None):
41     ''' Only have a single instance of the Database class for each instance
42     '''
43     db = _dbs.get(config.DATABASE, None)
44     if db is None or db._db is None:
45         db = _Database(config, journaltag)
46         _dbs[config.DATABASE] = db
47     else:
48         db.journaltag = journaltag
49         try:
50             delattr(db, 'curuserid')
51         except AttributeError:
52             pass
53     return db
55 class _Database(hyperdb.Database, roundupdb.Database):
56     def __init__(self, config, journaltag=None):
57         self.config = config
58         self.journaltag = journaltag
59         self.classes = {}
60         self.dirty = 0
61         self.lockfile = None
62         self._db = self.__open()
63         self.indexer = Indexer(self.config.DATABASE, self._db)
64         self.sessions = Sessions(self.config)
65         self.otks = OneTimeKeys(self.config)
66         self.security = security.Security(self)
68         os.umask(0002)
70     def post_init(self):
71         if self.indexer.should_reindex():
72             self.reindex()
74     def reindex(self):
75         for klass in self.classes.values():
76             for nodeid in klass.list():
77                 klass.index(nodeid)
78         self.indexer.save_index()
80     # --- defined in ping's spec
81     def __getattr__(self, classname):
82         if classname == 'curuserid':
83             if self.journaltag is None:
84                 return None
86             # try to set the curuserid from the journaltag
87             try:
88                 x = int(self.classes['user'].lookup(self.journaltag))
89                 self.curuserid = x
90             except KeyError:
91                 if self.journaltag == 'admin':
92                     self.curuserid = x = 1
93                 else:
94                     x = 0
95             return x
96         elif classname == 'transactions':
97             return self.dirty
98         # fall back on the classes
99         return self.getclass(classname)
100     def getclass(self, classname):
101         try:
102             return self.classes[classname]
103         except KeyError:
104             raise KeyError, 'There is no class called "%s"'%classname
105     def getclasses(self):
106         return self.classes.keys()
107     # --- end of ping's spec 
109     # --- exposed methods
110     def commit(self):
111         if self.dirty:
112             self._db.commit()
113             for cl in self.classes.values():
114                 cl._commit()
115             self.indexer.save_index()
116         self.dirty = 0
117     def rollback(self):
118         if self.dirty:
119             for cl in self.classes.values():
120                 cl._rollback()
121             self._db.rollback()
122             self._db = None
123             self._db = metakit.storage(self.dbnm, 1)
124             self.hist = self._db.view('history')
125             self.tables = self._db.view('tables')
126             self.indexer.rollback()
127             self.indexer.datadb = self._db
128         self.dirty = 0
129     def clearCache(self):
130         for cl in self.classes.values():
131             cl._commit()
132     def clear(self):
133         for cl in self.classes.values():
134             cl._clear()
135     def hasnode(self, classname, nodeid):
136         return self.getclass(classname).hasnode(nodeid)
137     def pack(self, pack_before):
138         mindate = int(calendar.timegm(pack_before.get_tuple()))
139         i = 0
140         while i < len(self.hist):
141             if self.hist[i].date < mindate and self.hist[i].action != _CREATE:
142                 self.hist.delete(i)
143             else:
144                 i = i + 1
145     def addclass(self, cl):
146         self.classes[cl.classname] = cl
147         if self.tables.find(name=cl.classname) < 0:
148             self.tables.append(name=cl.classname)
149     def addjournal(self, tablenm, nodeid, action, params, creator=None,
150                 creation=None):
151         tblid = self.tables.find(name=tablenm)
152         if tblid == -1:
153             tblid = self.tables.append(name=tablenm)
154         if creator is None:
155             creator = self.curuserid
156         else:
157             try:
158                 creator = int(creator)
159             except TypeError:
160                 creator = int(self.getclass('user').lookup(creator))
161         if creation is None:
162             creation = int(time.time())
163         elif isinstance(creation, date.Date):
164             creation = int(calendar.timegm(creation.get_tuple()))
165         # tableid:I,nodeid:I,date:I,user:I,action:I,params:B
166         self.hist.append(tableid=tblid,
167                          nodeid=int(nodeid),
168                          date=creation,
169                          action=action,
170                          user = creator,
171                          params = marshal.dumps(params))
172     def getjournal(self, tablenm, nodeid):
173         rslt = []
174         tblid = self.tables.find(name=tablenm)
175         if tblid == -1:
176             return rslt
177         q = self.hist.select(tableid=tblid, nodeid=int(nodeid))
178         if len(q) == 0:
179             raise IndexError, "no history for id %s in %s" % (nodeid, tablenm)
180         i = 0
181         #userclass = self.getclass('user')
182         for row in q:
183             try:
184                 params = marshal.loads(row.params)
185             except ValueError:
186                 print "history couldn't unmarshal %r" % row.params
187                 params = {}
188             #usernm = userclass.get(str(row.user), 'username')
189             dt = date.Date(time.gmtime(row.date))
190             #rslt.append((nodeid, dt, usernm, _actionnames[row.action], params))
191             rslt.append((nodeid, dt, str(row.user), _actionnames[row.action],
192                 params))
193         return rslt
195     def destroyjournal(self, tablenm, nodeid):
196         nodeid = int(nodeid)
197         tblid = self.tables.find(name=tablenm)
198         if tblid == -1:
199             return 
200         i = 0
201         hist = self.hist
202         while i < len(hist):
203             if hist[i].tableid == tblid and hist[i].nodeid == nodeid:
204                 hist.delete(i)
205             else:
206                 i = i + 1
207         self.dirty = 1
208         
209     def close(self):
210         for cl in self.classes.values():
211             cl.db = None
212         self._db = None
213         if self.lockfile is not None:
214             locking.release_lock(self.lockfile)
215         if _dbs.has_key(self.config.DATABASE):
216             del _dbs[self.config.DATABASE]
217         if self.lockfile is not None:
218             self.lockfile.close()
219             self.lockfile = None
220         self.classes = {}
221         self.indexer = None
223     # --- internal
224     def __open(self):
225         ''' Open the metakit database
226         '''
227         # make the database dir if it doesn't exist
228         if not os.path.exists(self.config.DATABASE):
229             os.makedirs(self.config.DATABASE)
231         # figure the file names
232         self.dbnm = db = os.path.join(self.config.DATABASE, 'tracker.mk4')
233         lockfilenm = db[:-3]+'lck'
235         # get the database lock
236         self.lockfile = locking.acquire_lock(lockfilenm)
237         self.lockfile.write(str(os.getpid()))
238         self.lockfile.flush()
240         # see if the schema has changed since last db access
241         self.fastopen = 0
242         if os.path.exists(db):
243             dbtm = os.path.getmtime(db)
244             pkgnm = self.config.__name__.split('.')[0]
245             schemamod = sys.modules.get(pkgnm+'.dbinit', None)
246             if schemamod:
247                 if os.path.exists(schemamod.__file__):
248                     schematm = os.path.getmtime(schemamod.__file__)
249                     if schematm < dbtm:
250                         # found schema mod - it's older than the db
251                         self.fastopen = 1
252                 else:
253                      # can't find schemamod - must be frozen
254                     self.fastopen = 1
256         # open the db
257         db = metakit.storage(db, 1)
258         hist = db.view('history')
259         tables = db.view('tables')
260         if not self.fastopen:
261             # create the database if it's brand new
262             if not hist.structure():
263                 hist = db.getas('history[tableid:I,nodeid:I,date:I,user:I,action:I,params:B]')
264             if not tables.structure():
265                 tables = db.getas('tables[name:S]')
266             db.commit()
268         # we now have an open, initialised database
269         self.tables = tables
270         self.hist = hist
271         return db
273     def setid(self, classname, maxid):
274         ''' No-op in metakit
275         '''
276         pass
277         
278 _STRINGTYPE = type('')
279 _LISTTYPE = type([])
280 _CREATE, _SET, _RETIRE, _LINK, _UNLINK = range(5)
282 _actionnames = {
283     _CREATE : 'create',
284     _SET : 'set',
285     _RETIRE : 'retire',
286     _LINK : 'link',
287     _UNLINK : 'unlink',
290 _marker = []
292 _ALLOWSETTINGPRIVATEPROPS = 0
294 class Class:    
295     privateprops = None
296     def __init__(self, db, classname, **properties):
297         #self.db = weakref.proxy(db)
298         self.db = db
299         self.classname = classname
300         self.keyname = None
301         self.ruprops = properties
302         self.privateprops = { 'id' : hyperdb.String(),
303                               'activity' : hyperdb.Date(),
304                               'creation' : hyperdb.Date(),
305                               'creator'  : hyperdb.Link('user') }
307         # event -> list of callables
308         self.auditors = {'create': [], 'set': [], 'retire': []}
309         self.reactors = {'create': [], 'set': [], 'retire': []}
311         view = self.__getview()
312         self.maxid = 1
313         if view:
314             self.maxid = view[-1].id + 1
315         self.uncommitted = {}
316         self.rbactions = []
318         # people reach inside!!
319         self.properties = self.ruprops
320         self.db.addclass(self)
321         self.idcache = {}
323         # default is to journal changes
324         self.do_journal = 1
326     def enableJournalling(self):
327         '''Turn journalling on for this class
328         '''
329         self.do_journal = 1
331     def disableJournalling(self):
332         '''Turn journalling off for this class
333         '''
334         self.do_journal = 0
335         
336     # --- the roundup.Class methods
337     def audit(self, event, detector):
338         l = self.auditors[event]
339         if detector not in l:
340             self.auditors[event].append(detector)
341     def fireAuditors(self, action, nodeid, newvalues):
342         for audit in self.auditors[action]:
343             audit(self.db, self, nodeid, newvalues)
344     def fireReactors(self, action, nodeid, oldvalues):
345         for react in self.reactors[action]:
346             react(self.db, self, nodeid, oldvalues)
347     def react(self, event, detector):
348         l = self.reactors[event]
349         if detector not in l:
350             self.reactors[event].append(detector)
352     # --- the hyperdb.Class methods
353     def create(self, **propvalues):
354         self.fireAuditors('create', None, propvalues)
355         newid = self.create_inner(**propvalues)
356         # self.set() (called in self.create_inner()) does reactors)
357         return newid
359     def create_inner(self, **propvalues):
360         rowdict = {}
361         rowdict['id'] = newid = self.maxid
362         self.maxid += 1
363         ndx = self.getview(1).append(rowdict)
364         propvalues['#ISNEW'] = 1
365         try:
366             self.set(str(newid), **propvalues)
367         except Exception:
368             self.maxid -= 1
369             raise
370         return str(newid)
371     
372     def get(self, nodeid, propname, default=_marker, cache=1):
373         # default and cache aren't in the spec
374         # cache=0 means "original value"
376         view = self.getview()        
377         id = int(nodeid)
378         if cache == 0:
379             oldnode = self.uncommitted.get(id, None)
380             if oldnode and oldnode.has_key(propname):
381                 return oldnode[propname]
382         ndx = self.idcache.get(id, None)
383         if ndx is None:
384             ndx = view.find(id=id)
385             if ndx < 0:
386                 raise IndexError, "%s has no node %s" % (self.classname, nodeid)
387             self.idcache[id] = ndx
388         try:
389             raw = getattr(view[ndx], propname)
390         except AttributeError:
391             raise KeyError, propname
392         rutyp = self.ruprops.get(propname, None)
393         if rutyp is None:
394             rutyp = self.privateprops[propname]
395         converter = _converters.get(rutyp.__class__, None)
396         if converter:
397             raw = converter(raw)
398         return raw
399         
400     def set(self, nodeid, **propvalues):
401         isnew = 0
402         if propvalues.has_key('#ISNEW'):
403             isnew = 1
404             del propvalues['#ISNEW']
405         if not isnew:
406             self.fireAuditors('set', nodeid, propvalues)
407         if not propvalues:
408             return propvalues
409         if propvalues.has_key('id'):
410             raise KeyError, '"id" is reserved'
411         if self.db.journaltag is None:
412             raise hyperdb.DatabaseError, 'Database open read-only'
413         view = self.getview(1)
415         # node must exist & not be retired
416         id = int(nodeid)
417         ndx = view.find(id=id)
418         if ndx < 0:
419             raise IndexError, "%s has no node %s" % (self.classname, nodeid)
420         row = view[ndx]
421         if row._isdel:
422             raise IndexError, "%s has no node %s" % (self.classname, nodeid)
423         oldnode = self.uncommitted.setdefault(id, {})
424         changes = {}
426         for key, value in propvalues.items():
427             # this will raise the KeyError if the property isn't valid
428             # ... we don't use getprops() here because we only care about
429             # the writeable properties.
430             if _ALLOWSETTINGPRIVATEPROPS:
431                 prop = self.ruprops.get(key, None)
432                 if not prop:
433                     prop = self.privateprops[key]
434             else:
435                 prop = self.ruprops[key]
436             converter = _converters.get(prop.__class__, lambda v: v)
437             # if the value's the same as the existing value, no sense in
438             # doing anything
439             oldvalue = converter(getattr(row, key))
440             if  value == oldvalue:
441                 del propvalues[key]
442                 continue
443             
444             # check to make sure we're not duplicating an existing key
445             if key == self.keyname:
446                 iv = self.getindexview(1)
447                 ndx = iv.find(k=value)
448                 if ndx == -1:
449                     iv.append(k=value, i=row.id)
450                     if not isnew:
451                         ndx = iv.find(k=oldvalue)
452                         if ndx > -1:
453                             iv.delete(ndx)
454                 else:
455                     raise ValueError, 'node with key "%s" exists'%value
457             # do stuff based on the prop type
458             if isinstance(prop, hyperdb.Link):
459                 link_class = prop.classname
460                 # must be a string or None
461                 if value is not None and not isinstance(value, type('')):
462                     raise ValueError, 'property "%s" link value be a string'%(
463                         key)
464                 # Roundup sets to "unselected" by passing None
465                 if value is None:
466                     value = 0   
467                 # if it isn't a number, it's a key
468                 try:
469                     int(value)
470                 except ValueError:
471                     try:
472                         value = self.db.getclass(link_class).lookup(value)
473                     except (TypeError, KeyError):
474                         raise IndexError, 'new property "%s": %s not a %s'%(
475                             key, value, prop.classname)
477                 if (value is not None and
478                         not self.db.getclass(link_class).hasnode(value)):
479                     raise IndexError, '%s has no node %s'%(link_class, value)
481                 setattr(row, key, int(value))
482                 changes[key] = oldvalue
483                 
484                 if self.do_journal and prop.do_journal:
485                     # register the unlink with the old linked node
486                     if oldvalue:
487                         self.db.addjournal(link_class, oldvalue, _UNLINK,
488                             (self.classname, str(row.id), key))
490                     # register the link with the newly linked node
491                     if value:
492                         self.db.addjournal(link_class, value, _LINK,
493                             (self.classname, str(row.id), key))
495             elif isinstance(prop, hyperdb.Multilink):
496                 if value is not None and type(value) != _LISTTYPE:
497                     raise TypeError, 'new property "%s" not a list of ids'%key
498                 link_class = prop.classname
499                 l = []
500                 if value is None:
501                     value = []
502                 for entry in value:
503                     if type(entry) != _STRINGTYPE:
504                         raise ValueError, 'new property "%s" link value ' \
505                             'must be a string'%key
506                     # if it isn't a number, it's a key
507                     try:
508                         int(entry)
509                     except ValueError:
510                         try:
511                             entry = self.db.getclass(link_class).lookup(entry)
512                         except (TypeError, KeyError):
513                             raise IndexError, 'new property "%s": %s not a %s'%(
514                                 key, entry, prop.classname)
515                     l.append(entry)
516                 propvalues[key] = value = l
518                 # handle removals
519                 rmvd = []
520                 for id in oldvalue:
521                     if id not in value:
522                         rmvd.append(id)
523                         # register the unlink with the old linked node
524                         if self.do_journal and prop.do_journal:
525                             self.db.addjournal(link_class, id, _UNLINK,
526                                 (self.classname, str(row.id), key))
528                 # handle additions
529                 adds = []
530                 for id in value:
531                     if id not in oldvalue:
532                         if not self.db.getclass(link_class).hasnode(id):
533                             raise IndexError, '%s has no node %s'%(
534                                 link_class, id)
535                         adds.append(id)
536                         # register the link with the newly linked node
537                         if self.do_journal and prop.do_journal:
538                             self.db.addjournal(link_class, id, _LINK,
539                                 (self.classname, str(row.id), key))
541                 # perform the modifications on the actual property value
542                 sv = getattr(row, key)
543                 i = 0
544                 while i < len(sv):
545                     if str(sv[i].fid) in rmvd:
546                         sv.delete(i)
547                     else:
548                         i += 1
549                 for id in adds:
550                     sv.append(fid=int(id))
552                 # figure the journal entry
553                 l = []
554                 if adds:
555                     l.append(('+', adds))
556                 if rmvd:
557                     l.append(('-', rmvd))
558                 if l:
559                     changes[key] = tuple(l)
560                 #changes[key] = oldvalue
562                 if not rmvd and not adds:
563                     del propvalues[key]
565             elif isinstance(prop, hyperdb.String):
566                 if value is not None and type(value) != _STRINGTYPE:
567                     raise TypeError, 'new property "%s" not a string'%key
568                 if value is None:
569                     value = ''
570                 setattr(row, key, value)
571                 changes[key] = oldvalue
572                 if hasattr(prop, 'isfilename') and prop.isfilename:
573                     propvalues[key] = os.path.basename(value)
574                 if prop.indexme:
575                     self.db.indexer.add_text((self.classname, nodeid, key),
576                         value, 'text/plain')
578             elif isinstance(prop, hyperdb.Password):
579                 if value is not None and not isinstance(value, password.Password):
580                     raise TypeError, 'new property "%s" not a Password'% key
581                 if value is None:
582                     value = ''
583                 setattr(row, key, str(value))
584                 changes[key] = str(oldvalue)
585                 propvalues[key] = str(value)
587             elif isinstance(prop, hyperdb.Date):
588                 if value is not None and not isinstance(value, date.Date):
589                     raise TypeError, 'new property "%s" not a Date'% key
590                 if value is None:
591                     setattr(row, key, 0)
592                 else:
593                     setattr(row, key, int(calendar.timegm(value.get_tuple())))
594                 changes[key] = str(oldvalue)
595                 propvalues[key] = str(value)
597             elif isinstance(prop, hyperdb.Interval):
598                 if value is not None and not isinstance(value, date.Interval):
599                     raise TypeError, 'new property "%s" not an Interval'% key
600                 if value is None:
601                     setattr(row, key, '')
602                 else:
603                     setattr(row, key, str(value))
604                 changes[key] = str(oldvalue)
605                 propvalues[key] = str(value)
606                 
607             elif isinstance(prop, hyperdb.Number):
608                 if value is None:
609                     value = 0
610                 try:
611                     v = int(value)
612                 except ValueError:
613                     raise TypeError, "%s (%s) is not numeric"%(key, repr(value))
614                 setattr(row, key, v)
615                 changes[key] = oldvalue
616                 propvalues[key] = value
618             elif isinstance(prop, hyperdb.Boolean):
619                 if value is None:
620                     bv = 0
621                 elif value not in (0,1):
622                     raise TypeError, "%s (%s) is not boolean"%(key, repr(value))
623                 else:
624                     bv = value 
625                 setattr(row, key, bv)
626                 changes[key] = oldvalue
627                 propvalues[key] = value
629             oldnode[key] = oldvalue
631         # nothing to do?
632         if not propvalues:
633             return propvalues
634         if not propvalues.has_key('activity'):
635             row.activity = int(time.time())
636         if isnew:
637             if not row.creation:
638                 row.creation = int(time.time())
639             if not row.creator:
640                 row.creator = self.db.curuserid
642         self.db.dirty = 1
643         if self.do_journal:
644             if isnew:
645                 self.db.addjournal(self.classname, nodeid, _CREATE, {})
646                 self.fireReactors('create', nodeid, None)
647             else:
648                 self.db.addjournal(self.classname, nodeid, _SET, changes)
649                 self.fireReactors('set', nodeid, oldnode)
651         return propvalues
652     
653     def retire(self, nodeid):
654         if self.db.journaltag is None:
655             raise hyperdb.DatabaseError, 'Database open read-only'
656         self.fireAuditors('retire', nodeid, None)
657         view = self.getview(1)
658         ndx = view.find(id=int(nodeid))
659         if ndx < 0:
660             raise KeyError, "nodeid %s not found" % nodeid
662         row = view[ndx]
663         oldvalues = self.uncommitted.setdefault(row.id, {})
664         oldval = oldvalues['_isdel'] = row._isdel
665         row._isdel = 1
667         if self.do_journal:
668             self.db.addjournal(self.classname, nodeid, _RETIRE, {})
669         if self.keyname:
670             iv = self.getindexview(1)
671             ndx = iv.find(k=getattr(row, self.keyname),i=row.id)
672             if ndx > -1:
673                 iv.delete(ndx)
674         self.db.dirty = 1
675         self.fireReactors('retire', nodeid, None)
677     def is_retired(self, nodeid):
678         view = self.getview(1)
679         # node must exist & not be retired
680         id = int(nodeid)
681         ndx = view.find(id=id)
682         if ndx < 0:
683             raise IndexError, "%s has no node %s" % (self.classname, nodeid)
684         row = view[ndx]
685         return row._isdel
687     def history(self, nodeid):
688         if not self.do_journal:
689             raise ValueError, 'Journalling is disabled for this class'
690         return self.db.getjournal(self.classname, nodeid)
692     def setkey(self, propname):
693         if self.keyname:
694             if propname == self.keyname:
695                 return
696             raise ValueError, "%s already indexed on %s"%(self.classname,
697                 self.keyname)
698         prop = self.properties.get(propname, None)
699         if prop is None:
700             prop = self.privateprops.get(propname, None)
701         if prop is None:
702             raise KeyError, "no property %s" % propname
703         if not isinstance(prop, hyperdb.String):
704             raise TypeError, "%s is not a String" % propname
706         # first setkey for this run
707         self.keyname = propname
708         iv = self.db._db.view('_%s' % self.classname)
709         if self.db.fastopen and iv.structure():
710             return
712         # very first setkey ever
713         self.db.dirty = 1
714         iv = self.db._db.getas('_%s[k:S,i:I]' % self.classname)
715         iv = iv.ordered(1)
716         for row in self.getview():
717             iv.append(k=getattr(row, propname), i=row.id)
718         self.db.commit()
720     def getkey(self):
721         return self.keyname
723     def lookup(self, keyvalue):
724         if type(keyvalue) is not _STRINGTYPE:
725             raise TypeError, "%r is not a string" % keyvalue
726         iv = self.getindexview()
727         if iv:
728             ndx = iv.find(k=keyvalue)
729             if ndx > -1:
730                 return str(iv[ndx].i)
731         else:
732             view = self.getview()
733             ndx = view.find({self.keyname:keyvalue, '_isdel':0})
734             if ndx > -1:
735                 return str(view[ndx].id)
736         raise KeyError, keyvalue
738     def destroy(self, id):
739         view = self.getview(1)
740         ndx = view.find(id=int(id))
741         if ndx > -1:
742             if self.keyname:
743                 keyvalue = getattr(view[ndx], self.keyname)
744                 iv = self.getindexview(1)
745                 if iv:
746                     ivndx = iv.find(k=keyvalue)
747                     if ivndx > -1:
748                         iv.delete(ivndx)
749             view.delete(ndx)
750             self.db.destroyjournal(self.classname, id)
751             self.db.dirty = 1
752         
753     def find(self, **propspec):
754         """Get the ids of nodes in this class which link to the given nodes.
756         'propspec' consists of keyword args propname={nodeid:1,}   
757         'propname' must be the name of a property in this class, or a
758                    KeyError is raised.  That property must be a Link or
759                    Multilink property, or a TypeError is raised.
761         Any node in this class whose propname property links to any of the
762         nodeids will be returned. Used by the full text indexing, which knows
763         that "foo" occurs in msg1, msg3 and file7; so we have hits on these
764         issues:
766             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
768         """
769         propspec = propspec.items()
770         for propname, nodeid in propspec:
771             # check the prop is OK
772             prop = self.ruprops[propname]
773             if (not isinstance(prop, hyperdb.Link) and
774                     not isinstance(prop, hyperdb.Multilink)):
775                 raise TypeError, "'%s' not a Link/Multilink property"%propname
777         vws = []
778         for propname, ids in propspec:
779             if type(ids) is _STRINGTYPE:
780                 ids = {int(ids):1}
781             else:
782                 d = {}
783                 for id in ids.keys():
784                     d[int(id)] = 1
785                 ids = d
786             prop = self.ruprops[propname]
787             view = self.getview()
788             if isinstance(prop, hyperdb.Multilink):
789                 def ff(row, nm=propname, ids=ids):
790                     sv = getattr(row, nm)
791                     for sr in sv:
792                         if ids.has_key(sr.fid):
793                             return 1
794                     return 0
795             else:
796                 def ff(row, nm=propname, ids=ids):
797                     return ids.has_key(getattr(row, nm))
798             ndxview = view.filter(ff)
799             vws.append(ndxview.unique())
801         # handle the empty match case
802         if not vws:
803             return []
805         ndxview = vws[0]
806         for v in vws[1:]:
807             ndxview = ndxview.union(v)
808         view = self.getview().remapwith(ndxview)
809         rslt = []
810         for row in view:
811             rslt.append(str(row.id))
812         return rslt
813             
815     def list(self):
816         l = []
817         for row in self.getview().select(_isdel=0):
818             l.append(str(row.id))
819         return l
821     def getnodeids(self):
822         l = []
823         for row in self.getview():
824             l.append(str(row.id))
825         return l
827     def count(self):
828         return len(self.getview())
830     def getprops(self, protected=1):
831         # protected is not in ping's spec
832         allprops = self.ruprops.copy()
833         if protected and self.privateprops is not None:
834             allprops.update(self.privateprops)
835         return allprops
837     def addprop(self, **properties):
838         for key in properties.keys():
839             if self.ruprops.has_key(key):
840                 raise ValueError, "%s is already a property of %s"%(key,
841                     self.classname)
842         self.ruprops.update(properties)
843         # Class structure has changed
844         self.db.fastopen = 0
845         view = self.__getview()
846         self.db.commit()
847     # ---- end of ping's spec
849     def filter(self, search_matches, filterspec, sort=(None,None),
850             group=(None,None)):
851         # search_matches is None or a set (dict of {nodeid: {propname:[nodeid,...]}})
852         # filterspec is a dict {propname:value}
853         # sort and group are (dir, prop) where dir is '+', '-' or None
854         #                    and prop is a prop name or None
855         where = {'_isdel':0}
856         mlcriteria = {}
857         regexes = {}
858         orcriteria = {}
859         for propname, value in filterspec.items():
860             prop = self.ruprops.get(propname, None)
861             if prop is None:
862                 prop = self.privateprops[propname]
863             if isinstance(prop, hyperdb.Multilink):
864                 if type(value) is not _LISTTYPE:
865                     value = [value]
866                 # transform keys to ids
867                 u = []
868                 for item in value:
869                     try:
870                         item = int(item)
871                     except (TypeError, ValueError):
872                         item = int(self.db.getclass(prop.classname).lookup(item))
873                     if item == -1:
874                         item = 0
875                     u.append(item)
876                 mlcriteria[propname] = u
877             elif isinstance(prop, hyperdb.Link):
878                 if type(value) is not _LISTTYPE:
879                     value = [value]
880                 # transform keys to ids
881                 u = []
882                 for item in value:
883                     try:
884                         item = int(item)
885                     except (TypeError, ValueError):
886                         item = int(self.db.getclass(prop.classname).lookup(item))
887                     if item == -1:
888                         item = 0
889                     u.append(item)
890                 if len(u) == 1:
891                     where[propname] = u[0]
892                 else:
893                     orcriteria[propname] = u
894             elif isinstance(prop, hyperdb.String):
895                 # simple glob searching
896                 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', value)
897                 v = v.replace('?', '.')
898                 v = v.replace('*', '.*?')
899                 regexes[propname] = re.compile(v, re.I)
900             elif propname == 'id':
901                 where[propname] = int(value)
902             elif isinstance(prop, hyperdb.Boolean):
903                 if type(value) is _STRINGTYPE:
904                     bv = value.lower() in ('yes', 'true', 'on', '1')
905                 else:
906                     bv = value
907                 where[propname] = bv
908             elif isinstance(prop, hyperdb.Date):
909                 t = date.Date(value).get_tuple()
910                 where[propname] = int(calendar.timegm(t))
911             elif isinstance(prop, hyperdb.Interval):
912                 where[propname] = str(date.Interval(value))
913             elif isinstance(prop, hyperdb.Number):
914                 where[propname] = int(value)
915             else:
916                 where[propname] = str(value)
917         v = self.getview()
918         #print "filter start at  %s" % time.time() 
919         if where:
920             v = v.select(where)
921         #print "filter where at  %s" % time.time() 
923         if mlcriteria:
924             # multilink - if any of the nodeids required by the
925             # filterspec aren't in this node's property, then skip it
926             def ff(row, ml=mlcriteria):
927                 for propname, values in ml.items():
928                     sv = getattr(row, propname)
929                     for id in values:
930                         if sv.find(fid=id) == -1:
931                             return 0
932                 return 1
933             iv = v.filter(ff)
934             v = v.remapwith(iv)
936         #print "filter mlcrit at %s" % time.time() 
937         
938         if orcriteria:
939             def ff(row, crit=orcriteria):
940                 for propname, allowed in crit.items():
941                     val = getattr(row, propname)
942                     if val not in allowed:
943                         return 0
944                 return 1
945             
946             iv = v.filter(ff)
947             v = v.remapwith(iv)
948         
949         #print "filter orcrit at %s" % time.time() 
950         if regexes:
951             def ff(row, r=regexes):
952                 for propname, regex in r.items():
953                     val = str(getattr(row, propname))
954                     if not regex.search(val):
955                         return 0
956                 return 1
957             
958             iv = v.filter(ff)
959             v = v.remapwith(iv)
960         #print "filter regexs at %s" % time.time() 
961         
962         if sort or group:
963             sortspec = []
964             rev = []
965             for dir, propname in group, sort:
966                 if propname is None: continue
967                 isreversed = 0
968                 if dir == '-':
969                     isreversed = 1
970                 try:
971                     prop = getattr(v, propname)
972                 except AttributeError:
973                     print "MK has no property %s" % propname
974                     continue
975                 propclass = self.ruprops.get(propname, None)
976                 if propclass is None:
977                     propclass = self.privateprops.get(propname, None)
978                     if propclass is None:
979                         print "Schema has no property %s" % propname
980                         continue
981                 if isinstance(propclass, hyperdb.Link):
982                     linkclass = self.db.getclass(propclass.classname)
983                     lv = linkclass.getview()
984                     lv = lv.rename('id', propname)
985                     v = v.join(lv, prop, 1)
986                     if linkclass.getprops().has_key('order'):
987                         propname = 'order'
988                     else:
989                         propname = linkclass.labelprop()
990                     prop = getattr(v, propname)
991                 if isreversed:
992                     rev.append(prop)
993                 sortspec.append(prop)
994             v = v.sortrev(sortspec, rev)[:] #XXX Metakit bug
995         #print "filter sort   at %s" % time.time() 
996             
997         rslt = []
998         for row in v:
999             id = str(row.id)
1000             if search_matches is not None:
1001                 if search_matches.has_key(id):
1002                     rslt.append(id)
1003             else:
1004                 rslt.append(id)
1005         return rslt
1006     
1007     def hasnode(self, nodeid):
1008         return int(nodeid) < self.maxid
1009     
1010     def labelprop(self, default_to_id=0):
1011         ''' Return the property name for a label for the given node.
1013         This method attempts to generate a consistent label for the node.
1014         It tries the following in order:
1015             1. key property
1016             2. "name" property
1017             3. "title" property
1018             4. first property from the sorted property name list
1019         '''
1020         k = self.getkey()
1021         if  k:
1022             return k
1023         props = self.getprops()
1024         if props.has_key('name'):
1025             return 'name'
1026         elif props.has_key('title'):
1027             return 'title'
1028         if default_to_id:
1029             return 'id'
1030         props = props.keys()
1031         props.sort()
1032         return props[0]
1034     def stringFind(self, **requirements):
1035         """Locate a particular node by matching a set of its String
1036         properties in a caseless search.
1038         If the property is not a String property, a TypeError is raised.
1039         
1040         The return is a list of the id of all nodes that match.
1041         """
1042         for propname in requirements.keys():
1043             prop = self.properties[propname]
1044             if isinstance(not prop, hyperdb.String):
1045                 raise TypeError, "'%s' not a String property"%propname
1046             requirements[propname] = requirements[propname].lower()
1047         requirements['_isdel'] = 0
1048         
1049         l = []
1050         for row in self.getview().select(requirements):
1051             l.append(str(row.id))
1052         return l
1054     def addjournal(self, nodeid, action, params):
1055         self.db.addjournal(self.classname, nodeid, action, params)
1057     def index(self, nodeid):
1058         ''' Add (or refresh) the node to search indexes '''
1059         # find all the String properties that have indexme
1060         for prop, propclass in self.getprops().items():
1061             if isinstance(propclass, hyperdb.String) and propclass.indexme:
1062                 # index them under (classname, nodeid, property)
1063                 self.db.indexer.add_text((self.classname, nodeid, prop),
1064                                 str(self.get(nodeid, prop)))
1066     def export_list(self, propnames, nodeid):
1067         ''' Export a node - generate a list of CSV-able data in the order
1068             specified by propnames for the given node.
1069         '''
1070         properties = self.getprops()
1071         l = []
1072         for prop in propnames:
1073             proptype = properties[prop]
1074             value = self.get(nodeid, prop)
1075             # "marshal" data where needed
1076             if value is None:
1077                 pass
1078             elif isinstance(proptype, hyperdb.Date):
1079                 value = value.get_tuple()
1080             elif isinstance(proptype, hyperdb.Interval):
1081                 value = value.get_tuple()
1082             elif isinstance(proptype, hyperdb.Password):
1083                 value = str(value)
1084             l.append(repr(value))
1086         # append retired flag
1087         l.append(self.is_retired(nodeid))
1089         return l
1090         
1091     def import_list(self, propnames, proplist):
1092         ''' Import a node - all information including "id" is present and
1093             should not be sanity checked. Triggers are not triggered. The
1094             journal should be initialised using the "creator" and "creation"
1095             information.
1097             Return the nodeid of the node imported.
1098         '''
1099         if self.db.journaltag is None:
1100             raise hyperdb.DatabaseError, 'Database open read-only'
1101         properties = self.getprops()
1103         d = {}
1104         view = self.getview(1)
1105         for i in range(len(propnames)):
1106             value = eval(proplist[i])
1107             if not value:
1108                 continue
1110             propname = propnames[i]
1111             if propname == 'id':
1112                 newid = value = int(value)
1113             elif propname == 'is retired':
1114                 # is the item retired?
1115                 if int(value):
1116                     d['_isdel'] = 1
1117                 continue
1119             prop = properties[propname]
1120             if isinstance(prop, hyperdb.Date):
1121                 value = int(calendar.timegm(value))
1122             elif isinstance(prop, hyperdb.Interval):
1123                 value = str(date.Interval(value))
1124             elif isinstance(prop, hyperdb.Number):
1125                 value = int(value)
1126             elif isinstance(prop, hyperdb.Boolean):
1127                 value = int(value)
1128             elif isinstance(prop, hyperdb.Link) and value:
1129                 value = int(value)
1130             elif isinstance(prop, hyperdb.Multilink):
1131                 # we handle multilinks separately
1132                 continue
1133             d[propname] = value
1135         # possibly make a new node
1136         if not d.has_key('id'):
1137             d['id'] = newid = self.maxid
1138             self.maxid += 1
1140         # save off the node
1141         view.append(d)
1143         # fix up multilinks
1144         ndx = view.find(id=newid)
1145         row = view[ndx]
1146         for i in range(len(propnames)):
1147             value = eval(proplist[i])
1148             propname = propnames[i]
1149             if propname == 'is retired':
1150                 continue
1151             prop = properties[propname]
1152             if not isinstance(prop, hyperdb.Multilink):
1153                 continue
1154             sv = getattr(row, propname)
1155             for entry in value:
1156                 sv.append(int(entry))
1158         self.db.dirty = 1
1159         creator = d.get('creator', 0)
1160         creation = d.get('creation', 0)
1161         self.db.addjournal(self.classname, str(newid), _CREATE, {}, creator,
1162             creation)
1163         return newid
1165     # --- used by Database
1166     def _commit(self):
1167         """ called post commit of the DB.
1168             interested subclasses may override """
1169         self.uncommitted = {}
1170         self.rbactions = []
1171         self.idcache = {}
1172     def _rollback(self):  
1173         """ called pre rollback of the DB.
1174             interested subclasses may override """
1175         for action in self.rbactions:
1176             action()
1177         self.rbactions = []
1178         self.uncommitted = {}
1179         self.idcache = {}
1180     def _clear(self):
1181         view = self.getview(1)
1182         if len(view):
1183             view[:] = []
1184             self.db.dirty = 1
1185         iv = self.getindexview(1)
1186         if iv:
1187             iv[:] = []
1188     def rollbackaction(self, action):
1189         """ call this to register a callback called on rollback
1190             callback is removed on end of transaction """
1191         self.rbactions.append(action)
1192     # --- internal
1193     def __getview(self):
1194         ''' Find the interface for a specific Class in the hyperdb.
1196             This method checks to see whether the schema has changed and
1197             re-works the underlying metakit structure if it has.
1198         '''
1199         db = self.db._db
1200         view = db.view(self.classname)
1201         mkprops = view.structure()
1203         # if we have structure in the database, and the structure hasn't
1204         # changed
1205         if mkprops and self.db.fastopen:
1206             return view.ordered(1)
1208         # is the definition the same?
1209         for nm, rutyp in self.ruprops.items():
1210             for mkprop in mkprops:
1211                 if mkprop.name == nm:
1212                     break
1213             else:
1214                 mkprop = None
1215             if mkprop is None:
1216                 break
1217             if _typmap[rutyp.__class__] != mkprop.type:
1218                 break
1219         else:
1220             return view.ordered(1)
1221         # need to create or restructure the mk view
1222         # id comes first, so MK will order it for us
1223         self.db.dirty = 1
1224         s = ["%s[id:I" % self.classname]
1225         for nm, rutyp in self.ruprops.items():
1226             mktyp = _typmap[rutyp.__class__]
1227             s.append('%s:%s' % (nm, mktyp))
1228             if mktyp == 'V':
1229                 s[-1] += ('[fid:I]')
1230         s.append('_isdel:I,activity:I,creation:I,creator:I]')
1231         v = self.db._db.getas(','.join(s))
1232         self.db.commit()
1233         return v.ordered(1)
1234     def getview(self, RW=0):
1235         return self.db._db.view(self.classname).ordered(1)
1236     def getindexview(self, RW=0):
1237         return self.db._db.view("_%s" % self.classname).ordered(1)
1239 def _fetchML(sv):
1240     l = []
1241     for row in sv:
1242         if row.fid:
1243             l.append(str(row.fid))
1244     return l
1246 def _fetchPW(s):
1247     ''' Convert to a password.Password unless the password is '' which is
1248         our sentinel for "unset".
1249     '''
1250     if s == '':
1251         return None
1252     p = password.Password()
1253     p.unpack(s)
1254     return p
1256 def _fetchLink(n):
1257     ''' Return None if the link is 0 - otherwise strify it.
1258     '''
1259     return n and str(n) or None
1261 def _fetchDate(n):
1262     ''' Convert the timestamp to a date.Date instance - unless it's 0 which
1263         is our sentinel for "unset".
1264     '''
1265     if n == 0:
1266         return None
1267     return date.Date(time.gmtime(n))
1269 def _fetchInterval(n):
1270     ''' Convert to a date.Interval unless the interval is '' which is our
1271         sentinel for "unset".
1272     '''
1273     if n == '':
1274         return None
1275     return date.Interval(n)
1277 _converters = {
1278     hyperdb.Date   : _fetchDate,
1279     hyperdb.Link   : _fetchLink,
1280     hyperdb.Multilink : _fetchML,
1281     hyperdb.Interval  : _fetchInterval,
1282     hyperdb.Password  : _fetchPW,
1283     hyperdb.Boolean   : lambda n: n,
1284     hyperdb.Number    : lambda n: n,
1285     hyperdb.String    : lambda s: s and str(s) or None,
1286 }                
1288 class FileName(hyperdb.String):
1289     isfilename = 1            
1291 _typmap = {
1292     FileName : 'S',
1293     hyperdb.String : 'S',
1294     hyperdb.Date   : 'I',
1295     hyperdb.Link   : 'I',
1296     hyperdb.Multilink : 'V',
1297     hyperdb.Interval  : 'S',
1298     hyperdb.Password  : 'S',
1299     hyperdb.Boolean   : 'I',
1300     hyperdb.Number    : 'I',
1302 class FileClass(Class, hyperdb.FileClass):
1303     ''' like Class but with a content property
1304     '''
1305     default_mime_type = 'text/plain'
1306     def __init__(self, db, classname, **properties):
1307         properties['content'] = FileName()
1308         if not properties.has_key('type'):
1309             properties['type'] = hyperdb.String()
1310         Class.__init__(self, db, classname, **properties)
1312     def get(self, nodeid, propname, default=_marker, cache=1):
1313         x = Class.get(self, nodeid, propname, default, cache)
1314         poss_msg = 'Possibly an access right configuration problem.'
1315         if propname == 'content':
1316             if x.startswith('file:'):
1317                 fnm = x[5:]
1318                 try:
1319                     x = open(fnm, 'rb').read()
1320                 except IOError, (strerror):
1321                     # XXX by catching this we donot see an error in the log.
1322                     return 'ERROR reading file: %s%s\n%s\n%s'%(
1323                             self.classname, nodeid, poss_msg, strerror)
1324         return x
1326     def create(self, **propvalues):
1327         self.fireAuditors('create', None, propvalues)
1328         content = propvalues['content']
1329         del propvalues['content']
1330         newid = Class.create_inner(self, **propvalues)
1331         if not content:
1332             return newid
1333         nm = bnm = '%s%s' % (self.classname, newid)
1334         sd = str(int(int(newid) / 1000))
1335         d = os.path.join(self.db.config.DATABASE, 'files', self.classname, sd)
1336         if not os.path.exists(d):
1337             os.makedirs(d)
1338         nm = os.path.join(d, nm)
1339         open(nm, 'wb').write(content)
1340         self.set(newid, content = 'file:'+nm)
1341         mimetype = propvalues.get('type', self.default_mime_type)
1342         self.db.indexer.add_text((self.classname, newid, 'content'), content,
1343             mimetype)
1344         def undo(fnm=nm, action1=os.remove, indexer=self.db.indexer):
1345             action1(fnm)
1346         self.rollbackaction(undo)
1347         return newid
1349     def index(self, nodeid):
1350         Class.index(self, nodeid)
1351         mimetype = self.get(nodeid, 'type')
1352         if not mimetype:
1353             mimetype = self.default_mime_type
1354         self.db.indexer.add_text((self.classname, nodeid, 'content'),
1355                     self.get(nodeid, 'content'), mimetype)
1356  
1357 class IssueClass(Class, roundupdb.IssueClass):
1358     ''' The newly-created class automatically includes the "messages",
1359         "files", "nosy", and "superseder" properties.  If the 'properties'
1360         dictionary attempts to specify any of these properties or a
1361         "creation" or "activity" property, a ValueError is raised.
1362     '''
1363     def __init__(self, db, classname, **properties):
1364         if not properties.has_key('title'):
1365             properties['title'] = hyperdb.String(indexme='yes')
1366         if not properties.has_key('messages'):
1367             properties['messages'] = hyperdb.Multilink("msg")
1368         if not properties.has_key('files'):
1369             properties['files'] = hyperdb.Multilink("file")
1370         if not properties.has_key('nosy'):
1371             # note: journalling is turned off as it really just wastes
1372             # space. this behaviour may be overridden in an instance
1373             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
1374         if not properties.has_key('superseder'):
1375             properties['superseder'] = hyperdb.Multilink(classname)
1376         Class.__init__(self, db, classname, **properties)
1377         
1378 CURVERSION = 2
1380 class Indexer(indexer.Indexer):
1381     disallows = {'THE':1, 'THIS':1, 'ZZZ':1, 'THAT':1, 'WITH':1}
1382     def __init__(self, path, datadb):
1383         self.path = os.path.join(path, 'index.mk4')
1384         self.db = metakit.storage(self.path, 1)
1385         self.datadb = datadb
1386         self.reindex = 0
1387         v = self.db.view('version')
1388         if not v.structure():
1389             v = self.db.getas('version[vers:I]')
1390             self.db.commit()
1391             v.append(vers=CURVERSION)
1392             self.reindex = 1
1393         elif v[0].vers != CURVERSION:
1394             v[0].vers = CURVERSION
1395             self.reindex = 1
1396         if self.reindex:
1397             self.db.getas('ids[tblid:I,nodeid:I,propid:I,ignore:I]')
1398             self.db.getas('index[word:S,hits[pos:I]]')
1399             self.db.commit()
1400             self.reindex = 1
1401         self.changed = 0
1402         self.propcache = {}
1404     def force_reindex(self):
1405         v = self.db.view('ids')
1406         v[:] = []
1407         v = self.db.view('index')
1408         v[:] = []
1409         self.db.commit()
1410         self.reindex = 1
1412     def should_reindex(self):
1413         return self.reindex
1415     def _getprops(self, classname):
1416         props = self.propcache.get(classname, None)
1417         if props is None:
1418             props = self.datadb.view(classname).structure()
1419             props = [prop.name for prop in props]
1420             self.propcache[classname] = props
1421         return props
1423     def _getpropid(self, classname, propname):
1424         return self._getprops(classname).index(propname)
1426     def _getpropname(self, classname, propid):
1427         return self._getprops(classname)[propid]
1429     def add_text(self, identifier, text, mime_type='text/plain'):
1430         if mime_type != 'text/plain':
1431             return
1432         classname, nodeid, property = identifier
1433         tbls = self.datadb.view('tables')
1434         tblid = tbls.find(name=classname)
1435         if tblid < 0:
1436             raise KeyError, "unknown class %r"%classname
1437         nodeid = int(nodeid)
1438         propid = self._getpropid(classname, property)
1439         ids = self.db.view('ids')
1440         oldpos = ids.find(tblid=tblid,nodeid=nodeid,propid=propid,ignore=0)
1441         if oldpos > -1:
1442             ids[oldpos].ignore = 1
1443             self.changed = 1
1444         pos = ids.append(tblid=tblid,nodeid=nodeid,propid=propid)
1445         
1446         wordlist = re.findall(r'\b\w{2,25}\b', text.upper())
1447         words = {}
1448         for word in wordlist:
1449             if not self.disallows.has_key(word):
1450                 words[word] = 1
1451         words = words.keys()
1452         
1453         index = self.db.view('index').ordered(1)
1454         for word in words:
1455             ndx = index.find(word=word)
1456             if ndx < 0:
1457                 index.append(word=word)
1458                 ndx = index.find(word=word)
1459             index[ndx].hits.append(pos=pos)
1460             self.changed = 1
1462     def find(self, wordlist):
1463         hits = None
1464         index = self.db.view('index').ordered(1)
1465         for word in wordlist:
1466             word = word.upper()
1467             if not 2 < len(word) < 26:
1468                 continue
1469             ndx = index.find(word=word)
1470             if ndx < 0:
1471                 return {}
1472             if hits is None:
1473                 hits = index[ndx].hits
1474             else:
1475                 hits = hits.intersect(index[ndx].hits)
1476             if len(hits) == 0:
1477                 return {}
1478         if hits is None:
1479             return {}
1480         rslt = {}
1481         ids = self.db.view('ids').remapwith(hits)
1482         tbls = self.datadb.view('tables')
1483         for i in range(len(ids)):
1484             hit = ids[i]
1485             if not hit.ignore:
1486                 classname = tbls[hit.tblid].name
1487                 nodeid = str(hit.nodeid)
1488                 property = self._getpropname(classname, hit.propid)
1489                 rslt[i] = (classname, nodeid, property)
1490         return rslt
1492     def save_index(self):
1493         if self.changed:
1494             self.db.commit()
1495         self.changed = 0
1497     def rollback(self):
1498         if self.changed:
1499             self.db.rollback()
1500             self.db = metakit.storage(self.path, 1)
1501         self.changed = 0