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