Code

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