Skip to content

Commit 2986750

Browse files
Bernhard ReiterBernhard Reiter
authored andcommitted
Multilinks can be filtered by combining elements with AND, OR and NOT now.
A javascript gui was added for "keywords", see issue2550648. Developed by Sascha Teichmann; funded by Intevation. (Bernhard Reiter)
1 parent 20ef5d1 commit 2986750

File tree

8 files changed

+290
-10
lines changed

8 files changed

+290
-10
lines changed

CHANGES.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ Features:
88

99
- Add explicit "Search" permissions, see Security Fix below.
1010
- Add "lookup" method to xmlrpc interface (Ralf Schlatterbeck)
11+
- Multilinks can be filtered by combining elements with AND, OR and NOT
12+
operators now. A javascript gui was added for "keywords", see issue2550648.
13+
Developed by Sascha Teichmann; funded by Intevation. (Bernhard Reiter)
1114

1215
Fixed:
1316

roundup/backends/back_anydbm.py

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,87 @@ def db_exists(config):
4949
def db_nuke(config):
5050
shutil.rmtree(config.DATABASE)
5151

52+
class Binary:
53+
54+
def __init__(self, x, y):
55+
self.x = x
56+
self.y = y
57+
58+
def visit(self, visitor):
59+
self.x.visit(visitor)
60+
self.y.visit(visitor)
61+
62+
class Unary:
63+
64+
def __init__(self, x):
65+
self.x = x
66+
67+
def generate(self, atom):
68+
return atom(self)
69+
70+
def visit(self, visitor):
71+
self.x.visit(visitor)
72+
73+
class Equals(Unary):
74+
75+
def evaluate(self, v):
76+
return self.x in v
77+
78+
def visit(self, visitor):
79+
visitor(self)
80+
81+
class Not(Unary):
82+
83+
def evaluate(self, v):
84+
return not self.x.evaluate(v)
85+
86+
def generate(self, atom):
87+
return "NOT(%s)" % self.x.generate(atom)
88+
89+
class Or(Binary):
90+
91+
def evaluate(self, v):
92+
return self.x.evaluate(v) or self.y.evaluate(v)
93+
94+
def generate(self, atom):
95+
return "(%s)OR(%s)" % (
96+
self.x.generate(atom),
97+
self.y.generate(atom))
98+
99+
class And(Binary):
100+
101+
def evaluate(self, v):
102+
return self.x.evaluate(v) and self.y.evaluate(v)
103+
104+
def generate(self, atom):
105+
return "(%s)AND(%s)" % (
106+
self.x.generate(atom),
107+
self.y.generate(atom))
108+
109+
def compile_expression(opcodes):
110+
111+
stack = []
112+
push, pop = stack.append, stack.pop
113+
for opcode in opcodes:
114+
if opcode == -2: push(Not(pop()))
115+
elif opcode == -3: push(And(pop(), pop()))
116+
elif opcode == -4: push(Or(pop(), pop()))
117+
else: push(Equals(opcode))
118+
119+
return pop()
120+
121+
class Expression:
122+
123+
def __init__(self, v):
124+
try:
125+
opcodes = [int(x) for x in v]
126+
if min(opcodes) >= -1: raise ValueError()
127+
128+
compiled = compile_expression(opcodes)
129+
self.evaluate = lambda x: compiled.evaluate([int(y) for y in x])
130+
except:
131+
self.evaluate = lambda x: bool(set(x) & set(v))
132+
52133
#
53134
# Now the database
54135
#
@@ -1702,12 +1783,10 @@ def _filter(self, search_matches, filterspec, proptree,
17021783
if not v:
17031784
match = not nv
17041785
else:
1705-
# othewise, make sure this node has each of the
1786+
# otherwise, make sure this node has each of the
17061787
# required values
1707-
for want in v:
1708-
if want in nv:
1709-
match = 1
1710-
break
1788+
expr = Expression(v)
1789+
if expr.evaluate(nv): match = 1
17111790
elif t == STRING:
17121791
if nv is None:
17131792
nv = ''

roundup/backends/back_mysql.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,11 @@ def sql_close(self):
564564
raise
565565

566566
class MysqlClass:
567+
568+
def supports_subselects(self):
569+
# TODO: AFAIK its version dependent for MySQL
570+
return False
571+
567572
def _subselect(self, classname, multilink_table):
568573
''' "I can't believe it's not a toy RDBMS"
569574
see, even toy RDBMSes like gadfly and sqlite can do sub-selects...

roundup/backends/rdbms_common.py

Lines changed: 161 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@
7171
from roundup.backends.sessions_rdbms import Sessions, OneTimeKeys
7272
from roundup.date import Range
7373

74+
from roundup.backends.back_anydbm import compile_expression
75+
76+
7477
# dummy value meaning "argument not passed"
7578
_marker = []
7679

@@ -100,6 +103,54 @@ def connection_dict(config, dbnamestr=None):
100103
d[name] = config[cvar]
101104
return d
102105

106+
107+
class IdListOptimizer:
108+
""" To prevent flooding the SQL parser of the underlaying
109+
db engine with "x IN (1, 2, 3, ..., <large number>)" collapses
110+
these cases to "x BETWEEN 1 AND <large number>".
111+
"""
112+
113+
def __init__(self):
114+
self.ranges = []
115+
self.singles = []
116+
117+
def append(self, nid):
118+
""" Invariant: nid are ordered ascending """
119+
if self.ranges:
120+
last = self.ranges[-1]
121+
if last[1] == nid-1:
122+
last[1] = nid
123+
return
124+
if self.singles:
125+
last = self.singles[-1]
126+
if last == nid-1:
127+
self.singles.pop()
128+
self.ranges.append([last, nid])
129+
return
130+
self.singles.append(nid)
131+
132+
def where(self, field, placeholder):
133+
ranges = self.ranges
134+
singles = self.singles
135+
136+
if not singles and not ranges: return "(1=0)", []
137+
138+
if ranges:
139+
between = '%s BETWEEN %s AND %s' % (
140+
field, placeholder, placeholder)
141+
stmnt = [between] * len(ranges)
142+
else:
143+
stmnt = []
144+
if singles:
145+
stmnt.append('%s in (%s)' % (
146+
field, ','.join([placeholder]*len(singles))))
147+
148+
return '(%s)' % ' OR '.join(stmnt), sum(ranges, []) + singles
149+
150+
def __str__(self):
151+
return "ranges: %r / singles: %r" % (self.ranges, self.singles)
152+
153+
103154
class Database(FileStorage, hyperdb.Database, roundupdb.Database):
104155
""" Wrapper around an SQL database that presents a hyperdb interface.
105156
@@ -170,6 +221,14 @@ def sql_fetchall(self):
170221
"""
171222
return self.cursor.fetchall()
172223

224+
def sql_fetchiter(self):
225+
""" Fetch all row as a generator
226+
"""
227+
while True:
228+
row = self.cursor.fetchone()
229+
if not row: break
230+
yield row
231+
173232
def sql_stringquote(self, value):
174233
""" Quote the string so it's safe to put in the 'sql quotes'
175234
"""
@@ -2134,6 +2193,95 @@ def _subselect(self, classname, multilink_table):
21342193
# The format parameter is replaced with the attribute.
21352194
order_by_null_values = None
21362195

2196+
def supports_subselects(self):
2197+
'''Assuming DBs can do subselects, overwrite if they cannot.
2198+
'''
2199+
return True
2200+
2201+
def _filter_multilink_expression_fallback(
2202+
self, classname, multilink_table, expr):
2203+
'''This is a fallback for database that do not support
2204+
subselects.'''
2205+
2206+
is_valid = expr.evaluate
2207+
2208+
last_id, kws = None, []
2209+
2210+
ids = IdListOptimizer()
2211+
append = ids.append
2212+
2213+
# This join and the evaluation in program space
2214+
# can be expensive for larger databases!
2215+
# TODO: Find a faster way to collect the data needed
2216+
# to evalute the expression.
2217+
# Moving the expression evaluation into the database
2218+
# would be nice but this tricky: Think about the cases
2219+
# where the multilink table does not have join values
2220+
# needed in evaluation.
2221+
2222+
stmnt = "SELECT c.id, m.linkid FROM _%s c " \
2223+
"LEFT OUTER JOIN %s m " \
2224+
"ON c.id = m.nodeid ORDER BY c.id" % (
2225+
classname, multilink_table)
2226+
self.db.sql(stmnt)
2227+
2228+
# collect all multilink items for a class item
2229+
for nid, kw in self.db.sql_fetchiter():
2230+
if nid != last_id:
2231+
if last_id is None:
2232+
last_id = nid
2233+
else:
2234+
# we have all multilink items -> evaluate!
2235+
if is_valid(kws): append(last_id)
2236+
last_id, kws = nid, []
2237+
if kw is not None:
2238+
kws.append(kw)
2239+
2240+
if last_id is not None and is_valid(kws):
2241+
append(last_id)
2242+
2243+
# we have ids of the classname table
2244+
return ids.where("_%s.id" % classname, self.db.arg)
2245+
2246+
def _filter_multilink_expression(self, classname, multilink_table, v):
2247+
""" Filters out elements of the classname table that do not
2248+
match the given expression.
2249+
Returns tuple of 'WHERE' introns for the overall filter.
2250+
"""
2251+
try:
2252+
opcodes = [int(x) for x in v]
2253+
if min(opcodes) >= -1: raise ValueError()
2254+
2255+
expr = compile_expression(opcodes)
2256+
2257+
if not self.supports_subselects():
2258+
# We heavily rely on subselects. If there is
2259+
# no decent support fall back to slower variant.
2260+
return self._filter_multilink_expression_fallback(
2261+
classname, multilink_table, expr)
2262+
2263+
atom = \
2264+
"%s IN(SELECT linkid FROM %s WHERE nodeid=a.id)" % (
2265+
self.db.arg,
2266+
multilink_table)
2267+
2268+
intron = \
2269+
"_%(classname)s.id in (SELECT id " \
2270+
"FROM _%(classname)s AS a WHERE %(condition)s) " % {
2271+
'classname' : classname,
2272+
'condition' : expr.generate(lambda n: atom) }
2273+
2274+
values = []
2275+
def collect_values(n): values.append(n.x)
2276+
expr.visit(collect_values)
2277+
2278+
return intron, values
2279+
except:
2280+
# original behavior
2281+
where = "%s.linkid in (%s)" % (
2282+
multilink_table, ','.join([self.db.arg] * len(v)))
2283+
return where, v, True # True to indicate original
2284+
21372285
def filter(self, search_matches, filterspec, sort=[], group=[]):
21382286
"""Return a list of the ids of the active nodes in this class that
21392287
match the 'filter' spec, sorted by the group spec and then the
@@ -2213,15 +2361,24 @@ def filter(self, search_matches, filterspec, sort=[], group=[]):
22132361
where.append(self._subselect(pcn, tn))
22142362
else:
22152363
frum.append(tn)
2216-
where.append('_%s.id=%s.nodeid'%(pln,tn))
2364+
gen_join = True
2365+
2366+
if p.has_values and isinstance(v, type([])):
2367+
result = self._filter_multilink_expression(pln, tn, v)
2368+
# XXX: We dont need an id join if we used the filter
2369+
gen_join = len(result) == 3
2370+
2371+
if gen_join:
2372+
where.append('_%s.id=%s.nodeid'%(pln,tn))
2373+
22172374
if p.children:
22182375
frum.append('_%s as _%s' % (cn, ln))
22192376
where.append('%s.linkid=_%s.id'%(tn, ln))
2377+
22202378
if p.has_values:
22212379
if isinstance(v, type([])):
2222-
s = ','.join([a for x in v])
2223-
where.append('%s.linkid in (%s)'%(tn, s))
2224-
args = args + v
2380+
where.append(result[0])
2381+
args += result[1]
22252382
else:
22262383
where.append('%s.linkid=%s'%(tn, a))
22272384
args.append(v)

roundup/cgi/templating.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
from roundup import i18n
2828
from roundup.i18n import _
2929

30+
from KeywordsExpr import render_keywords_expression_editor
31+
3032
try:
3133
import cPickle as pickle
3234
except ImportError:
@@ -2863,6 +2865,9 @@ def __getattr__(self, name):
28632865
raise AttributeError, name
28642866
return self.client.instance.templating_utils[name]
28652867

2868+
def keywords_expressions(self, request):
2869+
return render_keywords_expression_editor(request)
2870+
28662871
def html_calendar(self, request):
28672872
"""Generate a HTML calendar.
28682873

share/roundup/templates/classic/html/issue.search.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
sort_input templates/page/macros/sort_input;
2424
group_input templates/page/macros/group_input;
2525
search_select templates/page/macros/search_select;
26+
search_select_keywords templates/page/macros/search_select_keywords;
2627
search_select_translated templates/page/macros/search_select_translated;
2728
search_multiselect templates/page/macros/search_multiselect;">
2829

@@ -54,7 +55,7 @@
5455
db_klass string:keyword;
5556
db_content string:name;">
5657
<th i18n:translate="">Keyword:</th>
57-
<td metal:use-macro="search_select">
58+
<td metal:use-macro="search_select_keywords">
5859
<option metal:fill-slot="extra_options" value="-1" i18n:translate=""
5960
tal:attributes="selected python:value == '-1'">not selected</option>
6061
</td>

share/roundup/templates/classic/html/page.html

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,22 @@ <h2><span metal:define-slot="body_title">body title</span></h2>
247247
</select>
248248
</td>
249249

250+
<td metal:define-macro="search_select_keywords">
251+
<div tal:attributes="id python:'''keywords_%s'''%name">
252+
<select tal:attributes="name name; id name"
253+
tal:define="value python:request.form.getvalue(name)">
254+
<option value="" i18n:translate="">don't care</option>
255+
<metal:slot define-slot="extra_options" />
256+
<option value="" i18n:translate="" disabled="disabled">------------</option>
257+
<option tal:repeat="s python:db[db_klass].list()"
258+
tal:attributes="value s/id; selected python:value == s.id"
259+
tal:content="python:s[db_content]"></option>
260+
</select>
261+
<a class="classhelp"
262+
tal:attributes="href python:'''javascript:help_window('issue?@template=keywords_expr&property=%s&form=itemSynopsis', 350, 200)'''%name">(expr)</a>
263+
</div>
264+
</td>
265+
250266
<!-- like search_select, but translates the further values.
251267
Could extend it (METAL 1.1 attribute "extend-macro")
252268
-->

0 commit comments

Comments
 (0)