1 # $Id: back_metakit.py,v 1.70 2004-04-02 05: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 = 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))
215 def setjournal(self, tablenm, nodeid, journal):
216 '''Set the journal to the "journal" list.'''
217 tblid = self.tables.find(name=tablenm)
218 if tblid == -1:
219 tblid = self.tables.append(name=tablenm)
220 for nodeid, date, user, action, params in journal:
221 # tableid:I,nodeid:I,date:I,user:I,action:I,params:B
222 self.hist.append(tableid=tblid,
223 nodeid=int(nodeid),
224 date=date,
225 action=action,
226 user=user,
227 params=marshal.dumps(params))
229 def getjournal(self, tablenm, nodeid):
230 ''' get the journal for id
231 '''
232 rslt = []
233 tblid = self.tables.find(name=tablenm)
234 if tblid == -1:
235 return rslt
236 q = self.hist.select(tableid=tblid, nodeid=int(nodeid))
237 if len(q) == 0:
238 raise IndexError, "no history for id %s in %s" % (nodeid, tablenm)
239 i = 0
240 #userclass = self.getclass('user')
241 for row in q:
242 try:
243 params = marshal.loads(row.params)
244 except ValueError:
245 print "history couldn't unmarshal %r" % row.params
246 params = {}
247 #usernm = userclass.get(str(row.user), 'username')
248 dt = date.Date(time.gmtime(row.date))
249 #rslt.append((nodeid, dt, usernm, _actionnames[row.action], params))
250 rslt.append((nodeid, dt, str(row.user), _actionnames[row.action],
251 params))
252 return rslt
254 def destroyjournal(self, tablenm, nodeid):
255 nodeid = int(nodeid)
256 tblid = self.tables.find(name=tablenm)
257 if tblid == -1:
258 return
259 i = 0
260 hist = self.hist
261 while i < len(hist):
262 if hist[i].tableid == tblid and hist[i].nodeid == nodeid:
263 hist.delete(i)
264 else:
265 i = i + 1
266 self.dirty = 1
268 def close(self):
269 ''' Close off the connection.
270 '''
271 # de-reference count the metakit databases,
272 # as this is the only way they will be closed
273 for cl in self.classes.values():
274 cl.db = None
275 self._db = None
276 if self.lockfile is not None:
277 locking.release_lock(self.lockfile)
278 if _dbs.has_key(self.config.DATABASE):
279 del _dbs[self.config.DATABASE]
280 if self.lockfile is not None:
281 self.lockfile.close()
282 self.lockfile = None
283 self.classes = {}
285 # force the indexer to close
286 self.indexer.close()
287 self.indexer = None
289 # --- internal
290 def __open(self):
291 ''' Open the metakit database
292 '''
293 # make the database dir if it doesn't exist
294 if not os.path.exists(self.config.DATABASE):
295 os.makedirs(self.config.DATABASE)
297 # figure the file names
298 self.dbnm = db = os.path.join(self.config.DATABASE, 'tracker.mk4')
299 lockfilenm = db[:-3]+'lck'
301 # get the database lock
302 self.lockfile = locking.acquire_lock(lockfilenm)
303 self.lockfile.write(str(os.getpid()))
304 self.lockfile.flush()
306 # see if the schema has changed since last db access
307 self.fastopen = 0
308 if os.path.exists(db):
309 dbtm = os.path.getmtime(db)
310 pkgnm = self.config.__name__.split('.')[0]
311 schemamod = sys.modules.get(pkgnm+'.dbinit', None)
312 if schemamod:
313 if os.path.exists(schemamod.__file__):
314 schematm = os.path.getmtime(schemamod.__file__)
315 if schematm < dbtm:
316 # found schema mod - it's older than the db
317 self.fastopen = 1
318 else:
319 # can't find schemamod - must be frozen
320 self.fastopen = 1
322 # open the db
323 db = metakit.storage(db, 1)
324 hist = db.view('history')
325 tables = db.view('tables')
326 if not self.fastopen:
327 # create the database if it's brand new
328 if not hist.structure():
329 hist = db.getas('history[tableid:I,nodeid:I,date:I,user:I,action:I,params:B]')
330 if not tables.structure():
331 tables = db.getas('tables[name:S]')
332 db.commit()
334 # we now have an open, initialised database
335 self.tables = tables
336 self.hist = hist
337 return db
339 def setid(self, classname, maxid):
340 ''' No-op in metakit
341 '''
342 pass
344 def numfiles(self):
345 '''Get number of files in storage, even across subdirectories.
346 '''
347 files_dir = os.path.join(self.config.DATABASE, 'files')
348 return files_in_dir(files_dir)
350 _STRINGTYPE = type('')
351 _LISTTYPE = type([])
352 _CREATE, _SET, _RETIRE, _LINK, _UNLINK, _RESTORE = range(6)
354 _actionnames = {
355 _CREATE : 'create',
356 _SET : 'set',
357 _RETIRE : 'retire',
358 _RESTORE : 'restore',
359 _LINK : 'link',
360 _UNLINK : 'unlink',
361 }
363 _marker = []
365 _ALLOWSETTINGPRIVATEPROPS = 0
367 class Class(hyperdb.Class):
368 ''' The handle to a particular class of nodes in a hyperdatabase.
370 All methods except __repr__ and getnode must be implemented by a
371 concrete backend Class of which this is one.
372 '''
374 privateprops = None
375 def __init__(self, db, classname, **properties):
376 if (properties.has_key('creation') or properties.has_key('activity')
377 or properties.has_key('creator') or properties.has_key('actor')):
378 raise ValueError, '"creation", "activity" and "creator" are '\
379 'reserved'
380 if hasattr(db, classname):
381 raise ValueError, "Class %s already exists"%classname
383 self.db = db
384 self.classname = classname
385 self.key = None
386 self.ruprops = properties
387 self.privateprops = { 'id' : hyperdb.String(),
388 'activity' : hyperdb.Date(),
389 'actor' : hyperdb.Link('user'),
390 'creation' : hyperdb.Date(),
391 'creator' : hyperdb.Link('user') }
393 # event -> list of callables
394 self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
395 self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
397 view = self.__getview()
398 self.maxid = 1
399 if view:
400 self.maxid = view[-1].id + 1
401 self.uncommitted = {}
402 self.comactions = []
403 self.rbactions = []
405 # people reach inside!!
406 self.properties = self.ruprops
407 self.db.addclass(self)
408 self.idcache = {}
410 # default is to journal changes
411 self.do_journal = 1
413 def enableJournalling(self):
414 '''Turn journalling on for this class
415 '''
416 self.do_journal = 1
418 def disableJournalling(self):
419 '''Turn journalling off for this class
420 '''
421 self.do_journal = 0
423 #
424 # Detector/reactor interface
425 #
426 def audit(self, event, detector):
427 '''Register a detector
428 '''
429 l = self.auditors[event]
430 if detector not in l:
431 self.auditors[event].append(detector)
433 def fireAuditors(self, action, nodeid, newvalues):
434 '''Fire all registered auditors.
435 '''
436 for audit in self.auditors[action]:
437 audit(self.db, self, nodeid, newvalues)
439 def react(self, event, detector):
440 '''Register a reactor
441 '''
442 l = self.reactors[event]
443 if detector not in l:
444 self.reactors[event].append(detector)
446 def fireReactors(self, action, nodeid, oldvalues):
447 '''Fire all registered reactors.
448 '''
449 for react in self.reactors[action]:
450 react(self.db, self, nodeid, oldvalues)
452 # --- the hyperdb.Class methods
453 def create(self, **propvalues):
454 ''' Create a new node of this class and return its id.
456 The keyword arguments in 'propvalues' map property names to values.
458 The values of arguments must be acceptable for the types of their
459 corresponding properties or a TypeError is raised.
461 If this class has a key property, it must be present and its value
462 must not collide with other key strings or a ValueError is raised.
464 Any other properties on this class that are missing from the
465 'propvalues' dictionary are set to None.
467 If an id in a link or multilink property does not refer to a valid
468 node, an IndexError is raised.
469 '''
470 if not propvalues:
471 raise ValueError, "Need something to create!"
472 self.fireAuditors('create', None, propvalues)
473 newid = self.create_inner(**propvalues)
474 self.fireReactors('create', newid, None)
475 return newid
477 def create_inner(self, **propvalues):
478 ''' Called by create, in-between the audit and react calls.
479 '''
480 rowdict = {}
481 rowdict['id'] = newid = self.maxid
482 self.maxid += 1
483 ndx = self.getview(READWRITE).append(rowdict)
484 propvalues['#ISNEW'] = 1
485 try:
486 self.set(str(newid), **propvalues)
487 except Exception:
488 self.maxid -= 1
489 raise
490 return str(newid)
492 def get(self, nodeid, propname, default=_marker, cache=1):
493 '''Get the value of a property on an existing node of this class.
495 'nodeid' must be the id of an existing node of this class or an
496 IndexError is raised. 'propname' must be the name of a property
497 of this class or a KeyError is raised.
499 'cache' exists for backwards compatibility, and is not used.
500 '''
501 view = self.getview()
502 id = int(nodeid)
503 if cache == 0:
504 oldnode = self.uncommitted.get(id, None)
505 if oldnode and oldnode.has_key(propname):
506 raw = oldnode[propname]
507 converter = _converters.get(rutyp.__class__, None)
508 if converter:
509 return converter(raw)
510 return raw
511 ndx = self.idcache.get(id, None)
513 if ndx is None:
514 ndx = view.find(id=id)
515 if ndx < 0:
516 raise IndexError, "%s has no node %s" % (self.classname, nodeid)
517 self.idcache[id] = ndx
518 try:
519 raw = getattr(view[ndx], propname)
520 except AttributeError:
521 raise KeyError, propname
522 rutyp = self.ruprops.get(propname, None)
524 if rutyp is None:
525 rutyp = self.privateprops[propname]
527 converter = _converters.get(rutyp.__class__, None)
528 if converter:
529 raw = converter(raw)
530 return raw
532 def set(self, nodeid, **propvalues):
533 '''Modify a property on an existing node of this class.
535 'nodeid' must be the id of an existing node of this class or an
536 IndexError is raised.
538 Each key in 'propvalues' must be the name of a property of this
539 class or a KeyError is raised.
541 All values in 'propvalues' must be acceptable types for their
542 corresponding properties or a TypeError is raised.
544 If the value of the key property is set, it must not collide with
545 other key strings or a ValueError is raised.
547 If the value of a Link or Multilink property contains an invalid
548 node id, a ValueError is raised.
549 '''
550 self.fireAuditors('set', nodeid, propvalues)
551 propvalues, oldnode = self.set_inner(nodeid, **propvalues)
552 self.fireReactors('set', nodeid, oldnode)
554 def set_inner(self, nodeid, **propvalues):
555 '''Called outside of auditors'''
556 isnew = 0
557 if propvalues.has_key('#ISNEW'):
558 isnew = 1
559 del propvalues['#ISNEW']
561 if propvalues.has_key('id'):
562 raise KeyError, '"id" is reserved'
563 if self.db.journaltag is None:
564 raise hyperdb.DatabaseError, 'Database open read-only'
565 view = self.getview(READWRITE)
567 # node must exist & not be retired
568 id = int(nodeid)
569 ndx = view.find(id=id)
570 if ndx < 0:
571 raise IndexError, "%s has no node %s" % (self.classname, nodeid)
572 row = view[ndx]
573 if row._isdel:
574 raise IndexError, "%s has no node %s" % (self.classname, nodeid)
575 oldnode = self.uncommitted.setdefault(id, {})
576 changes = {}
578 for key, value in propvalues.items():
579 # this will raise the KeyError if the property isn't valid
580 # ... we don't use getprops() here because we only care about
581 # the writeable properties.
582 if _ALLOWSETTINGPRIVATEPROPS:
583 prop = self.ruprops.get(key, None)
584 if not prop:
585 prop = self.privateprops[key]
586 else:
587 prop = self.ruprops[key]
588 converter = _converters.get(prop.__class__, lambda v: v)
589 # if the value's the same as the existing value, no sense in
590 # doing anything
591 oldvalue = converter(getattr(row, key))
592 if value == oldvalue:
593 del propvalues[key]
594 continue
596 # check to make sure we're not duplicating an existing key
597 if key == self.key:
598 iv = self.getindexview(READWRITE)
599 ndx = iv.find(k=value)
600 if ndx == -1:
601 iv.append(k=value, i=row.id)
602 if not isnew:
603 ndx = iv.find(k=oldvalue)
604 if ndx > -1:
605 iv.delete(ndx)
606 else:
607 raise ValueError, 'node with key "%s" exists'%value
609 # do stuff based on the prop type
610 if isinstance(prop, hyperdb.Link):
611 link_class = prop.classname
612 # must be a string or None
613 if value is not None and not isinstance(value, type('')):
614 raise ValueError, 'property "%s" link value be a string'%(
615 key)
616 # Roundup sets to "unselected" by passing None
617 if value is None:
618 value = 0
619 # if it isn't a number, it's a key
620 try:
621 int(value)
622 except ValueError:
623 try:
624 value = self.db.getclass(link_class).lookup(value)
625 except (TypeError, KeyError):
626 raise IndexError, 'new property "%s": %s not a %s'%(
627 key, value, prop.classname)
629 if (value is not None and
630 not self.db.getclass(link_class).hasnode(value)):
631 raise IndexError, '%s has no node %s'%(link_class, value)
633 setattr(row, key, int(value))
634 changes[key] = oldvalue
636 if self.do_journal and prop.do_journal:
637 # register the unlink with the old linked node
638 if oldvalue:
639 self.db.addjournal(link_class, oldvalue, _UNLINK,
640 (self.classname, str(row.id), key))
642 # register the link with the newly linked node
643 if value:
644 self.db.addjournal(link_class, value, _LINK,
645 (self.classname, str(row.id), key))
647 elif isinstance(prop, hyperdb.Multilink):
648 if value is not None and type(value) != _LISTTYPE:
649 raise TypeError, 'new property "%s" not a list of ids'%key
650 link_class = prop.classname
651 l = []
652 if value is None:
653 value = []
654 for entry in value:
655 if type(entry) != _STRINGTYPE:
656 raise ValueError, 'new property "%s" link value ' \
657 'must be a string'%key
658 # if it isn't a number, it's a key
659 try:
660 int(entry)
661 except ValueError:
662 try:
663 entry = self.db.getclass(link_class).lookup(entry)
664 except (TypeError, KeyError):
665 raise IndexError, 'new property "%s": %s not a %s'%(
666 key, entry, prop.classname)
667 l.append(entry)
668 propvalues[key] = value = l
670 # handle removals
671 rmvd = []
672 for id in oldvalue:
673 if id not in value:
674 rmvd.append(id)
675 # register the unlink with the old linked node
676 if self.do_journal and prop.do_journal:
677 self.db.addjournal(link_class, id, _UNLINK,
678 (self.classname, str(row.id), key))
680 # handle additions
681 adds = []
682 for id in value:
683 if id not in oldvalue:
684 if not self.db.getclass(link_class).hasnode(id):
685 raise IndexError, '%s has no node %s'%(
686 link_class, id)
687 adds.append(id)
688 # register the link with the newly linked node
689 if self.do_journal and prop.do_journal:
690 self.db.addjournal(link_class, id, _LINK,
691 (self.classname, str(row.id), key))
693 # perform the modifications on the actual property value
694 sv = getattr(row, key)
695 i = 0
696 while i < len(sv):
697 if str(sv[i].fid) in rmvd:
698 sv.delete(i)
699 else:
700 i += 1
701 for id in adds:
702 sv.append(fid=int(id))
704 # figure the journal entry
705 l = []
706 if adds:
707 l.append(('+', adds))
708 if rmvd:
709 l.append(('-', rmvd))
710 if l:
711 changes[key] = tuple(l)
712 #changes[key] = oldvalue
714 if not rmvd and not adds:
715 del propvalues[key]
717 elif isinstance(prop, hyperdb.String):
718 if value is not None and type(value) != _STRINGTYPE:
719 raise TypeError, 'new property "%s" not a string'%key
720 if value is None:
721 value = ''
722 setattr(row, key, value)
723 changes[key] = oldvalue
724 if hasattr(prop, 'isfilename') and prop.isfilename:
725 propvalues[key] = os.path.basename(value)
726 if prop.indexme:
727 self.db.indexer.add_text((self.classname, nodeid, key),
728 value, 'text/plain')
730 elif isinstance(prop, hyperdb.Password):
731 if value is not None and not isinstance(value, password.Password):
732 raise TypeError, 'new property "%s" not a Password'% key
733 if value is None:
734 value = ''
735 setattr(row, key, str(value))
736 changes[key] = str(oldvalue)
737 propvalues[key] = str(value)
739 elif isinstance(prop, hyperdb.Date):
740 if value is not None and not isinstance(value, date.Date):
741 raise TypeError, 'new property "%s" not a Date'% key
742 if value is None:
743 setattr(row, key, 0)
744 else:
745 setattr(row, key, int(calendar.timegm(value.get_tuple())))
746 changes[key] = str(oldvalue)
747 propvalues[key] = str(value)
749 elif isinstance(prop, hyperdb.Interval):
750 if value is not None and not isinstance(value, date.Interval):
751 raise TypeError, 'new property "%s" not an Interval'% key
752 if value is None:
753 setattr(row, key, '')
754 else:
755 # kedder: we should store interval values serialized
756 setattr(row, key, value.serialise())
757 changes[key] = str(oldvalue)
758 propvalues[key] = str(value)
760 elif isinstance(prop, hyperdb.Number):
761 if value is None:
762 v = 0
763 else:
764 try:
765 v = int(value)
766 except ValueError:
767 raise TypeError, "%s (%s) is not numeric"%(key, repr(value))
768 if not BACKWARDS_COMPATIBLE:
769 if v >=0:
770 v = v + 1
771 setattr(row, key, v)
772 changes[key] = oldvalue
773 propvalues[key] = value
775 elif isinstance(prop, hyperdb.Boolean):
776 if value is None:
777 bv = 0
778 elif value not in (0,1):
779 raise TypeError, "%s (%s) is not boolean"%(key, repr(value))
780 else:
781 bv = value
782 if not BACKWARDS_COMPATIBLE:
783 bv += 1
784 setattr(row, key, bv)
785 changes[key] = oldvalue
786 propvalues[key] = value
788 oldnode[key] = oldvalue
790 # nothing to do?
791 if not propvalues:
792 return propvalues, oldnode
793 if not propvalues.has_key('activity'):
794 row.activity = int(time.time())
795 if not propvalues.has_key('actor'):
796 row.actor = int(self.db.getuid())
797 if isnew:
798 if not row.creation:
799 row.creation = int(time.time())
800 if not row.creator:
801 row.creator = int(self.db.getuid())
803 self.db.dirty = 1
805 if self.do_journal:
806 if isnew:
807 self.db.addjournal(self.classname, nodeid, _CREATE, {})
808 else:
809 self.db.addjournal(self.classname, nodeid, _SET, changes)
811 return propvalues, oldnode
813 def retire(self, nodeid):
814 '''Retire a node.
816 The properties on the node remain available from the get() method,
817 and the node's id is never reused.
819 Retired nodes are not returned by the find(), list(), or lookup()
820 methods, and other nodes may reuse the values of their key properties.
821 '''
822 if self.db.journaltag is None:
823 raise hyperdb.DatabaseError, 'Database open read-only'
824 self.fireAuditors('retire', nodeid, None)
825 view = self.getview(READWRITE)
826 ndx = view.find(id=int(nodeid))
827 if ndx < 0:
828 raise KeyError, "nodeid %s not found" % nodeid
830 row = view[ndx]
831 oldvalues = self.uncommitted.setdefault(row.id, {})
832 oldval = oldvalues['_isdel'] = row._isdel
833 row._isdel = 1
835 if self.do_journal:
836 self.db.addjournal(self.classname, nodeid, _RETIRE, {})
837 if self.key:
838 iv = self.getindexview(READWRITE)
839 ndx = iv.find(k=getattr(row, self.key))
840 # find is broken with multiple attribute lookups
841 # on ordered views
842 #ndx = iv.find(k=getattr(row, self.key),i=row.id)
843 if ndx > -1 and iv[ndx].i == row.id:
844 iv.delete(ndx)
846 self.db.dirty = 1
847 self.fireReactors('retire', nodeid, None)
849 def restore(self, nodeid):
850 '''Restore a retired node.
852 Make node available for all operations like it was before retirement.
853 '''
854 if self.db.journaltag is None:
855 raise hyperdb.DatabaseError, 'Database open read-only'
857 # check if key property was overrided
858 key = self.getkey()
859 keyvalue = self.get(nodeid, key)
861 try:
862 id = self.lookup(keyvalue)
863 except KeyError:
864 pass
865 else:
866 raise KeyError, "Key property (%s) of retired node clashes with \
867 existing one (%s)" % (key, keyvalue)
868 # Now we can safely restore node
869 self.fireAuditors('restore', nodeid, None)
870 view = self.getview(READWRITE)
871 ndx = view.find(id=int(nodeid))
872 if ndx < 0:
873 raise KeyError, "nodeid %s not found" % nodeid
875 row = view[ndx]
876 oldvalues = self.uncommitted.setdefault(row.id, {})
877 oldval = oldvalues['_isdel'] = row._isdel
878 row._isdel = 0
880 if self.do_journal:
881 self.db.addjournal(self.classname, nodeid, _RESTORE, {})
882 if self.key:
883 iv = self.getindexview(READWRITE)
884 ndx = iv.find(k=getattr(row, self.key),i=row.id)
885 if ndx > -1:
886 iv.delete(ndx)
887 self.db.dirty = 1
888 self.fireReactors('restore', nodeid, None)
890 def is_retired(self, nodeid):
891 '''Return true if the node is retired
892 '''
893 view = self.getview(READWRITE)
894 # node must exist & not be retired
895 id = int(nodeid)
896 ndx = view.find(id=id)
897 if ndx < 0:
898 raise IndexError, "%s has no node %s" % (self.classname, nodeid)
899 row = view[ndx]
900 return row._isdel
902 def history(self, nodeid):
903 '''Retrieve the journal of edits on a particular node.
905 'nodeid' must be the id of an existing node of this class or an
906 IndexError is raised.
908 The returned list contains tuples of the form
910 (nodeid, date, tag, action, params)
912 'date' is a Timestamp object specifying the time of the change and
913 'tag' is the journaltag specified when the database was opened.
914 '''
915 if not self.do_journal:
916 raise ValueError, 'Journalling is disabled for this class'
917 return self.db.getjournal(self.classname, nodeid)
919 def setkey(self, propname):
920 '''Select a String property of this class to be the key property.
922 'propname' must be the name of a String property of this class or
923 None, or a TypeError is raised. The values of the key property on
924 all existing nodes must be unique or a ValueError is raised.
925 '''
926 if self.key:
927 if propname == self.key:
928 return
929 else:
930 # drop the old key table
931 tablename = "_%s.%s"%(self.classname, self.key)
932 self.db._db.getas(tablename)
934 #raise ValueError, "%s already indexed on %s"%(self.classname,
935 # self.key)
937 prop = self.properties.get(propname, None)
938 if prop is None:
939 prop = self.privateprops.get(propname, None)
940 if prop is None:
941 raise KeyError, "no property %s" % propname
942 if not isinstance(prop, hyperdb.String):
943 raise TypeError, "%s is not a String" % propname
945 # the way he index on properties is by creating a
946 # table named _%(classname)s.%(key)s, if this table
947 # exists then everything is okay. If this table
948 # doesn't exist, then generate a new table on the
949 # key value.
951 # first setkey for this run or key has been changed
952 self.key = propname
953 tablename = "_%s.%s"%(self.classname, self.key)
955 iv = self.db._db.view(tablename)
956 if self.db.fastopen and iv.structure():
957 return
959 # very first setkey ever or the key has changed
960 self.db.dirty = 1
961 iv = self.db._db.getas('_%s[k:S,i:I]' % tablename)
962 iv = iv.ordered(1)
963 for row in self.getview():
964 iv.append(k=getattr(row, propname), i=row.id)
965 self.db.commit()
967 def getkey(self):
968 '''Return the name of the key property for this class or None.'''
969 return self.key
971 def lookup(self, keyvalue):
972 '''Locate a particular node by its key property and return its id.
974 If this class has no key property, a TypeError is raised. If the
975 keyvalue matches one of the values for the key property among
976 the nodes in this class, the matching node's id is returned;
977 otherwise a KeyError is raised.
978 '''
979 if not self.key:
980 raise TypeError, 'No key property set for class %s'%self.classname
982 if type(keyvalue) is not _STRINGTYPE:
983 raise TypeError, '%r is not a string'%keyvalue
985 # XXX FIX ME -> this is a bit convoluted
986 # First we search the index view to get the id
987 # which is a quicker look up.
988 # Then we lookup the row with id=id
989 # if the _isdel property of the row is 0, return the
990 # string version of the id. (Why string version???)
991 #
992 # Otherwise, just lookup the non-indexed key
993 # in the non-index table and check the _isdel property
994 iv = self.getindexview()
995 if iv:
996 # look up the index view for the id,
997 # then instead of looking up the keyvalue, lookup the
998 # quicker id
999 ndx = iv.find(k=keyvalue)
1000 if ndx > -1:
1001 view = self.getview()
1002 ndx = view.find(id=iv[ndx].i)
1003 if ndx > -1:
1004 row = view[ndx]
1005 if not row._isdel:
1006 return str(row.id)
1007 else:
1008 # perform the slower query
1009 view = self.getview()
1010 ndx = view.find({self.key:keyvalue})
1011 if ndx > -1:
1012 row = view[ndx]
1013 if not row._isdel:
1014 return str(row.id)
1016 raise KeyError, keyvalue
1018 def destroy(self, id):
1019 '''Destroy a node.
1021 WARNING: this method should never be used except in extremely rare
1022 situations where there could never be links to the node being
1023 deleted
1025 WARNING: use retire() instead
1027 WARNING: the properties of this node will not be available ever again
1029 WARNING: really, use retire() instead
1031 Well, I think that's enough warnings. This method exists mostly to
1032 support the session storage of the cgi interface.
1034 The node is completely removed from the hyperdb, including all journal
1035 entries. It will no longer be available, and will generally break code
1036 if there are any references to the node.
1037 '''
1038 view = self.getview(READWRITE)
1039 ndx = view.find(id=int(id))
1040 if ndx > -1:
1041 if self.key:
1042 keyvalue = getattr(view[ndx], self.key)
1043 iv = self.getindexview(READWRITE)
1044 if iv:
1045 ivndx = iv.find(k=keyvalue)
1046 if ivndx > -1:
1047 iv.delete(ivndx)
1048 view.delete(ndx)
1049 self.db.destroyjournal(self.classname, id)
1050 self.db.dirty = 1
1052 def find(self, **propspec):
1053 '''Get the ids of nodes in this class which link to the given nodes.
1055 'propspec'
1056 consists of keyword args propname={nodeid:1,}
1057 'propname'
1058 must be the name of a property in this class, or a
1059 KeyError is raised. That property must be a Link or
1060 Multilink property, or a TypeError is raised.
1062 Any node in this class whose propname property links to any of the
1063 nodeids will be returned. Used by the full text indexing, which knows
1064 that "foo" occurs in msg1, msg3 and file7; so we have hits on these
1065 issues::
1067 db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1068 '''
1069 propspec = propspec.items()
1070 for propname, nodeid in propspec:
1071 # check the prop is OK
1072 prop = self.ruprops[propname]
1073 if (not isinstance(prop, hyperdb.Link) and
1074 not isinstance(prop, hyperdb.Multilink)):
1075 raise TypeError, "'%s' not a Link/Multilink property"%propname
1077 vws = []
1078 for propname, ids in propspec:
1079 if type(ids) is _STRINGTYPE:
1080 ids = {int(ids):1}
1081 elif ids is None:
1082 ids = {0:1}
1083 else:
1084 d = {}
1085 for id in ids.keys():
1086 if id is None:
1087 d[0] = 1
1088 else:
1089 d[int(id)] = 1
1090 ids = d
1091 prop = self.ruprops[propname]
1092 view = self.getview()
1093 if isinstance(prop, hyperdb.Multilink):
1094 def ff(row, nm=propname, ids=ids):
1095 if not row._isdel:
1096 sv = getattr(row, nm)
1097 for sr in sv:
1098 if ids.has_key(sr.fid):
1099 return 1
1100 return 0
1101 else:
1102 def ff(row, nm=propname, ids=ids):
1103 return not row._isdel and ids.has_key(getattr(row, nm))
1104 ndxview = view.filter(ff)
1105 vws.append(ndxview.unique())
1107 # handle the empty match case
1108 if not vws:
1109 return []
1111 ndxview = vws[0]
1112 for v in vws[1:]:
1113 ndxview = ndxview.union(v)
1114 view = self.getview().remapwith(ndxview)
1115 rslt = []
1116 for row in view:
1117 rslt.append(str(row.id))
1118 return rslt
1121 def list(self):
1122 ''' Return a list of the ids of the active nodes in this class.
1123 '''
1124 l = []
1125 for row in self.getview().select(_isdel=0):
1126 l.append(str(row.id))
1127 return l
1129 def getnodeids(self):
1130 ''' Retrieve all the ids of the nodes for a particular Class.
1132 Set retired=None to get all nodes. Otherwise it'll get all the
1133 retired or non-retired nodes, depending on the flag.
1134 '''
1135 l = []
1136 for row in self.getview():
1137 l.append(str(row.id))
1138 return l
1140 def count(self):
1141 return len(self.getview())
1143 def getprops(self, protected=1):
1144 # protected is not in ping's spec
1145 allprops = self.ruprops.copy()
1146 if protected and self.privateprops is not None:
1147 allprops.update(self.privateprops)
1148 return allprops
1150 def addprop(self, **properties):
1151 for key in properties.keys():
1152 if self.ruprops.has_key(key):
1153 raise ValueError, "%s is already a property of %s"%(key,
1154 self.classname)
1155 self.ruprops.update(properties)
1156 # Class structure has changed
1157 self.db.fastopen = 0
1158 view = self.__getview()
1159 self.db.commit()
1160 # ---- end of ping's spec
1162 def filter(self, search_matches, filterspec, sort=(None,None),
1163 group=(None,None)):
1164 '''Return a list of the ids of the active nodes in this class that
1165 match the 'filter' spec, sorted by the group spec and then the
1166 sort spec
1168 "filterspec" is {propname: value(s)}
1170 "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1171 and prop is a prop name or None
1173 "search_matches" is {nodeid: marker}
1175 The filter must match all properties specificed - but if the
1176 property value to match is a list, any one of the values in the
1177 list may match for that property to match.
1178 '''
1179 # search_matches is None or a set (dict of {nodeid: {propname:[nodeid,...]}})
1180 # filterspec is a dict {propname:value}
1181 # sort and group are (dir, prop) where dir is '+', '-' or None
1182 # and prop is a prop name or None
1184 timezone = self.db.getUserTimezone()
1186 where = {'_isdel':0}
1187 wherehigh = {}
1188 mlcriteria = {}
1189 regexes = {}
1190 orcriteria = {}
1191 for propname, value in filterspec.items():
1192 prop = self.ruprops.get(propname, None)
1193 if prop is None:
1194 prop = self.privateprops[propname]
1195 if isinstance(prop, hyperdb.Multilink):
1196 if value in ('-1', ['-1']):
1197 value = []
1198 elif type(value) is not _LISTTYPE:
1199 value = [value]
1200 # transform keys to ids
1201 u = []
1202 for item in value:
1203 try:
1204 item = int(item)
1205 except (TypeError, ValueError):
1206 item = int(self.db.getclass(prop.classname).lookup(item))
1207 if item == -1:
1208 item = 0
1209 u.append(item)
1210 mlcriteria[propname] = u
1211 elif isinstance(prop, hyperdb.Link):
1212 if type(value) is not _LISTTYPE:
1213 value = [value]
1214 # transform keys to ids
1215 u = []
1216 for item in value:
1217 try:
1218 item = int(item)
1219 except (TypeError, ValueError):
1220 item = int(self.db.getclass(prop.classname).lookup(item))
1221 if item == -1:
1222 item = 0
1223 u.append(item)
1224 if len(u) == 1:
1225 where[propname] = u[0]
1226 else:
1227 orcriteria[propname] = u
1228 elif isinstance(prop, hyperdb.String):
1229 if type(value) is not type([]):
1230 value = [value]
1231 m = []
1232 for v in value:
1233 # simple glob searching
1234 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1235 v = v.replace('?', '.')
1236 v = v.replace('*', '.*?')
1237 m.append(v)
1238 regexes[propname] = re.compile('(%s)'%('|'.join(m)), re.I)
1239 elif propname == 'id':
1240 where[propname] = int(value)
1241 elif isinstance(prop, hyperdb.Boolean):
1242 if type(value) is _STRINGTYPE:
1243 bv = value.lower() in ('yes', 'true', 'on', '1')
1244 else:
1245 bv = value
1246 where[propname] = bv
1247 elif isinstance(prop, hyperdb.Date):
1248 try:
1249 # Try to filter on range of dates
1250 date_rng = Range(value, date.Date, offset=timezone)
1251 if date_rng.from_value:
1252 t = date_rng.from_value.get_tuple()
1253 where[propname] = int(calendar.timegm(t))
1254 else:
1255 # use minimum possible value to exclude items without
1256 # 'prop' property
1257 where[propname] = 0
1258 if date_rng.to_value:
1259 t = date_rng.to_value.get_tuple()
1260 wherehigh[propname] = int(calendar.timegm(t))
1261 else:
1262 wherehigh[propname] = None
1263 except ValueError:
1264 # If range creation fails - ignore that search parameter
1265 pass
1266 elif isinstance(prop, hyperdb.Interval):
1267 try:
1268 # Try to filter on range of intervals
1269 date_rng = Range(value, date.Interval)
1270 if date_rng.from_value:
1271 #t = date_rng.from_value.get_tuple()
1272 where[propname] = date_rng.from_value.serialise()
1273 else:
1274 # use minimum possible value to exclude items without
1275 # 'prop' property
1276 where[propname] = '-99999999999999'
1277 if date_rng.to_value:
1278 #t = date_rng.to_value.get_tuple()
1279 wherehigh[propname] = date_rng.to_value.serialise()
1280 else:
1281 wherehigh[propname] = None
1282 except ValueError:
1283 # If range creation fails - ignore that search parameter
1284 pass
1285 elif isinstance(prop, hyperdb.Number):
1286 where[propname] = int(value)
1287 else:
1288 where[propname] = str(value)
1289 v = self.getview()
1290 #print "filter start at %s" % time.time()
1291 if where:
1292 where_higherbound = where.copy()
1293 where_higherbound.update(wherehigh)
1294 v = v.select(where, where_higherbound)
1295 #print "filter where at %s" % time.time()
1297 if mlcriteria:
1298 # multilink - if any of the nodeids required by the
1299 # filterspec aren't in this node's property, then skip it
1300 def ff(row, ml=mlcriteria):
1301 for propname, values in ml.items():
1302 sv = getattr(row, propname)
1303 if not values and sv:
1304 return 0
1305 for id in values:
1306 if sv.find(fid=id) == -1:
1307 return 0
1308 return 1
1309 iv = v.filter(ff)
1310 v = v.remapwith(iv)
1312 #print "filter mlcrit at %s" % time.time()
1314 if orcriteria:
1315 def ff(row, crit=orcriteria):
1316 for propname, allowed in crit.items():
1317 val = getattr(row, propname)
1318 if val not in allowed:
1319 return 0
1320 return 1
1322 iv = v.filter(ff)
1323 v = v.remapwith(iv)
1325 #print "filter orcrit at %s" % time.time()
1326 if regexes:
1327 def ff(row, r=regexes):
1328 for propname, regex in r.items():
1329 val = str(getattr(row, propname))
1330 if not regex.search(val):
1331 return 0
1332 return 1
1334 iv = v.filter(ff)
1335 v = v.remapwith(iv)
1336 #print "filter regexs at %s" % time.time()
1338 if sort or group:
1339 sortspec = []
1340 rev = []
1341 for dir, propname in group, sort:
1342 if propname is None: continue
1343 isreversed = 0
1344 if dir == '-':
1345 isreversed = 1
1346 try:
1347 prop = getattr(v, propname)
1348 except AttributeError:
1349 print "MK has no property %s" % propname
1350 continue
1351 propclass = self.ruprops.get(propname, None)
1352 if propclass is None:
1353 propclass = self.privateprops.get(propname, None)
1354 if propclass is None:
1355 print "Schema has no property %s" % propname
1356 continue
1357 if isinstance(propclass, hyperdb.Link):
1358 linkclass = self.db.getclass(propclass.classname)
1359 lv = linkclass.getview()
1360 lv = lv.rename('id', propname)
1361 v = v.join(lv, prop, 1)
1362 if linkclass.getprops().has_key('order'):
1363 propname = 'order'
1364 else:
1365 propname = linkclass.labelprop()
1366 prop = getattr(v, propname)
1367 if isreversed:
1368 rev.append(prop)
1369 sortspec.append(prop)
1370 v = v.sortrev(sortspec, rev)[:] #XXX Metakit bug
1371 #print "filter sort at %s" % time.time()
1373 rslt = []
1374 for row in v:
1375 id = str(row.id)
1376 if search_matches is not None:
1377 if search_matches.has_key(id):
1378 rslt.append(id)
1379 else:
1380 rslt.append(id)
1381 return rslt
1383 def hasnode(self, nodeid):
1384 '''Determine if the given nodeid actually exists
1385 '''
1386 return int(nodeid) < self.maxid
1388 def labelprop(self, default_to_id=0):
1389 '''Return the property name for a label for the given node.
1391 This method attempts to generate a consistent label for the node.
1392 It tries the following in order:
1394 1. key property
1395 2. "name" property
1396 3. "title" property
1397 4. first property from the sorted property name list
1398 '''
1399 k = self.getkey()
1400 if k:
1401 return k
1402 props = self.getprops()
1403 if props.has_key('name'):
1404 return 'name'
1405 elif props.has_key('title'):
1406 return 'title'
1407 if default_to_id:
1408 return 'id'
1409 props = props.keys()
1410 props.sort()
1411 return props[0]
1413 def stringFind(self, **requirements):
1414 '''Locate a particular node by matching a set of its String
1415 properties in a caseless search.
1417 If the property is not a String property, a TypeError is raised.
1419 The return is a list of the id of all nodes that match.
1420 '''
1421 for propname in requirements.keys():
1422 prop = self.properties[propname]
1423 if isinstance(not prop, hyperdb.String):
1424 raise TypeError, "'%s' not a String property"%propname
1425 requirements[propname] = requirements[propname].lower()
1426 requirements['_isdel'] = 0
1428 l = []
1429 for row in self.getview().select(requirements):
1430 l.append(str(row.id))
1431 return l
1433 def addjournal(self, nodeid, action, params):
1434 '''Add a journal to the given nodeid,
1435 'action' may be:
1437 'create' or 'set' -- 'params' is a dictionary of property values
1438 'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
1439 'retire' -- 'params' is None
1440 '''
1441 self.db.addjournal(self.classname, nodeid, action, params)
1443 def index(self, nodeid):
1444 ''' Add (or refresh) the node to search indexes '''
1445 # find all the String properties that have indexme
1446 for prop, propclass in self.getprops().items():
1447 if isinstance(propclass, hyperdb.String) and propclass.indexme:
1448 # index them under (classname, nodeid, property)
1449 self.db.indexer.add_text((self.classname, nodeid, prop),
1450 str(self.get(nodeid, prop)))
1452 # --- used by Database
1453 def _commit(self):
1454 ''' called post commit of the DB.
1455 interested subclasses may override '''
1456 self.uncommitted = {}
1457 for action in self.comactions:
1458 action()
1459 self.comactions = []
1460 self.rbactions = []
1461 self.idcache = {}
1462 def _rollback(self):
1463 ''' called pre rollback of the DB.
1464 interested subclasses may override '''
1465 self.comactions = []
1466 for action in self.rbactions:
1467 action()
1468 self.rbactions = []
1469 self.uncommitted = {}
1470 self.idcache = {}
1471 def _clear(self):
1472 view = self.getview(READWRITE)
1473 if len(view):
1474 view[:] = []
1475 self.db.dirty = 1
1476 iv = self.getindexview(READWRITE)
1477 if iv:
1478 iv[:] = []
1479 def commitaction(self, action):
1480 ''' call this to register a callback called on commit
1481 callback is removed on end of transaction '''
1482 self.comactions.append(action)
1483 def rollbackaction(self, action):
1484 ''' call this to register a callback called on rollback
1485 callback is removed on end of transaction '''
1486 self.rbactions.append(action)
1487 # --- internal
1488 def __getview(self):
1489 ''' Find the interface for a specific Class in the hyperdb.
1491 This method checks to see whether the schema has changed and
1492 re-works the underlying metakit structure if it has.
1493 '''
1494 db = self.db._db
1495 view = db.view(self.classname)
1496 mkprops = view.structure()
1498 # if we have structure in the database, and the structure hasn't
1499 # changed
1500 # note on view.ordered ->
1501 # return a metakit view ordered on the id column
1502 # id is always the first column. This speeds up
1503 # look-ups on the id column.
1505 if mkprops and self.db.fastopen:
1506 return view.ordered(1)
1508 # is the definition the same?
1509 for nm, rutyp in self.ruprops.items():
1510 for mkprop in mkprops:
1511 if mkprop.name == nm:
1512 break
1513 else:
1514 mkprop = None
1515 if mkprop is None:
1516 break
1517 if _typmap[rutyp.__class__] != mkprop.type:
1518 break
1519 else:
1520 # make sure we have the 'actor' property too
1521 for mkprop in mkprops:
1522 if mkprop.name == 'actor':
1523 return view.ordered(1)
1525 # The schema has changed. We need to create or restructure the mk view
1526 # id comes first, so we can use view.ordered(1) so that
1527 # MK will order it for us to allow binary-search quick lookups on
1528 # the id column
1529 self.db.dirty = 1
1530 s = ["%s[id:I" % self.classname]
1532 # these columns will always be added, we can't trample them :)
1533 _columns = {"id":"I", "_isdel":"I", "activity":"I", "actor": "I",
1534 "creation":"I", "creator":"I"}
1536 for nm, rutyp in self.ruprops.items():
1537 mktyp = _typmap[rutyp.__class__].upper()
1538 if nm in _columns and _columns[nm] != mktyp:
1539 # oops, two columns with the same name and different properties
1540 raise MKBackendError("column %s for table %sis defined with multiple types"%(nm, self.classname))
1541 _columns[nm] = mktyp
1542 s.append('%s:%s' % (nm, mktyp))
1543 if mktyp == 'V':
1544 s[-1] += ('[fid:I]')
1546 # XXX FIX ME -> in some tests, creation:I becomes creation:S is this
1547 # okay? Does this need to be supported?
1548 s.append('_isdel:I,activity:I,actor:I,creation:I,creator:I]')
1549 view = self.db._db.getas(','.join(s))
1550 self.db.commit()
1551 return view.ordered(1)
1552 def getview(self, RW=0):
1553 # XXX FIX ME -> The RW flag doesn't do anything.
1554 return self.db._db.view(self.classname).ordered(1)
1555 def getindexview(self, RW=0):
1556 # XXX FIX ME -> The RW flag doesn't do anything.
1557 tablename = "_%s.%s"%(self.classname, self.key)
1558 return self.db._db.view("_%s" % tablename).ordered(1)
1560 #
1561 # import / export
1562 #
1563 def export_list(self, propnames, nodeid):
1564 ''' Export a node - generate a list of CSV-able data in the order
1565 specified by propnames for the given node.
1566 '''
1567 properties = self.getprops()
1568 l = []
1569 for prop in propnames:
1570 proptype = properties[prop]
1571 value = self.get(nodeid, prop)
1572 # "marshal" data where needed
1573 if value is None:
1574 pass
1575 elif isinstance(proptype, hyperdb.Date):
1576 value = value.get_tuple()
1577 elif isinstance(proptype, hyperdb.Interval):
1578 value = value.get_tuple()
1579 elif isinstance(proptype, hyperdb.Password):
1580 value = str(value)
1581 l.append(repr(value))
1583 # append retired flag
1584 l.append(repr(self.is_retired(nodeid)))
1586 return l
1588 def import_list(self, propnames, proplist):
1589 ''' Import a node - all information including "id" is present and
1590 should not be sanity checked. Triggers are not triggered. The
1591 journal should be initialised using the "creator" and "creation"
1592 information.
1594 Return the nodeid of the node imported.
1595 '''
1596 if self.db.journaltag is None:
1597 raise hyperdb.DatabaseError, 'Database open read-only'
1598 properties = self.getprops()
1600 d = {}
1601 view = self.getview(READWRITE)
1602 for i in range(len(propnames)):
1603 value = eval(proplist[i])
1604 if not value:
1605 continue
1607 propname = propnames[i]
1608 if propname == 'id':
1609 newid = value = int(value)
1610 elif propname == 'is retired':
1611 # is the item retired?
1612 if int(value):
1613 d['_isdel'] = 1
1614 continue
1615 elif value is None:
1616 d[propname] = None
1617 continue
1619 prop = properties[propname]
1620 if isinstance(prop, hyperdb.Date):
1621 value = int(calendar.timegm(value))
1622 elif isinstance(prop, hyperdb.Interval):
1623 value = date.Interval(value).serialise()
1624 elif isinstance(prop, hyperdb.Number):
1625 value = int(value)
1626 elif isinstance(prop, hyperdb.Boolean):
1627 value = int(value)
1628 elif isinstance(prop, hyperdb.Link) and value:
1629 value = int(value)
1630 elif isinstance(prop, hyperdb.Multilink):
1631 # we handle multilinks separately
1632 continue
1633 d[propname] = value
1635 # possibly make a new node
1636 if not d.has_key('id'):
1637 d['id'] = newid = self.maxid
1638 self.maxid += 1
1640 # save off the node
1641 view.append(d)
1643 # fix up multilinks
1644 ndx = view.find(id=newid)
1645 row = view[ndx]
1646 for i in range(len(propnames)):
1647 value = eval(proplist[i])
1648 propname = propnames[i]
1649 if propname == 'is retired':
1650 continue
1651 prop = properties[propname]
1652 if not isinstance(prop, hyperdb.Multilink):
1653 continue
1654 sv = getattr(row, propname)
1655 for entry in value:
1656 sv.append((int(entry),))
1658 self.db.dirty = 1
1659 return newid
1661 def export_journals(self):
1662 '''Export a class's journal - generate a list of lists of
1663 CSV-able data:
1665 nodeid, date, user, action, params
1667 No heading here - the columns are fixed.
1668 '''
1669 properties = self.getprops()
1670 r = []
1671 for nodeid in self.getnodeids():
1672 for nodeid, date, user, action, params in self.history(nodeid):
1673 date = date.get_tuple()
1674 if action == 'set':
1675 for propname, value in params.items():
1676 prop = properties[propname]
1677 # make sure the params are eval()'able
1678 if value is None:
1679 pass
1680 elif isinstance(prop, Date):
1681 value = value.get_tuple()
1682 elif isinstance(prop, Interval):
1683 value = value.get_tuple()
1684 elif isinstance(prop, Password):
1685 value = str(value)
1686 params[propname] = value
1687 l = [nodeid, date, user, action, params]
1688 r.append(map(repr, l))
1689 return r
1691 def import_journals(self, entries):
1692 '''Import a class's journal.
1694 Uses setjournal() to set the journal for each item.'''
1695 properties = self.getprops()
1696 d = {}
1697 for l in entries:
1698 l = map(eval, l)
1699 nodeid, date, user, action, params = l
1700 r = d.setdefault(nodeid, [])
1701 if action == 'set':
1702 for propname, value in params.items():
1703 prop = properties[propname]
1704 if value is None:
1705 pass
1706 elif isinstance(prop, Date):
1707 value = date.Date(value)
1708 elif isinstance(prop, Interval):
1709 value = date.Interval(value)
1710 elif isinstance(prop, Password):
1711 pwd = password.Password()
1712 pwd.unpack(value)
1713 value = pwd
1714 params[propname] = value
1715 r.append((nodeid, date.Date(date), user, action, params))
1717 for nodeid, l in d.items():
1718 self.db.setjournal(self.classname, nodeid, l)
1720 def _fetchML(sv):
1721 l = []
1722 for row in sv:
1723 if row.fid:
1724 l.append(str(row.fid))
1725 return l
1727 def _fetchPW(s):
1728 ''' Convert to a password.Password unless the password is '' which is
1729 our sentinel for "unset".
1730 '''
1731 if s == '':
1732 return None
1733 p = password.Password()
1734 p.unpack(s)
1735 return p
1737 def _fetchLink(n):
1738 ''' Return None if the link is 0 - otherwise strify it.
1739 '''
1740 return n and str(n) or None
1742 def _fetchDate(n):
1743 ''' Convert the timestamp to a date.Date instance - unless it's 0 which
1744 is our sentinel for "unset".
1745 '''
1746 if n == 0:
1747 return None
1748 return date.Date(time.gmtime(n))
1750 def _fetchInterval(n):
1751 ''' Convert to a date.Interval unless the interval is '' which is our
1752 sentinel for "unset".
1753 '''
1754 if n == '':
1755 return None
1756 return date.Interval(n)
1758 # Converters for boolean and numbers to properly
1759 # return None values.
1760 # These are in conjunction with the setters above
1761 # look for hyperdb.Boolean and hyperdb.Number
1762 if BACKWARDS_COMPATIBLE:
1763 def getBoolean(bool): return bool
1764 def getNumber(number): return number
1765 else:
1766 def getBoolean(bool):
1767 if not bool: res = None
1768 else: res = bool - 1
1769 return res
1771 def getNumber(number):
1772 if number == 0: res = None
1773 elif number < 0: res = number
1774 else: res = number - 1
1775 return res
1777 _converters = {
1778 hyperdb.Date : _fetchDate,
1779 hyperdb.Link : _fetchLink,
1780 hyperdb.Multilink : _fetchML,
1781 hyperdb.Interval : _fetchInterval,
1782 hyperdb.Password : _fetchPW,
1783 hyperdb.Boolean : getBoolean,
1784 hyperdb.Number : getNumber,
1785 hyperdb.String : lambda s: s and str(s) or None,
1786 }
1788 class FileName(hyperdb.String):
1789 isfilename = 1
1791 _typmap = {
1792 FileName : 'S',
1793 hyperdb.String : 'S',
1794 hyperdb.Date : 'I',
1795 hyperdb.Link : 'I',
1796 hyperdb.Multilink : 'V',
1797 hyperdb.Interval : 'S',
1798 hyperdb.Password : 'S',
1799 hyperdb.Boolean : 'I',
1800 hyperdb.Number : 'I',
1801 }
1802 class FileClass(Class, hyperdb.FileClass):
1803 ''' like Class but with a content property
1804 '''
1805 default_mime_type = 'text/plain'
1806 def __init__(self, db, classname, **properties):
1807 properties['content'] = FileName()
1808 if not properties.has_key('type'):
1809 properties['type'] = hyperdb.String()
1810 Class.__init__(self, db, classname, **properties)
1812 def gen_filename(self, nodeid):
1813 nm = '%s%s' % (self.classname, nodeid)
1814 sd = str(int(int(nodeid) / 1000))
1815 d = os.path.join(self.db.config.DATABASE, 'files', self.classname, sd)
1816 if not os.path.exists(d):
1817 os.makedirs(d)
1818 return os.path.join(d, nm)
1820 def get(self, nodeid, propname, default=_marker, cache=1):
1821 if propname == 'content':
1822 poss_msg = 'Possibly an access right configuration problem.'
1823 fnm = self.gen_filename(nodeid)
1824 if not os.path.exists(fnm):
1825 fnm = fnm + '.tmp'
1826 try:
1827 f = open(fnm, 'rb')
1828 except IOError, (strerror):
1829 # XXX by catching this we donot see an error in the log.
1830 return 'ERROR reading file: %s%s\n%s\n%s'%(
1831 self.classname, nodeid, poss_msg, strerror)
1832 x = f.read()
1833 f.close()
1834 else:
1835 x = Class.get(self, nodeid, propname, default)
1836 return x
1838 def create(self, **propvalues):
1839 if not propvalues:
1840 raise ValueError, "Need something to create!"
1841 self.fireAuditors('create', None, propvalues)
1843 content = propvalues['content']
1844 del propvalues['content']
1846 newid = Class.create_inner(self, **propvalues)
1847 if not content:
1848 return newid
1850 # figure a filename
1851 nm = self.gen_filename(newid)
1852 f = open(nm + '.tmp', 'wb')
1853 f.write(content)
1854 f.close()
1856 mimetype = propvalues.get('type', self.default_mime_type)
1857 self.db.indexer.add_text((self.classname, newid, 'content'), content,
1858 mimetype)
1860 # register commit and rollback actions
1861 def commit(fnm=nm):
1862 os.rename(fnm + '.tmp', fnm)
1863 self.commitaction(commit)
1864 def undo(fnm=nm):
1865 os.remove(fnm + '.tmp')
1866 self.rollbackaction(undo)
1867 return newid
1869 def set(self, itemid, **propvalues):
1870 if not propvalues:
1871 return
1872 self.fireAuditors('set', None, propvalues)
1874 content = propvalues.get('content', None)
1875 if content is not None:
1876 del propvalues['content']
1878 propvalues, oldnode = Class.set_inner(self, itemid, **propvalues)
1880 # figure a filename
1881 if content is not None:
1882 nm = self.gen_filename(itemid)
1883 f = open(nm + '.tmp', 'wb')
1884 f.write(content)
1885 f.close()
1886 mimetype = propvalues.get('type', self.default_mime_type)
1887 self.db.indexer.add_text((self.classname, itemid, 'content'),
1888 content, mimetype)
1890 # register commit and rollback actions
1891 def commit(fnm=nm):
1892 if os.path.exists(fnm):
1893 os.remove(fnm)
1894 os.rename(fnm + '.tmp', fnm)
1895 self.commitaction(commit)
1896 def undo(fnm=nm):
1897 os.remove(fnm + '.tmp')
1898 self.rollbackaction(undo)
1900 self.fireReactors('set', oldnode, propvalues)
1902 def index(self, nodeid):
1903 Class.index(self, nodeid)
1904 mimetype = self.get(nodeid, 'type')
1905 if not mimetype:
1906 mimetype = self.default_mime_type
1907 self.db.indexer.add_text((self.classname, nodeid, 'content'),
1908 self.get(nodeid, 'content'), mimetype)
1910 class IssueClass(Class, roundupdb.IssueClass):
1911 ''' The newly-created class automatically includes the "messages",
1912 "files", "nosy", and "superseder" properties. If the 'properties'
1913 dictionary attempts to specify any of these properties or a
1914 "creation" or "activity" property, a ValueError is raised.
1915 '''
1916 def __init__(self, db, classname, **properties):
1917 if not properties.has_key('title'):
1918 properties['title'] = hyperdb.String(indexme='yes')
1919 if not properties.has_key('messages'):
1920 properties['messages'] = hyperdb.Multilink("msg")
1921 if not properties.has_key('files'):
1922 properties['files'] = hyperdb.Multilink("file")
1923 if not properties.has_key('nosy'):
1924 # note: journalling is turned off as it really just wastes
1925 # space. this behaviour may be overridden in an instance
1926 properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
1927 if not properties.has_key('superseder'):
1928 properties['superseder'] = hyperdb.Multilink(classname)
1929 Class.__init__(self, db, classname, **properties)
1931 CURVERSION = 2
1933 class Indexer(Indexer):
1934 disallows = {'THE':1, 'THIS':1, 'ZZZ':1, 'THAT':1, 'WITH':1}
1935 def __init__(self, path, datadb):
1936 self.path = os.path.join(path, 'index.mk4')
1937 self.db = metakit.storage(self.path, 1)
1938 self.datadb = datadb
1939 self.reindex = 0
1940 v = self.db.view('version')
1941 if not v.structure():
1942 v = self.db.getas('version[vers:I]')
1943 self.db.commit()
1944 v.append(vers=CURVERSION)
1945 self.reindex = 1
1946 elif v[0].vers != CURVERSION:
1947 v[0].vers = CURVERSION
1948 self.reindex = 1
1949 if self.reindex:
1950 self.db.getas('ids[tblid:I,nodeid:I,propid:I,ignore:I]')
1951 self.db.getas('index[word:S,hits[pos:I]]')
1952 self.db.commit()
1953 self.reindex = 1
1954 self.changed = 0
1955 self.propcache = {}
1957 def close(self):
1958 '''close the indexing database'''
1959 del self.db
1960 self.db = None
1962 def force_reindex(self):
1963 '''Force a reindexing of the database. This essentially
1964 empties the tables ids and index and sets a flag so
1965 that the databases are reindexed'''
1966 v = self.db.view('ids')
1967 v[:] = []
1968 v = self.db.view('index')
1969 v[:] = []
1970 self.db.commit()
1971 self.reindex = 1
1973 def should_reindex(self):
1974 '''returns True if the indexes need to be rebuilt'''
1975 return self.reindex
1977 def _getprops(self, classname):
1978 props = self.propcache.get(classname, None)
1979 if props is None:
1980 props = self.datadb.view(classname).structure()
1981 props = [prop.name for prop in props]
1982 self.propcache[classname] = props
1983 return props
1985 def _getpropid(self, classname, propname):
1986 return self._getprops(classname).index(propname)
1988 def _getpropname(self, classname, propid):
1989 return self._getprops(classname)[propid]
1991 def add_text(self, identifier, text, mime_type='text/plain'):
1992 if mime_type != 'text/plain':
1993 return
1994 classname, nodeid, property = identifier
1995 tbls = self.datadb.view('tables')
1996 tblid = tbls.find(name=classname)
1997 if tblid < 0:
1998 raise KeyError, "unknown class %r"%classname
1999 nodeid = int(nodeid)
2000 propid = self._getpropid(classname, property)
2001 ids = self.db.view('ids')
2002 oldpos = ids.find(tblid=tblid,nodeid=nodeid,propid=propid,ignore=0)
2003 if oldpos > -1:
2004 ids[oldpos].ignore = 1
2005 self.changed = 1
2006 pos = ids.append(tblid=tblid,nodeid=nodeid,propid=propid)
2008 wordlist = re.findall(r'\b\w{2,25}\b', text.upper())
2009 words = {}
2010 for word in wordlist:
2011 if not self.disallows.has_key(word):
2012 words[word] = 1
2013 words = words.keys()
2015 index = self.db.view('index').ordered(1)
2016 for word in words:
2017 ndx = index.find(word=word)
2018 if ndx < 0:
2019 index.append(word=word)
2020 ndx = index.find(word=word)
2021 index[ndx].hits.append(pos=pos)
2022 self.changed = 1
2024 def find(self, wordlist):
2025 '''look up all the words in the wordlist.
2026 If none are found return an empty dictionary
2027 * more rules here
2028 '''
2029 hits = None
2030 index = self.db.view('index').ordered(1)
2031 for word in wordlist:
2032 word = word.upper()
2033 if not 2 < len(word) < 26:
2034 continue
2035 ndx = index.find(word=word)
2036 if ndx < 0:
2037 return {}
2038 if hits is None:
2039 hits = index[ndx].hits
2040 else:
2041 hits = hits.intersect(index[ndx].hits)
2042 if len(hits) == 0:
2043 return {}
2044 if hits is None:
2045 return {}
2046 rslt = {}
2047 ids = self.db.view('ids').remapwith(hits)
2048 tbls = self.datadb.view('tables')
2049 for i in range(len(ids)):
2050 hit = ids[i]
2051 if not hit.ignore:
2052 classname = tbls[hit.tblid].name
2053 nodeid = str(hit.nodeid)
2054 property = self._getpropname(classname, hit.propid)
2055 rslt[i] = (classname, nodeid, property)
2056 return rslt
2058 def save_index(self):
2059 if self.changed:
2060 self.db.commit()
2061 self.changed = 0
2063 def rollback(self):
2064 if self.changed:
2065 self.db.rollback()
2066 self.db = metakit.storage(self.path, 1)
2067 self.changed = 0