1 # $Id: back_metakit.py,v 1.62 2004-03-18 01:58:45 richard Exp $
2 '''Metakit backend for Roundup, originally by Gordon McMillan.
4 Known Current Bugs:
6 - You can't change a class' key properly. This shouldn't be too hard to fix.
7 - Some unit tests are overridden.
9 Notes by Richard:
11 This backend has some behaviour specific to metakit:
13 - there's no concept of an explicit "unset" in metakit, so all types
14 have some "unset" value:
16 ========= ===== ======================================================
17 Type Value Action when fetching from mk
18 ========= ===== ======================================================
19 Strings '' convert to None
20 Date 0 (seconds since 1970-01-01.00:00:00) convert to None
21 Interval '' convert to None
22 Number 0 ambiguious :( - do nothing (see BACKWARDS_COMPATIBLE)
23 Boolean 0 ambiguious :( - do nothing (see BACKWARDS_COMPATABILE)
24 Link 0 convert to None
25 Multilink [] actually, mk can handle this one ;)
26 Password '' convert to None
27 ========= ===== ======================================================
29 The get/set routines handle these values accordingly by converting
30 to/from None where they can. The Number/Boolean types are not able
31 to handle an "unset" at all, so they default the "unset" to 0.
32 - Metakit relies in reference counting to close the database, there is
33 no explicit close call. This can cause issues if a metakit
34 database is referenced multiple times, one might not actually be
35 closing the db.
36 - probably a bunch of stuff that I'm not aware of yet because I haven't
37 fully read through the source. One of these days....
38 '''
39 __docformat__ = 'restructuredtext'
40 # Enable this flag to break backwards compatibility (i.e. can't read old
41 # databases) but comply with more roundup features, like adding NULL support.
42 BACKWARDS_COMPATIBLE = True
44 from roundup import hyperdb, date, password, roundupdb, security
45 import metakit
46 from sessions_dbm import Sessions, OneTimeKeys
47 import re, marshal, os, sys, time, calendar
48 from roundup import indexer
49 import locking
50 from roundup.date import Range
52 # view modes for opening
53 # XXX FIXME BPK -> these don't do anything, they are ignored
54 # should we just get rid of them for simplicities sake?
55 READ = 0
56 READWRITE = 1
58 # general metakit error
59 class MKBackendError(Exception):
60 pass
62 _dbs = {}
64 def Database(config, journaltag=None):
65 ''' Only have a single instance of the Database class for each instance
66 '''
67 db = _dbs.get(config.DATABASE, None)
68 if db is None or db._db is None:
69 db = _Database(config, journaltag)
70 _dbs[config.DATABASE] = db
71 else:
72 db.journaltag = journaltag
73 return db
75 class _Database(hyperdb.Database, roundupdb.Database):
76 def __init__(self, config, journaltag=None):
77 self.config = config
78 self.journaltag = journaltag
79 self.classes = {}
80 self.dirty = 0
81 self.lockfile = None
82 self._db = self.__open()
83 self.indexer = Indexer(self.config.DATABASE, self._db)
84 self.security = security.Security(self)
86 os.umask(0002)
88 def post_init(self):
89 if self.indexer.should_reindex():
90 self.reindex()
92 def refresh_database(self):
93 # XXX handle refresh
94 self.reindex()
96 def reindex(self):
97 for klass in self.classes.values():
98 for nodeid in klass.list():
99 klass.index(nodeid)
100 self.indexer.save_index()
102 def getSessionManager(self):
103 return Sessions(self)
105 def getOTKManager(self):
106 return OneTimeKeys(self)
108 # --- defined in ping's spec
109 def __getattr__(self, classname):
110 if classname == 'transactions':
111 return self.dirty
112 # fall back on the classes
113 try:
114 return self.getclass(classname)
115 except KeyError, msg:
116 # KeyError's not appropriate here
117 raise AttributeError, str(msg)
118 def getclass(self, classname):
119 try:
120 return self.classes[classname]
121 except KeyError:
122 raise KeyError, 'There is no class called "%s"'%classname
123 def getclasses(self):
124 return self.classes.keys()
125 # --- end of ping's spec
127 # --- exposed methods
128 def commit(self):
129 '''commit all changes to the database'''
130 if self.dirty:
131 self._db.commit()
132 for cl in self.classes.values():
133 cl._commit()
134 self.indexer.save_index()
135 self.dirty = 0
136 def rollback(self):
137 '''roll back all changes since the last commit'''
138 if self.dirty:
139 for cl in self.classes.values():
140 cl._rollback()
141 self._db.rollback()
142 self._db = None
143 self._db = metakit.storage(self.dbnm, 1)
144 self.hist = self._db.view('history')
145 self.tables = self._db.view('tables')
146 self.indexer.rollback()
147 self.indexer.datadb = self._db
148 self.dirty = 0
149 def clearCache(self):
150 '''clear the internal cache by committing all pending database changes'''
151 for cl in self.classes.values():
152 cl._commit()
153 def clear(self):
154 '''clear the internal cache but don't commit any changes'''
155 for cl in self.classes.values():
156 cl._clear()
157 def hasnode(self, classname, nodeid):
158 '''does a particular class contain a nodeid?'''
159 return self.getclass(classname).hasnode(nodeid)
160 def pack(self, pack_before):
161 ''' Delete all journal entries except "create" before 'pack_before'.
162 '''
163 mindate = int(calendar.timegm(pack_before.get_tuple()))
164 i = 0
165 while i < len(self.hist):
166 if self.hist[i].date < mindate and self.hist[i].action != _CREATE:
167 self.hist.delete(i)
168 else:
169 i = i + 1
170 def addclass(self, cl):
171 ''' Add a Class to the hyperdatabase.
172 '''
173 self.classes[cl.classname] = cl
174 if self.tables.find(name=cl.classname) < 0:
175 self.tables.append(name=cl.classname)
177 # add default Edit and View permissions
178 self.security.addPermission(name="Edit", klass=cl.classname,
179 description="User is allowed to edit "+cl.classname)
180 self.security.addPermission(name="View", klass=cl.classname,
181 description="User is allowed to access "+cl.classname)
183 def addjournal(self, tablenm, nodeid, action, params, creator=None,
184 creation=None):
185 ''' Journal the Action
186 'action' may be:
188 'create' or 'set' -- 'params' is a dictionary of property values
189 'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
190 'retire' -- 'params' is None
191 '''
192 tblid = self.tables.find(name=tablenm)
193 if tblid == -1:
194 tblid = self.tables.append(name=tablenm)
195 if creator is None:
196 creator = int(self.getuid())
197 else:
198 try:
199 creator = int(creator)
200 except TypeError:
201 creator = int(self.getclass('user').lookup(creator))
202 if creation is None:
203 creation = int(time.time())
204 elif isinstance(creation, date.Date):
205 creation = int(calendar.timegm(creation.get_tuple()))
206 # tableid:I,nodeid:I,date:I,user:I,action:I,params:B
207 self.hist.append(tableid=tblid,
208 nodeid=int(nodeid),
209 date=creation,
210 action=action,
211 user = creator,
212 params = marshal.dumps(params))
213 def getjournal(self, tablenm, nodeid):
214 ''' get the journal for id
215 '''
216 rslt = []
217 tblid = self.tables.find(name=tablenm)
218 if tblid == -1:
219 return rslt
220 q = self.hist.select(tableid=tblid, nodeid=int(nodeid))
221 if len(q) == 0:
222 raise IndexError, "no history for id %s in %s" % (nodeid, tablenm)
223 i = 0
224 #userclass = self.getclass('user')
225 for row in q:
226 try:
227 params = marshal.loads(row.params)
228 except ValueError:
229 print "history couldn't unmarshal %r" % row.params
230 params = {}
231 #usernm = userclass.get(str(row.user), 'username')
232 dt = date.Date(time.gmtime(row.date))
233 #rslt.append((nodeid, dt, usernm, _actionnames[row.action], params))
234 rslt.append((nodeid, dt, str(row.user), _actionnames[row.action],
235 params))
236 return rslt
238 def destroyjournal(self, tablenm, nodeid):
239 nodeid = int(nodeid)
240 tblid = self.tables.find(name=tablenm)
241 if tblid == -1:
242 return
243 i = 0
244 hist = self.hist
245 while i < len(hist):
246 if hist[i].tableid == tblid and hist[i].nodeid == nodeid:
247 hist.delete(i)
248 else:
249 i = i + 1
250 self.dirty = 1
252 def close(self):
253 ''' Close off the connection.
254 '''
255 # de-reference count the metakit databases,
256 # as this is the only way they will be closed
257 for cl in self.classes.values():
258 cl.db = None
259 self._db = None
260 if self.lockfile is not None:
261 locking.release_lock(self.lockfile)
262 if _dbs.has_key(self.config.DATABASE):
263 del _dbs[self.config.DATABASE]
264 if self.lockfile is not None:
265 self.lockfile.close()
266 self.lockfile = None
267 self.classes = {}
269 # force the indexer to close
270 self.indexer.close()
271 self.indexer = None
273 # --- internal
274 def __open(self):
275 ''' Open the metakit database
276 '''
277 # make the database dir if it doesn't exist
278 if not os.path.exists(self.config.DATABASE):
279 os.makedirs(self.config.DATABASE)
281 # figure the file names
282 self.dbnm = db = os.path.join(self.config.DATABASE, 'tracker.mk4')
283 lockfilenm = db[:-3]+'lck'
285 # get the database lock
286 self.lockfile = locking.acquire_lock(lockfilenm)
287 self.lockfile.write(str(os.getpid()))
288 self.lockfile.flush()
290 # see if the schema has changed since last db access
291 self.fastopen = 0
292 if os.path.exists(db):
293 dbtm = os.path.getmtime(db)
294 pkgnm = self.config.__name__.split('.')[0]
295 schemamod = sys.modules.get(pkgnm+'.dbinit', None)
296 if schemamod:
297 if os.path.exists(schemamod.__file__):
298 schematm = os.path.getmtime(schemamod.__file__)
299 if schematm < dbtm:
300 # found schema mod - it's older than the db
301 self.fastopen = 1
302 else:
303 # can't find schemamod - must be frozen
304 self.fastopen = 1
306 # open the db
307 db = metakit.storage(db, 1)
308 hist = db.view('history')
309 tables = db.view('tables')
310 if not self.fastopen:
311 # create the database if it's brand new
312 if not hist.structure():
313 hist = db.getas('history[tableid:I,nodeid:I,date:I,user:I,action:I,params:B]')
314 if not tables.structure():
315 tables = db.getas('tables[name:S]')
316 db.commit()
318 # we now have an open, initialised database
319 self.tables = tables
320 self.hist = hist
321 return db
323 def setid(self, classname, maxid):
324 ''' No-op in metakit
325 '''
326 pass
328 _STRINGTYPE = type('')
329 _LISTTYPE = type([])
330 _CREATE, _SET, _RETIRE, _LINK, _UNLINK, _RESTORE = range(6)
332 _actionnames = {
333 _CREATE : 'create',
334 _SET : 'set',
335 _RETIRE : 'retire',
336 _RESTORE : 'restore',
337 _LINK : 'link',
338 _UNLINK : 'unlink',
339 }
341 _marker = []
343 _ALLOWSETTINGPRIVATEPROPS = 0
345 class Class(hyperdb.Class):
346 ''' The handle to a particular class of nodes in a hyperdatabase.
348 All methods except __repr__ and getnode must be implemented by a
349 concrete backend Class of which this is one.
350 '''
352 privateprops = None
353 def __init__(self, db, classname, **properties):
354 if (properties.has_key('creation') or properties.has_key('activity')
355 or properties.has_key('creator')):
356 raise ValueError, '"creation", "activity" and "creator" are '\
357 'reserved'
358 if hasattr(db, classname):
359 raise ValueError, "Class %s already exists"%classname
361 self.db = db
362 self.classname = classname
363 self.key = None
364 self.ruprops = properties
365 self.privateprops = { 'id' : hyperdb.String(),
366 'activity' : hyperdb.Date(),
367 'creation' : hyperdb.Date(),
368 'creator' : hyperdb.Link('user') }
370 # event -> list of callables
371 self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
372 self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
374 view = self.__getview()
375 self.maxid = 1
376 if view:
377 self.maxid = view[-1].id + 1
378 self.uncommitted = {}
379 self.rbactions = []
381 # people reach inside!!
382 self.properties = self.ruprops
383 self.db.addclass(self)
384 self.idcache = {}
386 # default is to journal changes
387 self.do_journal = 1
389 def enableJournalling(self):
390 '''Turn journalling on for this class
391 '''
392 self.do_journal = 1
394 def disableJournalling(self):
395 '''Turn journalling off for this class
396 '''
397 self.do_journal = 0
399 #
400 # Detector/reactor interface
401 #
402 def audit(self, event, detector):
403 '''Register a detector
404 '''
405 l = self.auditors[event]
406 if detector not in l:
407 self.auditors[event].append(detector)
409 def fireAuditors(self, action, nodeid, newvalues):
410 '''Fire all registered auditors.
411 '''
412 for audit in self.auditors[action]:
413 audit(self.db, self, nodeid, newvalues)
415 def react(self, event, detector):
416 '''Register a reactor
417 '''
418 l = self.reactors[event]
419 if detector not in l:
420 self.reactors[event].append(detector)
422 def fireReactors(self, action, nodeid, oldvalues):
423 '''Fire all registered reactors.
424 '''
425 for react in self.reactors[action]:
426 react(self.db, self, nodeid, oldvalues)
428 # --- the hyperdb.Class methods
429 def create(self, **propvalues):
430 ''' Create a new node of this class and return its id.
432 The keyword arguments in 'propvalues' map property names to values.
434 The values of arguments must be acceptable for the types of their
435 corresponding properties or a TypeError is raised.
437 If this class has a key property, it must be present and its value
438 must not collide with other key strings or a ValueError is raised.
440 Any other properties on this class that are missing from the
441 'propvalues' dictionary are set to None.
443 If an id in a link or multilink property does not refer to a valid
444 node, an IndexError is raised.
445 '''
446 if not propvalues:
447 raise ValueError, "Need something to create!"
448 self.fireAuditors('create', None, propvalues)
449 newid = self.create_inner(**propvalues)
450 # self.set() (called in self.create_inner()) does reactors)
451 return newid
453 def create_inner(self, **propvalues):
454 ''' Called by create, in-between the audit and react calls.
455 '''
456 rowdict = {}
457 rowdict['id'] = newid = self.maxid
458 self.maxid += 1
459 ndx = self.getview(READWRITE).append(rowdict)
460 propvalues['#ISNEW'] = 1
461 try:
462 self.set(str(newid), **propvalues)
463 except Exception:
464 self.maxid -= 1
465 raise
466 return str(newid)
468 def get(self, nodeid, propname, default=_marker, cache=1):
469 '''Get the value of a property on an existing node of this class.
471 'nodeid' must be the id of an existing node of this class or an
472 IndexError is raised. 'propname' must be the name of a property
473 of this class or a KeyError is raised.
475 'cache' exists for backwards compatibility, and is not used.
476 '''
477 view = self.getview()
478 id = int(nodeid)
479 if cache == 0:
480 oldnode = self.uncommitted.get(id, None)
481 if oldnode and oldnode.has_key(propname):
482 raw = oldnode[propname]
483 converter = _converters.get(rutyp.__class__, None)
484 if converter:
485 return converter(raw)
486 return raw
487 ndx = self.idcache.get(id, None)
489 if ndx is None:
490 ndx = view.find(id=id)
491 if ndx < 0:
492 raise IndexError, "%s has no node %s" % (self.classname, nodeid)
493 self.idcache[id] = ndx
494 try:
495 raw = getattr(view[ndx], propname)
496 except AttributeError:
497 raise KeyError, propname
498 rutyp = self.ruprops.get(propname, None)
500 if rutyp is None:
501 rutyp = self.privateprops[propname]
503 converter = _converters.get(rutyp.__class__, None)
504 if converter:
505 raw = converter(raw)
506 return raw
508 def set(self, nodeid, **propvalues):
509 '''Modify a property on an existing node of this class.
511 'nodeid' must be the id of an existing node of this class or an
512 IndexError is raised.
514 Each key in 'propvalues' must be the name of a property of this
515 class or a KeyError is raised.
517 All values in 'propvalues' must be acceptable types for their
518 corresponding properties or a TypeError is raised.
520 If the value of the key property is set, it must not collide with
521 other key strings or a ValueError is raised.
523 If the value of a Link or Multilink property contains an invalid
524 node id, a ValueError is raised.
525 '''
526 isnew = 0
527 if propvalues.has_key('#ISNEW'):
528 isnew = 1
529 del propvalues['#ISNEW']
530 if not isnew:
531 self.fireAuditors('set', nodeid, propvalues)
532 if not propvalues:
533 return propvalues
534 if propvalues.has_key('id'):
535 raise KeyError, '"id" is reserved'
536 if self.db.journaltag is None:
537 raise hyperdb.DatabaseError, 'Database open read-only'
538 view = self.getview(READWRITE)
540 # node must exist & not be retired
541 id = int(nodeid)
542 ndx = view.find(id=id)
543 if ndx < 0:
544 raise IndexError, "%s has no node %s" % (self.classname, nodeid)
545 row = view[ndx]
546 if row._isdel:
547 raise IndexError, "%s has no node %s" % (self.classname, nodeid)
548 oldnode = self.uncommitted.setdefault(id, {})
549 changes = {}
551 for key, value in propvalues.items():
552 # this will raise the KeyError if the property isn't valid
553 # ... we don't use getprops() here because we only care about
554 # the writeable properties.
555 if _ALLOWSETTINGPRIVATEPROPS:
556 prop = self.ruprops.get(key, None)
557 if not prop:
558 prop = self.privateprops[key]
559 else:
560 prop = self.ruprops[key]
561 converter = _converters.get(prop.__class__, lambda v: v)
562 # if the value's the same as the existing value, no sense in
563 # doing anything
564 oldvalue = converter(getattr(row, key))
565 if value == oldvalue:
566 del propvalues[key]
567 continue
569 # check to make sure we're not duplicating an existing key
570 if key == self.key:
571 iv = self.getindexview(READWRITE)
572 ndx = iv.find(k=value)
573 if ndx == -1:
574 iv.append(k=value, i=row.id)
575 if not isnew:
576 ndx = iv.find(k=oldvalue)
577 if ndx > -1:
578 iv.delete(ndx)
579 else:
580 raise ValueError, 'node with key "%s" exists'%value
582 # do stuff based on the prop type
583 if isinstance(prop, hyperdb.Link):
584 link_class = prop.classname
585 # must be a string or None
586 if value is not None and not isinstance(value, type('')):
587 raise ValueError, 'property "%s" link value be a string'%(
588 key)
589 # Roundup sets to "unselected" by passing None
590 if value is None:
591 value = 0
592 # if it isn't a number, it's a key
593 try:
594 int(value)
595 except ValueError:
596 try:
597 value = self.db.getclass(link_class).lookup(value)
598 except (TypeError, KeyError):
599 raise IndexError, 'new property "%s": %s not a %s'%(
600 key, value, prop.classname)
602 if (value is not None and
603 not self.db.getclass(link_class).hasnode(value)):
604 raise IndexError, '%s has no node %s'%(link_class, value)
606 setattr(row, key, int(value))
607 changes[key] = oldvalue
609 if self.do_journal and prop.do_journal:
610 # register the unlink with the old linked node
611 if oldvalue:
612 self.db.addjournal(link_class, oldvalue, _UNLINK,
613 (self.classname, str(row.id), key))
615 # register the link with the newly linked node
616 if value:
617 self.db.addjournal(link_class, value, _LINK,
618 (self.classname, str(row.id), key))
620 elif isinstance(prop, hyperdb.Multilink):
621 if value is not None and type(value) != _LISTTYPE:
622 raise TypeError, 'new property "%s" not a list of ids'%key
623 link_class = prop.classname
624 l = []
625 if value is None:
626 value = []
627 for entry in value:
628 if type(entry) != _STRINGTYPE:
629 raise ValueError, 'new property "%s" link value ' \
630 'must be a string'%key
631 # if it isn't a number, it's a key
632 try:
633 int(entry)
634 except ValueError:
635 try:
636 entry = self.db.getclass(link_class).lookup(entry)
637 except (TypeError, KeyError):
638 raise IndexError, 'new property "%s": %s not a %s'%(
639 key, entry, prop.classname)
640 l.append(entry)
641 propvalues[key] = value = l
643 # handle removals
644 rmvd = []
645 for id in oldvalue:
646 if id not in value:
647 rmvd.append(id)
648 # register the unlink with the old linked node
649 if self.do_journal and prop.do_journal:
650 self.db.addjournal(link_class, id, _UNLINK,
651 (self.classname, str(row.id), key))
653 # handle additions
654 adds = []
655 for id in value:
656 if id not in oldvalue:
657 if not self.db.getclass(link_class).hasnode(id):
658 raise IndexError, '%s has no node %s'%(
659 link_class, id)
660 adds.append(id)
661 # register the link with the newly linked node
662 if self.do_journal and prop.do_journal:
663 self.db.addjournal(link_class, id, _LINK,
664 (self.classname, str(row.id), key))
666 # perform the modifications on the actual property value
667 sv = getattr(row, key)
668 i = 0
669 while i < len(sv):
670 if str(sv[i].fid) in rmvd:
671 sv.delete(i)
672 else:
673 i += 1
674 for id in adds:
675 sv.append(fid=int(id))
677 # figure the journal entry
678 l = []
679 if adds:
680 l.append(('+', adds))
681 if rmvd:
682 l.append(('-', rmvd))
683 if l:
684 changes[key] = tuple(l)
685 #changes[key] = oldvalue
687 if not rmvd and not adds:
688 del propvalues[key]
690 elif isinstance(prop, hyperdb.String):
691 if value is not None and type(value) != _STRINGTYPE:
692 raise TypeError, 'new property "%s" not a string'%key
693 if value is None:
694 value = ''
695 setattr(row, key, value)
696 changes[key] = oldvalue
697 if hasattr(prop, 'isfilename') and prop.isfilename:
698 propvalues[key] = os.path.basename(value)
699 if prop.indexme:
700 self.db.indexer.add_text((self.classname, nodeid, key),
701 value, 'text/plain')
703 elif isinstance(prop, hyperdb.Password):
704 if value is not None and not isinstance(value, password.Password):
705 raise TypeError, 'new property "%s" not a Password'% key
706 if value is None:
707 value = ''
708 setattr(row, key, str(value))
709 changes[key] = str(oldvalue)
710 propvalues[key] = str(value)
712 elif isinstance(prop, hyperdb.Date):
713 if value is not None and not isinstance(value, date.Date):
714 raise TypeError, 'new property "%s" not a Date'% key
715 if value is None:
716 setattr(row, key, 0)
717 else:
718 setattr(row, key, int(calendar.timegm(value.get_tuple())))
719 changes[key] = str(oldvalue)
720 propvalues[key] = str(value)
722 elif isinstance(prop, hyperdb.Interval):
723 if value is not None and not isinstance(value, date.Interval):
724 raise TypeError, 'new property "%s" not an Interval'% key
725 if value is None:
726 setattr(row, key, '')
727 else:
728 # kedder: we should store interval values serialized
729 setattr(row, key, value.serialise())
730 changes[key] = str(oldvalue)
731 propvalues[key] = str(value)
733 elif isinstance(prop, hyperdb.Number):
734 if value is None:
735 v = 0
736 else:
737 try:
738 v = int(value)
739 except ValueError:
740 raise TypeError, "%s (%s) is not numeric"%(key, repr(value))
741 if not BACKWARDS_COMPATIBLE:
742 if v >=0:
743 v = v + 1
744 setattr(row, key, v)
745 changes[key] = oldvalue
746 propvalues[key] = value
748 elif isinstance(prop, hyperdb.Boolean):
749 if value is None:
750 bv = 0
751 elif value not in (0,1):
752 raise TypeError, "%s (%s) is not boolean"%(key, repr(value))
753 else:
754 bv = value
755 if not BACKWARDS_COMPATIBLE:
756 bv += 1
757 setattr(row, key, bv)
758 changes[key] = oldvalue
759 propvalues[key] = value
761 oldnode[key] = oldvalue
763 # nothing to do?
764 if not propvalues:
765 return propvalues
766 if not propvalues.has_key('activity'):
767 row.activity = int(time.time())
768 if isnew:
769 if not row.creation:
770 row.creation = int(time.time())
771 if not row.creator:
772 row.creator = int(self.db.getuid())
774 self.db.dirty = 1
775 if self.do_journal:
776 if isnew:
777 self.db.addjournal(self.classname, nodeid, _CREATE, {})
778 self.fireReactors('create', nodeid, None)
779 else:
780 self.db.addjournal(self.classname, nodeid, _SET, changes)
781 self.fireReactors('set', nodeid, oldnode)
783 return propvalues
785 def retire(self, nodeid):
786 '''Retire a node.
788 The properties on the node remain available from the get() method,
789 and the node's id is never reused.
791 Retired nodes are not returned by the find(), list(), or lookup()
792 methods, and other nodes may reuse the values of their key properties.
793 '''
794 if self.db.journaltag is None:
795 raise hyperdb.DatabaseError, 'Database open read-only'
796 self.fireAuditors('retire', nodeid, None)
797 view = self.getview(READWRITE)
798 ndx = view.find(id=int(nodeid))
799 if ndx < 0:
800 raise KeyError, "nodeid %s not found" % nodeid
802 row = view[ndx]
803 oldvalues = self.uncommitted.setdefault(row.id, {})
804 oldval = oldvalues['_isdel'] = row._isdel
805 row._isdel = 1
807 if self.do_journal:
808 self.db.addjournal(self.classname, nodeid, _RETIRE, {})
809 if self.key:
810 iv = self.getindexview(READWRITE)
811 ndx = iv.find(k=getattr(row, self.key))
812 # find is broken with multiple attribute lookups
813 # on ordered views
814 #ndx = iv.find(k=getattr(row, self.key),i=row.id)
815 if ndx > -1 and iv[ndx].i == row.id:
816 iv.delete(ndx)
818 self.db.dirty = 1
819 self.fireReactors('retire', nodeid, None)
821 def restore(self, nodeid):
822 '''Restore a retired node.
824 Make node available for all operations like it was before retirement.
825 '''
826 if self.db.journaltag is None:
827 raise hyperdb.DatabaseError, 'Database open read-only'
829 # check if key property was overrided
830 key = self.getkey()
831 keyvalue = self.get(nodeid, key)
833 try:
834 id = self.lookup(keyvalue)
835 except KeyError:
836 pass
837 else:
838 raise KeyError, "Key property (%s) of retired node clashes with \
839 existing one (%s)" % (key, keyvalue)
840 # Now we can safely restore node
841 self.fireAuditors('restore', nodeid, None)
842 view = self.getview(READWRITE)
843 ndx = view.find(id=int(nodeid))
844 if ndx < 0:
845 raise KeyError, "nodeid %s not found" % nodeid
847 row = view[ndx]
848 oldvalues = self.uncommitted.setdefault(row.id, {})
849 oldval = oldvalues['_isdel'] = row._isdel
850 row._isdel = 0
852 if self.do_journal:
853 self.db.addjournal(self.classname, nodeid, _RESTORE, {})
854 if self.key:
855 iv = self.getindexview(READWRITE)
856 ndx = iv.find(k=getattr(row, self.key),i=row.id)
857 if ndx > -1:
858 iv.delete(ndx)
859 self.db.dirty = 1
860 self.fireReactors('restore', nodeid, None)
862 def is_retired(self, nodeid):
863 '''Return true if the node is retired
864 '''
865 view = self.getview(READWRITE)
866 # node must exist & not be retired
867 id = int(nodeid)
868 ndx = view.find(id=id)
869 if ndx < 0:
870 raise IndexError, "%s has no node %s" % (self.classname, nodeid)
871 row = view[ndx]
872 return row._isdel
874 def history(self, nodeid):
875 '''Retrieve the journal of edits on a particular node.
877 'nodeid' must be the id of an existing node of this class or an
878 IndexError is raised.
880 The returned list contains tuples of the form
882 (nodeid, date, tag, action, params)
884 'date' is a Timestamp object specifying the time of the change and
885 'tag' is the journaltag specified when the database was opened.
886 '''
887 if not self.do_journal:
888 raise ValueError, 'Journalling is disabled for this class'
889 return self.db.getjournal(self.classname, nodeid)
891 def setkey(self, propname):
892 '''Select a String property of this class to be the key property.
894 'propname' must be the name of a String property of this class or
895 None, or a TypeError is raised. The values of the key property on
896 all existing nodes must be unique or a ValueError is raised.
897 '''
898 if self.key:
899 if propname == self.key:
900 return
901 else:
902 # drop the old key table
903 tablename = "_%s.%s"%(self.classname, self.key)
904 self.db._db.getas(tablename)
906 #raise ValueError, "%s already indexed on %s"%(self.classname,
907 # self.key)
909 prop = self.properties.get(propname, None)
910 if prop is None:
911 prop = self.privateprops.get(propname, None)
912 if prop is None:
913 raise KeyError, "no property %s" % propname
914 if not isinstance(prop, hyperdb.String):
915 raise TypeError, "%s is not a String" % propname
917 # the way he index on properties is by creating a
918 # table named _%(classname)s.%(key)s, if this table
919 # exists then everything is okay. If this table
920 # doesn't exist, then generate a new table on the
921 # key value.
923 # first setkey for this run or key has been changed
924 self.key = propname
925 tablename = "_%s.%s"%(self.classname, self.key)
927 iv = self.db._db.view(tablename)
928 if self.db.fastopen and iv.structure():
929 return
931 # very first setkey ever or the key has changed
932 self.db.dirty = 1
933 iv = self.db._db.getas('_%s[k:S,i:I]' % tablename)
934 iv = iv.ordered(1)
935 for row in self.getview():
936 iv.append(k=getattr(row, propname), i=row.id)
937 self.db.commit()
939 def getkey(self):
940 '''Return the name of the key property for this class or None.'''
941 return self.key
943 def lookup(self, keyvalue):
944 '''Locate a particular node by its key property and return its id.
946 If this class has no key property, a TypeError is raised. If the
947 keyvalue matches one of the values for the key property among
948 the nodes in this class, the matching node's id is returned;
949 otherwise a KeyError is raised.
950 '''
951 if not self.key:
952 raise TypeError, 'No key property set for class %s'%self.classname
954 if type(keyvalue) is not _STRINGTYPE:
955 raise TypeError, '%r is not a string'%keyvalue
957 # XXX FIX ME -> this is a bit convoluted
958 # First we search the index view to get the id
959 # which is a quicker look up.
960 # Then we lookup the row with id=id
961 # if the _isdel property of the row is 0, return the
962 # string version of the id. (Why string version???)
963 #
964 # Otherwise, just lookup the non-indexed key
965 # in the non-index table and check the _isdel property
966 iv = self.getindexview()
967 if iv:
968 # look up the index view for the id,
969 # then instead of looking up the keyvalue, lookup the
970 # quicker id
971 ndx = iv.find(k=keyvalue)
972 if ndx > -1:
973 view = self.getview()
974 ndx = view.find(id=iv[ndx].i)
975 if ndx > -1:
976 row = view[ndx]
977 if not row._isdel:
978 return str(row.id)
979 else:
980 # perform the slower query
981 view = self.getview()
982 ndx = view.find({self.key:keyvalue})
983 if ndx > -1:
984 row = view[ndx]
985 if not row._isdel:
986 return str(row.id)
988 raise KeyError, keyvalue
990 def destroy(self, id):
991 '''Destroy a node.
993 WARNING: this method should never be used except in extremely rare
994 situations where there could never be links to the node being
995 deleted
997 WARNING: use retire() instead
999 WARNING: the properties of this node will not be available ever again
1001 WARNING: really, use retire() instead
1003 Well, I think that's enough warnings. This method exists mostly to
1004 support the session storage of the cgi interface.
1006 The node is completely removed from the hyperdb, including all journal
1007 entries. It will no longer be available, and will generally break code
1008 if there are any references to the node.
1009 '''
1010 view = self.getview(READWRITE)
1011 ndx = view.find(id=int(id))
1012 if ndx > -1:
1013 if self.key:
1014 keyvalue = getattr(view[ndx], self.key)
1015 iv = self.getindexview(READWRITE)
1016 if iv:
1017 ivndx = iv.find(k=keyvalue)
1018 if ivndx > -1:
1019 iv.delete(ivndx)
1020 view.delete(ndx)
1021 self.db.destroyjournal(self.classname, id)
1022 self.db.dirty = 1
1024 def find(self, **propspec):
1025 '''Get the ids of nodes in this class which link to the given nodes.
1027 'propspec'
1028 consists of keyword args propname={nodeid:1,}
1029 'propname'
1030 must be the name of a property in this class, or a
1031 KeyError is raised. That property must be a Link or
1032 Multilink property, or a TypeError is raised.
1034 Any node in this class whose propname property links to any of the
1035 nodeids will be returned. Used by the full text indexing, which knows
1036 that "foo" occurs in msg1, msg3 and file7; so we have hits on these
1037 issues::
1039 db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1040 '''
1041 propspec = propspec.items()
1042 for propname, nodeid in propspec:
1043 # check the prop is OK
1044 prop = self.ruprops[propname]
1045 if (not isinstance(prop, hyperdb.Link) and
1046 not isinstance(prop, hyperdb.Multilink)):
1047 raise TypeError, "'%s' not a Link/Multilink property"%propname
1049 vws = []
1050 for propname, ids in propspec:
1051 if type(ids) is _STRINGTYPE:
1052 ids = {int(ids):1}
1053 elif ids is None:
1054 ids = {0:1}
1055 else:
1056 d = {}
1057 for id in ids.keys():
1058 if id is None:
1059 d[0] = 1
1060 else:
1061 d[int(id)] = 1
1062 ids = d
1063 prop = self.ruprops[propname]
1064 view = self.getview()
1065 if isinstance(prop, hyperdb.Multilink):
1066 def ff(row, nm=propname, ids=ids):
1067 if not row._isdel:
1068 sv = getattr(row, nm)
1069 for sr in sv:
1070 if ids.has_key(sr.fid):
1071 return 1
1072 return 0
1073 else:
1074 def ff(row, nm=propname, ids=ids):
1075 return not row._isdel and ids.has_key(getattr(row, nm))
1076 ndxview = view.filter(ff)
1077 vws.append(ndxview.unique())
1079 # handle the empty match case
1080 if not vws:
1081 return []
1083 ndxview = vws[0]
1084 for v in vws[1:]:
1085 ndxview = ndxview.union(v)
1086 view = self.getview().remapwith(ndxview)
1087 rslt = []
1088 for row in view:
1089 rslt.append(str(row.id))
1090 return rslt
1093 def list(self):
1094 ''' Return a list of the ids of the active nodes in this class.
1095 '''
1096 l = []
1097 for row in self.getview().select(_isdel=0):
1098 l.append(str(row.id))
1099 return l
1101 def getnodeids(self):
1102 ''' Retrieve all the ids of the nodes for a particular Class.
1104 Set retired=None to get all nodes. Otherwise it'll get all the
1105 retired or non-retired nodes, depending on the flag.
1106 '''
1107 l = []
1108 for row in self.getview():
1109 l.append(str(row.id))
1110 return l
1112 def count(self):
1113 return len(self.getview())
1115 def getprops(self, protected=1):
1116 # protected is not in ping's spec
1117 allprops = self.ruprops.copy()
1118 if protected and self.privateprops is not None:
1119 allprops.update(self.privateprops)
1120 return allprops
1122 def addprop(self, **properties):
1123 for key in properties.keys():
1124 if self.ruprops.has_key(key):
1125 raise ValueError, "%s is already a property of %s"%(key,
1126 self.classname)
1127 self.ruprops.update(properties)
1128 # Class structure has changed
1129 self.db.fastopen = 0
1130 view = self.__getview()
1131 self.db.commit()
1132 # ---- end of ping's spec
1134 def filter(self, search_matches, filterspec, sort=(None,None),
1135 group=(None,None)):
1136 '''Return a list of the ids of the active nodes in this class that
1137 match the 'filter' spec, sorted by the group spec and then the
1138 sort spec
1140 "filterspec" is {propname: value(s)}
1142 "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1143 and prop is a prop name or None
1145 "search_matches" is {nodeid: marker}
1147 The filter must match all properties specificed - but if the
1148 property value to match is a list, any one of the values in the
1149 list may match for that property to match.
1150 '''
1151 # search_matches is None or a set (dict of {nodeid: {propname:[nodeid,...]}})
1152 # filterspec is a dict {propname:value}
1153 # sort and group are (dir, prop) where dir is '+', '-' or None
1154 # and prop is a prop name or None
1156 timezone = self.db.getUserTimezone()
1158 where = {'_isdel':0}
1159 wherehigh = {}
1160 mlcriteria = {}
1161 regexes = {}
1162 orcriteria = {}
1163 for propname, value in filterspec.items():
1164 prop = self.ruprops.get(propname, None)
1165 if prop is None:
1166 prop = self.privateprops[propname]
1167 if isinstance(prop, hyperdb.Multilink):
1168 if value in ('-1', ['-1']):
1169 value = []
1170 elif type(value) is not _LISTTYPE:
1171 value = [value]
1172 # transform keys to ids
1173 u = []
1174 for item in value:
1175 try:
1176 item = int(item)
1177 except (TypeError, ValueError):
1178 item = int(self.db.getclass(prop.classname).lookup(item))
1179 if item == -1:
1180 item = 0
1181 u.append(item)
1182 mlcriteria[propname] = u
1183 elif isinstance(prop, hyperdb.Link):
1184 if type(value) is not _LISTTYPE:
1185 value = [value]
1186 # transform keys to ids
1187 u = []
1188 for item in value:
1189 try:
1190 item = int(item)
1191 except (TypeError, ValueError):
1192 item = int(self.db.getclass(prop.classname).lookup(item))
1193 if item == -1:
1194 item = 0
1195 u.append(item)
1196 if len(u) == 1:
1197 where[propname] = u[0]
1198 else:
1199 orcriteria[propname] = u
1200 elif isinstance(prop, hyperdb.String):
1201 if type(value) is not type([]):
1202 value = [value]
1203 m = []
1204 for v in value:
1205 # simple glob searching
1206 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1207 v = v.replace('?', '.')
1208 v = v.replace('*', '.*?')
1209 m.append(v)
1210 regexes[propname] = re.compile('(%s)'%('|'.join(m)), re.I)
1211 elif propname == 'id':
1212 where[propname] = int(value)
1213 elif isinstance(prop, hyperdb.Boolean):
1214 if type(value) is _STRINGTYPE:
1215 bv = value.lower() in ('yes', 'true', 'on', '1')
1216 else:
1217 bv = value
1218 where[propname] = bv
1219 elif isinstance(prop, hyperdb.Date):
1220 try:
1221 # Try to filter on range of dates
1222 date_rng = Range(value, date.Date, offset=timezone)
1223 if date_rng.from_value:
1224 t = date_rng.from_value.get_tuple()
1225 where[propname] = int(calendar.timegm(t))
1226 else:
1227 # use minimum possible value to exclude items without
1228 # 'prop' property
1229 where[propname] = 0
1230 if date_rng.to_value:
1231 t = date_rng.to_value.get_tuple()
1232 wherehigh[propname] = int(calendar.timegm(t))
1233 else:
1234 wherehigh[propname] = None
1235 except ValueError:
1236 # If range creation fails - ignore that search parameter
1237 pass
1238 elif isinstance(prop, hyperdb.Interval):
1239 try:
1240 # Try to filter on range of intervals
1241 date_rng = Range(value, date.Interval)
1242 if date_rng.from_value:
1243 #t = date_rng.from_value.get_tuple()
1244 where[propname] = date_rng.from_value.serialise()
1245 else:
1246 # use minimum possible value to exclude items without
1247 # 'prop' property
1248 where[propname] = '-99999999999999'
1249 if date_rng.to_value:
1250 #t = date_rng.to_value.get_tuple()
1251 wherehigh[propname] = date_rng.to_value.serialise()
1252 else:
1253 wherehigh[propname] = None
1254 except ValueError:
1255 # If range creation fails - ignore that search parameter
1256 pass
1257 elif isinstance(prop, hyperdb.Number):
1258 where[propname] = int(value)
1259 else:
1260 where[propname] = str(value)
1261 v = self.getview()
1262 #print "filter start at %s" % time.time()
1263 if where:
1264 where_higherbound = where.copy()
1265 where_higherbound.update(wherehigh)
1266 v = v.select(where, where_higherbound)
1267 #print "filter where at %s" % time.time()
1269 if mlcriteria:
1270 # multilink - if any of the nodeids required by the
1271 # filterspec aren't in this node's property, then skip it
1272 def ff(row, ml=mlcriteria):
1273 for propname, values in ml.items():
1274 sv = getattr(row, propname)
1275 if not values and sv:
1276 return 0
1277 for id in values:
1278 if sv.find(fid=id) == -1:
1279 return 0
1280 return 1
1281 iv = v.filter(ff)
1282 v = v.remapwith(iv)
1284 #print "filter mlcrit at %s" % time.time()
1286 if orcriteria:
1287 def ff(row, crit=orcriteria):
1288 for propname, allowed in crit.items():
1289 val = getattr(row, propname)
1290 if val not in allowed:
1291 return 0
1292 return 1
1294 iv = v.filter(ff)
1295 v = v.remapwith(iv)
1297 #print "filter orcrit at %s" % time.time()
1298 if regexes:
1299 def ff(row, r=regexes):
1300 for propname, regex in r.items():
1301 val = str(getattr(row, propname))
1302 if not regex.search(val):
1303 return 0
1304 return 1
1306 iv = v.filter(ff)
1307 v = v.remapwith(iv)
1308 #print "filter regexs at %s" % time.time()
1310 if sort or group:
1311 sortspec = []
1312 rev = []
1313 for dir, propname in group, sort:
1314 if propname is None: continue
1315 isreversed = 0
1316 if dir == '-':
1317 isreversed = 1
1318 try:
1319 prop = getattr(v, propname)
1320 except AttributeError:
1321 print "MK has no property %s" % propname
1322 continue
1323 propclass = self.ruprops.get(propname, None)
1324 if propclass is None:
1325 propclass = self.privateprops.get(propname, None)
1326 if propclass is None:
1327 print "Schema has no property %s" % propname
1328 continue
1329 if isinstance(propclass, hyperdb.Link):
1330 linkclass = self.db.getclass(propclass.classname)
1331 lv = linkclass.getview()
1332 lv = lv.rename('id', propname)
1333 v = v.join(lv, prop, 1)
1334 if linkclass.getprops().has_key('order'):
1335 propname = 'order'
1336 else:
1337 propname = linkclass.labelprop()
1338 prop = getattr(v, propname)
1339 if isreversed:
1340 rev.append(prop)
1341 sortspec.append(prop)
1342 v = v.sortrev(sortspec, rev)[:] #XXX Metakit bug
1343 #print "filter sort at %s" % time.time()
1345 rslt = []
1346 for row in v:
1347 id = str(row.id)
1348 if search_matches is not None:
1349 if search_matches.has_key(id):
1350 rslt.append(id)
1351 else:
1352 rslt.append(id)
1353 return rslt
1355 def hasnode(self, nodeid):
1356 '''Determine if the given nodeid actually exists
1357 '''
1358 return int(nodeid) < self.maxid
1360 def labelprop(self, default_to_id=0):
1361 '''Return the property name for a label for the given node.
1363 This method attempts to generate a consistent label for the node.
1364 It tries the following in order:
1366 1. key property
1367 2. "name" property
1368 3. "title" property
1369 4. first property from the sorted property name list
1370 '''
1371 k = self.getkey()
1372 if k:
1373 return k
1374 props = self.getprops()
1375 if props.has_key('name'):
1376 return 'name'
1377 elif props.has_key('title'):
1378 return 'title'
1379 if default_to_id:
1380 return 'id'
1381 props = props.keys()
1382 props.sort()
1383 return props[0]
1385 def stringFind(self, **requirements):
1386 '''Locate a particular node by matching a set of its String
1387 properties in a caseless search.
1389 If the property is not a String property, a TypeError is raised.
1391 The return is a list of the id of all nodes that match.
1392 '''
1393 for propname in requirements.keys():
1394 prop = self.properties[propname]
1395 if isinstance(not prop, hyperdb.String):
1396 raise TypeError, "'%s' not a String property"%propname
1397 requirements[propname] = requirements[propname].lower()
1398 requirements['_isdel'] = 0
1400 l = []
1401 for row in self.getview().select(requirements):
1402 l.append(str(row.id))
1403 return l
1405 def addjournal(self, nodeid, action, params):
1406 '''Add a journal to the given nodeid,
1407 'action' may be:
1409 'create' or 'set' -- 'params' is a dictionary of property values
1410 'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
1411 'retire' -- 'params' is None
1412 '''
1413 self.db.addjournal(self.classname, nodeid, action, params)
1415 def index(self, nodeid):
1416 ''' Add (or refresh) the node to search indexes '''
1417 # find all the String properties that have indexme
1418 for prop, propclass in self.getprops().items():
1419 if isinstance(propclass, hyperdb.String) and propclass.indexme:
1420 # index them under (classname, nodeid, property)
1421 self.db.indexer.add_text((self.classname, nodeid, prop),
1422 str(self.get(nodeid, prop)))
1424 def export_list(self, propnames, nodeid):
1425 ''' Export a node - generate a list of CSV-able data in the order
1426 specified by propnames for the given node.
1427 '''
1428 properties = self.getprops()
1429 l = []
1430 for prop in propnames:
1431 proptype = properties[prop]
1432 value = self.get(nodeid, prop)
1433 # "marshal" data where needed
1434 if value is None:
1435 pass
1436 elif isinstance(proptype, hyperdb.Date):
1437 value = value.get_tuple()
1438 elif isinstance(proptype, hyperdb.Interval):
1439 value = value.get_tuple()
1440 elif isinstance(proptype, hyperdb.Password):
1441 value = str(value)
1442 l.append(repr(value))
1444 # append retired flag
1445 l.append(repr(self.is_retired(nodeid)))
1447 return l
1449 def import_list(self, propnames, proplist):
1450 ''' Import a node - all information including "id" is present and
1451 should not be sanity checked. Triggers are not triggered. The
1452 journal should be initialised using the "creator" and "creation"
1453 information.
1455 Return the nodeid of the node imported.
1456 '''
1457 if self.db.journaltag is None:
1458 raise hyperdb.DatabaseError, 'Database open read-only'
1459 properties = self.getprops()
1461 d = {}
1462 view = self.getview(READWRITE)
1463 for i in range(len(propnames)):
1464 value = eval(proplist[i])
1465 if not value:
1466 continue
1468 propname = propnames[i]
1469 if propname == 'id':
1470 newid = value = int(value)
1471 elif propname == 'is retired':
1472 # is the item retired?
1473 if int(value):
1474 d['_isdel'] = 1
1475 continue
1476 elif value is None:
1477 d[propname] = None
1478 continue
1480 prop = properties[propname]
1481 if isinstance(prop, hyperdb.Date):
1482 value = int(calendar.timegm(value))
1483 elif isinstance(prop, hyperdb.Interval):
1484 value = date.Interval(value).serialise()
1485 elif isinstance(prop, hyperdb.Number):
1486 value = int(value)
1487 elif isinstance(prop, hyperdb.Boolean):
1488 value = int(value)
1489 elif isinstance(prop, hyperdb.Link) and value:
1490 value = int(value)
1491 elif isinstance(prop, hyperdb.Multilink):
1492 # we handle multilinks separately
1493 continue
1494 d[propname] = value
1496 # possibly make a new node
1497 if not d.has_key('id'):
1498 d['id'] = newid = self.maxid
1499 self.maxid += 1
1501 # save off the node
1502 view.append(d)
1504 # fix up multilinks
1505 ndx = view.find(id=newid)
1506 row = view[ndx]
1507 for i in range(len(propnames)):
1508 value = eval(proplist[i])
1509 propname = propnames[i]
1510 if propname == 'is retired':
1511 continue
1512 prop = properties[propname]
1513 if not isinstance(prop, hyperdb.Multilink):
1514 continue
1515 sv = getattr(row, propname)
1516 for entry in value:
1517 sv.append((int(entry),))
1519 self.db.dirty = 1
1520 creator = d.get('creator', 0)
1521 creation = d.get('creation', 0)
1522 self.db.addjournal(self.classname, str(newid), _CREATE, {}, creator,
1523 creation)
1524 return newid
1526 # --- used by Database
1527 def _commit(self):
1528 ''' called post commit of the DB.
1529 interested subclasses may override '''
1530 self.uncommitted = {}
1531 self.rbactions = []
1532 self.idcache = {}
1533 def _rollback(self):
1534 ''' called pre rollback of the DB.
1535 interested subclasses may override '''
1536 for action in self.rbactions:
1537 action()
1538 self.rbactions = []
1539 self.uncommitted = {}
1540 self.idcache = {}
1541 def _clear(self):
1542 view = self.getview(READWRITE)
1543 if len(view):
1544 view[:] = []
1545 self.db.dirty = 1
1546 iv = self.getindexview(READWRITE)
1547 if iv:
1548 iv[:] = []
1549 def rollbackaction(self, action):
1550 ''' call this to register a callback called on rollback
1551 callback is removed on end of transaction '''
1552 self.rbactions.append(action)
1553 # --- internal
1554 def __getview(self):
1555 ''' Find the interface for a specific Class in the hyperdb.
1557 This method checks to see whether the schema has changed and
1558 re-works the underlying metakit structure if it has.
1559 '''
1560 db = self.db._db
1561 view = db.view(self.classname)
1562 mkprops = view.structure()
1564 # if we have structure in the database, and the structure hasn't
1565 # changed
1566 # note on view.ordered ->
1567 # return a metakit view ordered on the id column
1568 # id is always the first column. This speeds up
1569 # look-ups on the id column.
1571 if mkprops and self.db.fastopen:
1572 return view.ordered(1)
1574 # is the definition the same?
1575 for nm, rutyp in self.ruprops.items():
1576 for mkprop in mkprops:
1577 if mkprop.name == nm:
1578 break
1579 else:
1580 mkprop = None
1581 if mkprop is None:
1582 break
1583 if _typmap[rutyp.__class__] != mkprop.type:
1584 break
1585 else:
1587 return view.ordered(1)
1588 # The schema has changed. We need to create or restructure the mk view
1589 # id comes first, so we can use view.ordered(1) so that
1590 # MK will order it for us to allow binary-search quick lookups on
1591 # the id column
1592 self.db.dirty = 1
1593 s = ["%s[id:I" % self.classname]
1595 # these columns will always be added, we can't trample them :)
1596 _columns = {"id":"I", "_isdel":"I", "activity":"I", "creation":"I", "creator":"I"}
1598 for nm, rutyp in self.ruprops.items():
1599 mktyp = _typmap[rutyp.__class__].upper()
1600 if nm in _columns and _columns[nm] != mktyp:
1601 # oops, two columns with the same name and different properties
1602 raise MKBackendError("column %s for table %sis defined with multiple types"%(nm, self.classname))
1603 _columns[nm] = mktyp
1604 s.append('%s:%s' % (nm, mktyp))
1605 if mktyp == 'V':
1606 s[-1] += ('[fid:I]')
1608 # XXX FIX ME -> in some tests, creation:I becomes creation:S is this
1609 # okay? Does this need to be supported?
1610 s.append('_isdel:I,activity:I,creation:I,creator:I]')
1611 view = self.db._db.getas(','.join(s))
1612 self.db.commit()
1613 return view.ordered(1)
1614 def getview(self, RW=0):
1615 # XXX FIX ME -> The RW flag doesn't do anything.
1616 return self.db._db.view(self.classname).ordered(1)
1617 def getindexview(self, RW=0):
1618 # XXX FIX ME -> The RW flag doesn't do anything.
1619 tablename = "_%s.%s"%(self.classname, self.key)
1620 return self.db._db.view("_%s" % tablename).ordered(1)
1622 def _fetchML(sv):
1623 l = []
1624 for row in sv:
1625 if row.fid:
1626 l.append(str(row.fid))
1627 return l
1629 def _fetchPW(s):
1630 ''' Convert to a password.Password unless the password is '' which is
1631 our sentinel for "unset".
1632 '''
1633 if s == '':
1634 return None
1635 p = password.Password()
1636 p.unpack(s)
1637 return p
1639 def _fetchLink(n):
1640 ''' Return None if the link is 0 - otherwise strify it.
1641 '''
1642 return n and str(n) or None
1644 def _fetchDate(n):
1645 ''' Convert the timestamp to a date.Date instance - unless it's 0 which
1646 is our sentinel for "unset".
1647 '''
1648 if n == 0:
1649 return None
1650 return date.Date(time.gmtime(n))
1652 def _fetchInterval(n):
1653 ''' Convert to a date.Interval unless the interval is '' which is our
1654 sentinel for "unset".
1655 '''
1656 if n == '':
1657 return None
1658 return date.Interval(n)
1660 # Converters for boolean and numbers to properly
1661 # return None values.
1662 # These are in conjunction with the setters above
1663 # look for hyperdb.Boolean and hyperdb.Number
1664 if BACKWARDS_COMPATIBLE:
1665 def getBoolean(bool): return bool
1666 def getNumber(number): return number
1667 else:
1668 def getBoolean(bool):
1669 if not bool: res = None
1670 else: res = bool - 1
1671 return res
1673 def getNumber(number):
1674 if number == 0: res = None
1675 elif number < 0: res = number
1676 else: res = number - 1
1677 return res
1679 _converters = {
1680 hyperdb.Date : _fetchDate,
1681 hyperdb.Link : _fetchLink,
1682 hyperdb.Multilink : _fetchML,
1683 hyperdb.Interval : _fetchInterval,
1684 hyperdb.Password : _fetchPW,
1685 hyperdb.Boolean : getBoolean,
1686 hyperdb.Number : getNumber,
1687 hyperdb.String : lambda s: s and str(s) or None,
1688 }
1690 class FileName(hyperdb.String):
1691 isfilename = 1
1693 _typmap = {
1694 FileName : 'S',
1695 hyperdb.String : 'S',
1696 hyperdb.Date : 'I',
1697 hyperdb.Link : 'I',
1698 hyperdb.Multilink : 'V',
1699 hyperdb.Interval : 'S',
1700 hyperdb.Password : 'S',
1701 hyperdb.Boolean : 'I',
1702 hyperdb.Number : 'I',
1703 }
1704 class FileClass(Class, hyperdb.FileClass):
1705 ''' like Class but with a content property
1706 '''
1707 default_mime_type = 'text/plain'
1708 def __init__(self, db, classname, **properties):
1709 properties['content'] = FileName()
1710 if not properties.has_key('type'):
1711 properties['type'] = hyperdb.String()
1712 Class.__init__(self, db, classname, **properties)
1714 def get(self, nodeid, propname, default=_marker, cache=1):
1715 x = Class.get(self, nodeid, propname, default)
1716 poss_msg = 'Possibly an access right configuration problem.'
1717 if propname == 'content':
1718 if x.startswith('file:'):
1719 fnm = x[5:]
1720 try:
1721 f = open(fnm, 'rb')
1722 except IOError, (strerror):
1723 # XXX by catching this we donot see an error in the log.
1724 return 'ERROR reading file: %s%s\n%s\n%s'%(
1725 self.classname, nodeid, poss_msg, strerror)
1726 x = f.read()
1727 f.close()
1728 return x
1730 def create(self, **propvalues):
1731 if not propvalues:
1732 raise ValueError, "Need something to create!"
1733 self.fireAuditors('create', None, propvalues)
1734 content = propvalues['content']
1735 del propvalues['content']
1736 newid = Class.create_inner(self, **propvalues)
1737 if not content:
1738 return newid
1739 nm = bnm = '%s%s' % (self.classname, newid)
1740 sd = str(int(int(newid) / 1000))
1741 d = os.path.join(self.db.config.DATABASE, 'files', self.classname, sd)
1742 if not os.path.exists(d):
1743 os.makedirs(d)
1744 nm = os.path.join(d, nm)
1745 open(nm, 'wb').write(content)
1746 self.set(newid, content = 'file:'+nm)
1747 mimetype = propvalues.get('type', self.default_mime_type)
1748 self.db.indexer.add_text((self.classname, newid, 'content'), content,
1749 mimetype)
1750 def undo(fnm=nm, action1=os.remove, indexer=self.db.indexer):
1751 action1(fnm)
1752 self.rollbackaction(undo)
1753 return newid
1755 def index(self, nodeid):
1756 Class.index(self, nodeid)
1757 mimetype = self.get(nodeid, 'type')
1758 if not mimetype:
1759 mimetype = self.default_mime_type
1760 self.db.indexer.add_text((self.classname, nodeid, 'content'),
1761 self.get(nodeid, 'content'), mimetype)
1763 class IssueClass(Class, roundupdb.IssueClass):
1764 ''' The newly-created class automatically includes the "messages",
1765 "files", "nosy", and "superseder" properties. If the 'properties'
1766 dictionary attempts to specify any of these properties or a
1767 "creation" or "activity" property, a ValueError is raised.
1768 '''
1769 def __init__(self, db, classname, **properties):
1770 if not properties.has_key('title'):
1771 properties['title'] = hyperdb.String(indexme='yes')
1772 if not properties.has_key('messages'):
1773 properties['messages'] = hyperdb.Multilink("msg")
1774 if not properties.has_key('files'):
1775 properties['files'] = hyperdb.Multilink("file")
1776 if not properties.has_key('nosy'):
1777 # note: journalling is turned off as it really just wastes
1778 # space. this behaviour may be overridden in an instance
1779 properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
1780 if not properties.has_key('superseder'):
1781 properties['superseder'] = hyperdb.Multilink(classname)
1782 Class.__init__(self, db, classname, **properties)
1784 CURVERSION = 2
1786 class Indexer(indexer.Indexer):
1787 disallows = {'THE':1, 'THIS':1, 'ZZZ':1, 'THAT':1, 'WITH':1}
1788 def __init__(self, path, datadb):
1789 self.path = os.path.join(path, 'index.mk4')
1790 self.db = metakit.storage(self.path, 1)
1791 self.datadb = datadb
1792 self.reindex = 0
1793 v = self.db.view('version')
1794 if not v.structure():
1795 v = self.db.getas('version[vers:I]')
1796 self.db.commit()
1797 v.append(vers=CURVERSION)
1798 self.reindex = 1
1799 elif v[0].vers != CURVERSION:
1800 v[0].vers = CURVERSION
1801 self.reindex = 1
1802 if self.reindex:
1803 self.db.getas('ids[tblid:I,nodeid:I,propid:I,ignore:I]')
1804 self.db.getas('index[word:S,hits[pos:I]]')
1805 self.db.commit()
1806 self.reindex = 1
1807 self.changed = 0
1808 self.propcache = {}
1810 def close(self):
1811 '''close the indexing database'''
1812 del self.db
1813 self.db = None
1815 def force_reindex(self):
1816 '''Force a reindexing of the database. This essentially
1817 empties the tables ids and index and sets a flag so
1818 that the databases are reindexed'''
1819 v = self.db.view('ids')
1820 v[:] = []
1821 v = self.db.view('index')
1822 v[:] = []
1823 self.db.commit()
1824 self.reindex = 1
1826 def should_reindex(self):
1827 '''returns True if the indexes need to be rebuilt'''
1828 return self.reindex
1830 def _getprops(self, classname):
1831 props = self.propcache.get(classname, None)
1832 if props is None:
1833 props = self.datadb.view(classname).structure()
1834 props = [prop.name for prop in props]
1835 self.propcache[classname] = props
1836 return props
1838 def _getpropid(self, classname, propname):
1839 return self._getprops(classname).index(propname)
1841 def _getpropname(self, classname, propid):
1842 return self._getprops(classname)[propid]
1844 def add_text(self, identifier, text, mime_type='text/plain'):
1845 if mime_type != 'text/plain':
1846 return
1847 classname, nodeid, property = identifier
1848 tbls = self.datadb.view('tables')
1849 tblid = tbls.find(name=classname)
1850 if tblid < 0:
1851 raise KeyError, "unknown class %r"%classname
1852 nodeid = int(nodeid)
1853 propid = self._getpropid(classname, property)
1854 ids = self.db.view('ids')
1855 oldpos = ids.find(tblid=tblid,nodeid=nodeid,propid=propid,ignore=0)
1856 if oldpos > -1:
1857 ids[oldpos].ignore = 1
1858 self.changed = 1
1859 pos = ids.append(tblid=tblid,nodeid=nodeid,propid=propid)
1861 wordlist = re.findall(r'\b\w{2,25}\b', text.upper())
1862 words = {}
1863 for word in wordlist:
1864 if not self.disallows.has_key(word):
1865 words[word] = 1
1866 words = words.keys()
1868 index = self.db.view('index').ordered(1)
1869 for word in words:
1870 ndx = index.find(word=word)
1871 if ndx < 0:
1872 index.append(word=word)
1873 ndx = index.find(word=word)
1874 index[ndx].hits.append(pos=pos)
1875 self.changed = 1
1877 def find(self, wordlist):
1878 '''look up all the words in the wordlist.
1879 If none are found return an empty dictionary
1880 * more rules here
1881 '''
1882 hits = None
1883 index = self.db.view('index').ordered(1)
1884 for word in wordlist:
1885 word = word.upper()
1886 if not 2 < len(word) < 26:
1887 continue
1888 ndx = index.find(word=word)
1889 if ndx < 0:
1890 return {}
1891 if hits is None:
1892 hits = index[ndx].hits
1893 else:
1894 hits = hits.intersect(index[ndx].hits)
1895 if len(hits) == 0:
1896 return {}
1897 if hits is None:
1898 return {}
1899 rslt = {}
1900 ids = self.db.view('ids').remapwith(hits)
1901 tbls = self.datadb.view('tables')
1902 for i in range(len(ids)):
1903 hit = ids[i]
1904 if not hit.ignore:
1905 classname = tbls[hit.tblid].name
1906 nodeid = str(hit.nodeid)
1907 property = self._getpropname(classname, hit.propid)
1908 rslt[i] = (classname, nodeid, property)
1909 return rslt
1911 def save_index(self):
1912 if self.changed:
1913 self.db.commit()
1914 self.changed = 0
1916 def rollback(self):
1917 if self.changed:
1918 self.db.rollback()
1919 self.db = metakit.storage(self.path, 1)
1920 self.changed = 0