Skip to content

Commit f7d0708

Browse files
author
Ralf Schlatterbeck
committed
Implemented what I'll call for now "transitive searching"...
...using the filter method. The first idea was mentioned on the roundup-users mailing list: http://article.gmane.org/gmane.comp.bug-tracking.roundup.user/6909 We can now search for items which link transitively to other classes using filter. An example is searching for all items where a certain user has added a message in the last week: db.issue.filter (None, {'messages.author' : '42', 'messages.date' : '.-1w;'}) or more readable (but not exactly semantically equivalent, if you're searching for multiple users in this way it will fail, because string searches are ANDed): {'messages.author.username':'ralf', ... We can even extend this further, look for all items that were changed by users belonging to a certain department (having the same supervisor -- a property that is not in the user class in standard roundup) in the last week, the filterspec would be: {'messages.author.supervisor' : '42', 'messages.date' : '.-1w;'} If anybody wants to suggest another name instead of transitive searching, you're welcome. I've implemented a generic method for this in hyperdb.py -- the backend now implements _filter in this case. With the generic method, anydbm and metakit should work (anydbm is tested, metakit breaks for other reasons). A backend may chose to implement the real transitive filter itself. This was done for rdbms_common.py. It now has an implementation of filter that supports transitive searching by creating one big join in the generated SQL query. I've added several new regression tests to test for the new features. All the tests (not just the new ones) run through on python2.3 and python2.4 with postgres, mysql, sqlite, anydbm -- but metakit was already broken when I started. I've generated a tag before commit called 'rsc_before_transitive_search' and will create the 'after' tag after this commit, so you can merge out my changes if you don't like them -- if you like them I can remove the tags. .-- Ralf
1 parent 8f8471d commit f7d0708

File tree

6 files changed

+262
-44
lines changed

6 files changed

+262
-44
lines changed

roundup/backends/back_anydbm.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
1616
# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
1717
#
18-
#$Id: back_anydbm.py,v 1.199 2006-04-27 04:59:37 richard Exp $
18+
#$Id: back_anydbm.py,v 1.200 2006-07-08 18:28:18 schlatterbeck Exp $
1919
'''This module defines a backend that saves the hyperdatabase in a
2020
database chosen by anydbm. It is guaranteed to always be available in python
2121
versions >2.1.1 (the dumbdbm fallback in 2.1.1 and earlier has several
@@ -1531,7 +1531,7 @@ def getnodeids(self, db=None, retired=None):
15311531
db.close()
15321532
return res
15331533

1534-
def filter(self, search_matches, filterspec, sort=(None,None),
1534+
def _filter(self, search_matches, filterspec, sort=(None,None),
15351535
group=(None,None), num_re = re.compile('^\d+$')):
15361536
"""Return a list of the ids of the active nodes in this class that
15371537
match the 'filter' spec, sorted by the group spec and then the

roundup/backends/back_metakit.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# $Id: back_metakit.py,v 1.108 2006-04-27 04:59:37 richard Exp $
1+
# $Id: back_metakit.py,v 1.109 2006-07-08 18:28:18 schlatterbeck Exp $
22
'''Metakit backend for Roundup, originally by Gordon McMillan.
33
44
Known Current Bugs:
@@ -1155,7 +1155,7 @@ def addprop(self, **properties):
11551155
self.db.commit()
11561156
# ---- end of ping's spec
11571157

1158-
def filter(self, search_matches, filterspec, sort=(None,None),
1158+
def _filter(self, search_matches, filterspec, sort=(None,None),
11591159
group=(None,None)):
11601160
'''Return a list of the ids of the active nodes in this class that
11611161
match the 'filter' spec, sorted by the group spec and then the

roundup/backends/rdbms_common.py

Lines changed: 55 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
1616
# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
1717
#
18-
#$Id: rdbms_common.py,v 1.171 2006-07-07 15:04:28 schlatterbeck Exp $
18+
#$Id: rdbms_common.py,v 1.172 2006-07-08 18:28:18 schlatterbeck Exp $
1919
''' Relational database (SQL) backend common code.
2020
2121
Basics:
@@ -2048,7 +2048,7 @@ def filter(self, search_matches, filterspec, sort=(None,None),
20482048
if __debug__:
20492049
start_t = time.time()
20502050

2051-
cn = self.classname
2051+
icn = self.classname
20522052

20532053
# vars to hold the components of the SQL statement
20542054
frum = [] # FROM clauses
@@ -2058,36 +2058,56 @@ def filter(self, search_matches, filterspec, sort=(None,None),
20582058
a = self.db.arg
20592059

20602060
# figure the WHERE clause from the filterspec
2061-
props = self.getprops()
20622061
mlfilt = 0 # are we joining with Multilink tables?
2063-
for k, v in filterspec.items():
2064-
propclass = props[k]
2062+
proptree = self._proptree (filterspec)
2063+
for p in proptree :
2064+
cn = p.classname
2065+
ln = p.uniqname
2066+
pln = p.parent.uniqname
2067+
pcn = p.parent.classname
2068+
k = p.name
2069+
v = p.val
2070+
propclass = p.prcls
2071+
if p.children :
2072+
if isinstance (propclass, Multilink) :
2073+
mlfilt = 1
2074+
mn = '%s_%s'%(pcn, p.name)
2075+
frum.append(mn)
2076+
frum.append('_%s as _%s' % (cn, ln))
2077+
where.append('_%s.id=%s.nodeid and %s.linkid=_%s.id'%(pln,
2078+
mn, mn, ln))
2079+
else :
2080+
if not isinstance (propclass, Link) :
2081+
raise ValueError,"%s must be Link/Multilink property"%k
2082+
frum.append('_%s as _%s' % (cn, ln))
2083+
where.append('_%s._%s=_%s.id'%(pln, k, ln))
2084+
continue
20652085
# now do other where clause stuff
2066-
if isinstance(propclass, Multilink):
2086+
elif isinstance(propclass, Multilink):
20672087
mlfilt = 1
2068-
tn = '%s_%s'%(cn, k)
2088+
tn = '%s_%s'%(pcn, k)
20692089
if v in ('-1', ['-1']):
20702090
# only match rows that have count(linkid)=0 in the
20712091
# corresponding multilink table)
2072-
where.append(self._subselect(cn, tn))
2092+
where.append(self._subselect(pcn, tn))
20732093
elif isinstance(v, type([])):
20742094
frum.append(tn)
20752095
s = ','.join([a for x in v])
2076-
where.append('_%s.id=%s.nodeid and %s.linkid in (%s)'%(cn,
2096+
where.append('_%s.id=%s.nodeid and %s.linkid in (%s)'%(pln,
20772097
tn, tn, s))
20782098
args = args + v
20792099
else:
20802100
frum.append(tn)
2081-
where.append('_%s.id=%s.nodeid and %s.linkid=%s'%(cn, tn,
2101+
where.append('_%s.id=%s.nodeid and %s.linkid=%s'%(pln, tn,
20822102
tn, a))
20832103
args.append(v)
20842104
elif k == 'id':
20852105
if isinstance(v, type([])):
20862106
s = ','.join([a for x in v])
2087-
where.append('_%s.%s in (%s)'%(cn, k, s))
2107+
where.append('_%s.%s in (%s)'%(pln, k, s))
20882108
args = args + v
20892109
else:
2090-
where.append('_%s.%s=%s'%(cn, k, a))
2110+
where.append('_%s.%s=%s'%(pln, k, a))
20912111
args.append(v)
20922112
elif isinstance(propclass, String):
20932113
if not isinstance(v, type([])):
@@ -2100,7 +2120,7 @@ def filter(self, search_matches, filterspec, sort=(None,None),
21002120

21012121
# now add to the where clause
21022122
where.append('('
2103-
+' and '.join(["_%s._%s LIKE '%s'"%(cn, k, s) for s in v])
2123+
+' and '.join(["_%s._%s LIKE '%s'"%(pln, k, s) for s in v])
21042124
+')')
21052125
# note: args are embedded in the query string now
21062126
elif isinstance(propclass, Link):
@@ -2113,36 +2133,36 @@ def filter(self, search_matches, filterspec, sort=(None,None),
21132133
l = []
21142134
if d.has_key(None) or not d:
21152135
del d[None]
2116-
l.append('_%s._%s is NULL'%(cn, k))
2136+
l.append('_%s._%s is NULL'%(pln, k))
21172137
if d:
21182138
v = d.keys()
21192139
s = ','.join([a for x in v])
2120-
l.append('(_%s._%s in (%s))'%(cn, k, s))
2140+
l.append('(_%s._%s in (%s))'%(pln, k, s))
21212141
args = args + v
21222142
if l:
21232143
where.append('(' + ' or '.join(l) +')')
21242144
else:
21252145
if v in ('-1', None):
21262146
v = None
2127-
where.append('_%s._%s is NULL'%(cn, k))
2147+
where.append('_%s._%s is NULL'%(pln, k))
21282148
else:
2129-
where.append('_%s._%s=%s'%(cn, k, a))
2149+
where.append('_%s._%s=%s'%(pln, k, a))
21302150
args.append(v)
21312151
elif isinstance(propclass, Date):
21322152
dc = self.db.hyperdb_to_sql_value[hyperdb.Date]
21332153
if isinstance(v, type([])):
21342154
s = ','.join([a for x in v])
2135-
where.append('_%s._%s in (%s)'%(cn, k, s))
2155+
where.append('_%s._%s in (%s)'%(pln, k, s))
21362156
args = args + [dc(date.Date(x)) for x in v]
21372157
else:
21382158
try:
21392159
# Try to filter on range of dates
21402160
date_rng = propclass.range_from_raw(v, self.db)
21412161
if date_rng.from_value:
2142-
where.append('_%s._%s >= %s'%(cn, k, a))
2162+
where.append('_%s._%s >= %s'%(pln, k, a))
21432163
args.append(dc(date_rng.from_value))
21442164
if date_rng.to_value:
2145-
where.append('_%s._%s <= %s'%(cn, k, a))
2165+
where.append('_%s._%s <= %s'%(pln, k, a))
21462166
args.append(dc(date_rng.to_value))
21472167
except ValueError:
21482168
# If range creation fails - ignore that search parameter
@@ -2151,38 +2171,40 @@ def filter(self, search_matches, filterspec, sort=(None,None),
21512171
# filter using the __<prop>_int__ column
21522172
if isinstance(v, type([])):
21532173
s = ','.join([a for x in v])
2154-
where.append('_%s.__%s_int__ in (%s)'%(cn, k, s))
2174+
where.append('_%s.__%s_int__ in (%s)'%(pln, k, s))
21552175
args = args + [date.Interval(x).as_seconds() for x in v]
21562176
else:
21572177
try:
21582178
# Try to filter on range of intervals
21592179
date_rng = Range(v, date.Interval)
21602180
if date_rng.from_value:
2161-
where.append('_%s.__%s_int__ >= %s'%(cn, k, a))
2181+
where.append('_%s.__%s_int__ >= %s'%(pln, k, a))
21622182
args.append(date_rng.from_value.as_seconds())
21632183
if date_rng.to_value:
2164-
where.append('_%s.__%s_int__ <= %s'%(cn, k, a))
2184+
where.append('_%s.__%s_int__ <= %s'%(pln, k, a))
21652185
args.append(date_rng.to_value.as_seconds())
21662186
except ValueError:
21672187
# If range creation fails - ignore that search parameter
21682188
pass
21692189
else:
21702190
if isinstance(v, type([])):
21712191
s = ','.join([a for x in v])
2172-
where.append('_%s._%s in (%s)'%(cn, k, s))
2192+
where.append('_%s._%s in (%s)'%(pln, k, s))
21732193
args = args + v
21742194
else:
2175-
where.append('_%s._%s=%s'%(cn, k, a))
2195+
where.append('_%s._%s=%s'%(pln, k, a))
21762196
args.append(v)
21772197

2198+
props = self.getprops()
2199+
21782200
# don't match retired nodes
2179-
where.append('_%s.__retired__ <> 1'%cn)
2201+
where.append('_%s.__retired__ <> 1'%icn)
21802202

21812203
# add results of full text search
21822204
if search_matches is not None:
21832205
v = search_matches.keys()
21842206
s = ','.join([a for x in v])
2185-
where.append('_%s.id in (%s)'%(cn, s))
2207+
where.append('_%s.id in (%s)'%(icn, s))
21862208
args = args + v
21872209

21882210
# sanity check: sorting *and* grouping on the same property?
@@ -2208,7 +2230,7 @@ def filter(self, search_matches, filterspec, sort=(None,None),
22082230
# determine whether the linked Class has an order property
22092231
lcn = props[prop].classname
22102232
link = self.db.classes[lcn]
2211-
o = '_%s._%s'%(cn, prop)
2233+
o = '_%s._%s'%(icn, prop)
22122234
op = link.orderprop()
22132235
if op != 'id':
22142236
tn = '_' + lcn
@@ -2219,16 +2241,16 @@ def filter(self, search_matches, filterspec, sort=(None,None),
22192241
o = '%s._%s'%(rhs, op)
22202242
ordercols.append(o)
22212243
elif prop == 'id':
2222-
o = '_%s.id'%cn
2244+
o = '_%s.id'%icn
22232245
else:
2224-
o = '_%s._%s'%(cn, prop)
2246+
o = '_%s._%s'%(icn, prop)
22252247
ordercols.append(o)
22262248
if sdir == '-':
22272249
o += ' desc'
22282250
orderby.append(o)
22292251

22302252
# construct the SQL
2231-
frum.append('_'+cn)
2253+
frum.append('_'+icn)
22322254
frum = ','.join(frum)
22332255
if where:
22342256
where = ' where ' + (' and '.join(where))
@@ -2237,9 +2259,9 @@ def filter(self, search_matches, filterspec, sort=(None,None),
22372259
if mlfilt:
22382260
# we're joining tables on the id, so we will get dupes if we
22392261
# don't distinct()
2240-
cols = ['distinct(_%s.id)'%cn]
2262+
cols = ['distinct(_%s.id)'%icn]
22412263
else:
2242-
cols = ['_%s.id'%cn]
2264+
cols = ['_%s.id'%icn]
22432265
if orderby:
22442266
cols = cols + ordercols
22452267
order = ' order by %s'%(','.join(orderby))

roundup/hyperdb.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
1616
# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
1717
#
18-
# $Id: hyperdb.py,v 1.120 2006-05-06 17:18:03 a1s Exp $
18+
# $Id: hyperdb.py,v 1.121 2006-07-08 18:28:18 schlatterbeck Exp $
1919

2020
"""Hyperdatabase implementation, especially field types.
2121
"""
@@ -26,7 +26,7 @@
2626

2727
# roundup modules
2828
import date, password
29-
from support import ensureParentsExist, PrioList
29+
from support import ensureParentsExist, PrioList, Proptree
3030

3131
#
3232
# Types
@@ -706,6 +706,37 @@ def find(self, **propspec):
706706
"""
707707
raise NotImplementedError
708708

709+
def _filter(self, search_matches, filterspec, sort=(None,None),
710+
group=(None,None)):
711+
"""For some backends this implements the non-transitive
712+
search, for more information see the filter method.
713+
"""
714+
raise NotImplementedError
715+
716+
def _proptree (self, filterspec) :
717+
"""Build a tree of all transitive properties in the given
718+
filterspec.
719+
"""
720+
proptree = Proptree (self.db, self, '', self.getprops ())
721+
for key, v in filterspec.iteritems () :
722+
keys = key.split ('.')
723+
p = proptree
724+
for k in keys :
725+
p = p.append (k)
726+
p.val = v
727+
return proptree
728+
729+
def _propsearch (self, search_matches, proptree, sort, group) :
730+
""" Recursively search for the given properties in proptree.
731+
Once all properties are non-transitive, the search generates a
732+
simple _filter call which does the real work
733+
"""
734+
for p in proptree.children :
735+
if not p.children : continue
736+
p.val = p.cls._propsearch (None, p, (None, None), (None, None))
737+
filterspec = dict ([(p.name, p.val) for p in proptree.children])
738+
return self._filter (search_matches, filterspec, sort, group)
739+
709740
def filter(self, search_matches, filterspec, sort=(None,None),
710741
group=(None,None)):
711742
"""Return a list of the ids of the active nodes in this class that
@@ -714,6 +745,12 @@ def filter(self, search_matches, filterspec, sort=(None,None),
714745
715746
"filterspec" is {propname: value(s)}
716747
748+
Note that now the propname in filterspec may be transitive,
749+
i.e., it may contain properties of the form link.link.link.name,
750+
e.g. you can search for all issues where a message was added by
751+
a certain user in the last week with a filterspec of
752+
{'messages.author' : '42', 'messages.creation' : '.-1w;'}
753+
717754
"sort" and "group" are (dir, prop) where dir is '+', '-' or None
718755
and prop is a prop name or None
719756
@@ -724,8 +761,16 @@ def filter(self, search_matches, filterspec, sort=(None,None),
724761
725762
1. String properties must match all elements in the list, and
726763
2. Other properties must match any of the elements in the list.
764+
765+
Implementation note:
766+
This implements a non-optimized version of Transitive search
767+
using _filter implemented in a backend class. A more efficient
768+
version can be implemented in the individual backends -- e.g.,
769+
an SQL backen will want to create a single SQL statement and
770+
override the filter method instead of implementing _filter.
727771
"""
728-
raise NotImplementedError
772+
proptree = self._proptree (filterspec)
773+
return self._propsearch (search_matches, proptree, sort, group)
729774

730775
def count(self):
731776
"""Get the number of nodes in this class.

0 commit comments

Comments
 (0)