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
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
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',
288 }
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
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)
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
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
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
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)
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
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
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
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()
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
946 iv = v.filter(ff)
947 v = v.remapwith(iv)
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
958 iv = v.filter(ff)
959 v = v.remapwith(iv)
960 #print "filter regexs at %s" % time.time()
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()
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
1007 def hasnode(self, nodeid):
1008 return int(nodeid) < self.maxid
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.
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
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
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',
1301 }
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)
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)
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)
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()
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