Skip to content

Commit 8db9842

Browse files
committed
version 20250526.1
1 parent f8192c2 commit 8db9842

File tree

3 files changed

+144
-99
lines changed

3 files changed

+144
-99
lines changed

pydal/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
__version__ = "20250525.3"
1+
__version__ = "20250526.1"
22

33
from .base import DAL
44
from .helpers.classes import SQLCustomType

pydal/querybuilder.py

Lines changed: 75 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -39,25 +39,31 @@ def validate(field, value):
3939

4040

4141
class QueryBuilder:
42-
token_not = "not"
43-
tokens_transform = {
44-
"upper": "upper",
45-
"lower": "lower",
42+
tokens_not = {
43+
"not",
4644
}
47-
tokens_op = {
48-
"is null": "is null",
49-
"is not null": "is not null",
50-
"==": "==",
51-
"!=": "!=",
52-
"<": "<",
53-
">": ">",
54-
"<=": "<=",
55-
">=": ">=",
56-
"belongs": "belongs",
57-
"contains": "contains",
58-
"startswith": "startswith",
45+
tokens_ops = {
46+
"upper",
47+
"lower",
48+
"is null",
49+
"is not null",
50+
"is true",
51+
"is false",
52+
"==",
53+
"!=",
54+
"<",
55+
">",
56+
"<=",
57+
">=",
58+
"belongs",
59+
"contains",
60+
"startswith",
5961
}
60-
tokens_aliases = {
62+
tokens_and_or = {
63+
"and",
64+
"or",
65+
}
66+
default_token_aliases = {
6167
"is": "==",
6268
"is equal": "==",
6369
"is equal to": "==",
@@ -79,7 +85,6 @@ class QueryBuilder:
7985
"in": "belongs",
8086
"starts with": "startswith",
8187
}
82-
tokens_bool = {"and": "and", "or": "or"}
8388
# regex matching field names, and
8489
re_token = re.compile(r"^(\w+)\s*(.*)$")
8590
# regex matching a value or quoted value
@@ -90,38 +95,52 @@ class QueryBuilder:
9095
def __init__(
9196
self,
9297
table,
93-
token_not=None,
94-
tokens_transform=None,
95-
tokens_op=None,
96-
tokens_aliases=None,
97-
tokens_bool=None,
98+
field_aliases=None,
99+
token_aliases=None,
98100
debug=False,
99101
):
102+
"""
103+
Creates a QueryBuilder object
104+
params:
105+
- table: the table object to be searched
106+
- field_aliases: an optional mapping between desired field names and actual field names.
107+
If present only listed fields will be searchable. If only only readable fields.
108+
- token_aliases: a mapping between expressions like "is equal to" into operations like "==".
109+
"""
100110
self.table = table
101-
self.token_not = token_not or QueryBuilder.token_not
102-
self.tokens_transform = tokens_transform or QueryBuilder.tokens_transform
103-
self.tokens_op = tokens_op or QueryBuilder.tokens_op
104-
self.tokens_aliases = (
105-
QueryBuilder.tokens_aliases if tokens_aliases is None else tokens_aliases
106-
)
107-
self.tokens_bool = tokens_bool or QueryBuilder.tokens_bool
108-
# regex matching a not
109-
self.re_not = re.compile(r"^(" + self.token_not + r")(\W.*)$")
110-
# regex matcing operators
111-
self.tokens_all = {
112-
**self.tokens_transform,
113-
**self.tokens_op,
114-
**self.tokens_aliases,
115-
}
111+
# we either override all fields or none
112+
if field_aliases:
113+
self.fields = {k: table[v] for k, v in field_aliases.items()}
114+
else:
115+
self.fields = {f.name.lower(): f for f in table if f.readable}
116+
# use default token aliases if none provided
117+
if token_aliases is None:
118+
token_aliases = QueryBuilder.default_token_aliases
119+
# build a complete list of tokens insluding aliases
120+
self.tokens_not = self._augment(token_aliases, QueryBuilder.tokens_not)
121+
self.tokens_and_or = self._augment(token_aliases, QueryBuilder.tokens_and_or)
122+
self.tokens_ops = self._augment(token_aliases, QueryBuilder.tokens_ops)
123+
# build the regexes that depend on tokens
124+
self.re_not = re.compile(r"^(" + "|".join(self.tokens_not) + r")(\W.*)$")
125+
print(set(sorted(self.tokens_ops, reverse=True)))
116126
self.re_op = re.compile(
117127
"^("
118128
+ "|".join(
119-
t.replace(" ", r"\s+") for t in sorted(self.tokens_all, reverse=True)
129+
t.replace(" ", r"\s+") for t in sorted(self.tokens_ops, reverse=True)
120130
)
121131
+ r")\s*(.*)$"
122132
)
133+
# true or false
123134
self.debug = debug
124135

136+
@staticmethod
137+
def _augment(aliases, original):
138+
"""returns a dict of k:v for k,v in aliases and v in original"""
139+
output = {k: k for k in original}
140+
if aliases:
141+
output.update({k: v for k, v in aliases.items() if v in original})
142+
return output
143+
125144
@staticmethod
126145
def _find_closing_bracket(text):
127146
"""Finds the end of a bracketed expression"""
@@ -161,7 +180,9 @@ def parse(self, text):
161180
def next(text, regex, ignore=False):
162181
text = text.strip()
163182
if not text:
164-
return None, ""
183+
if ignore:
184+
return None, text
185+
raise QueryParseError("Unable to parse truncated expression")
165186
match = regex.match(text)
166187
if not match:
167188
if ignore:
@@ -193,32 +214,38 @@ def next(text, regex, ignore=False):
193214
else:
194215
token, text = next(text, self.re_token)
195216
# match a field name
196-
if token.lower() not in fields:
217+
if token.lower() not in self.fields:
197218
raise QueryParseError(
198219
f"Unable to parse {token}, expected a field name"
199220
)
200-
field = self.table[token]
221+
field = self.fields[token]
201222
is_text = field.type in ("string", "text", "blob")
202223
has_contains = is_text or field.type.startswith("list:")
203-
# check an operator
224+
# match an operator
204225
token, text = next(text, self.re_op)
205-
token = self.tokens_all[token]
226+
print(self.re_op, token)
227+
token = self.tokens_ops[token]
206228
# if the operator is a field modifier, get the next operator
207229
if is_text and token == "lower":
208230
token, text = next(text, self.re_op)
231+
token = self.tokens_ops.get(token)
209232
field = field.lower()
210233
elif is_text and token == "upper":
211234
token, text = next(text, self.re_op)
235+
token = self.tokens_ops.get(token)
212236
field = field.upper()
213237
if token == "is null":
214238
query = field == None
215239
elif token == "is not null":
216240
query = field != None
241+
elif field.type == "boolean" and token == "is true":
242+
query = field == True
243+
elif field.type == "boolean" and token == "is false":
244+
query = field == False
217245
else:
218246
# the operator requires a value, match a value
219247
value, text = next(text, self.re_value)
220248
validate(field, value)
221-
token = self.tokens_all[token]
222249
if token == "==":
223250
query = field == value
224251
elif token == "!=":
@@ -259,11 +286,11 @@ def next(text, regex, ignore=False):
259286
stack.append(query)
260287

261288
# we have a query match next "and" or "or" and put them in stack
262-
token, text = next(text, self.re_token)
289+
token, text = next(text, self.re_token, ignore=True)
263290
if not token:
264291
break
265-
elif token in self.tokens_bool and text:
266-
stack.append(self.tokens_bool[token])
292+
elif token in self.tokens_and_or and text:
293+
stack.append(self.tokens_and_or[token])
267294
else:
268295
raise QueryParseError(f"Unable to parse {token}, expected and/or")
269296
if len(stack) > 1:

tests/querybuilder.py

Lines changed: 68 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -6,71 +6,89 @@
66
class TestQueryBuilder(unittest.TestCase):
77
def test_query_builder(self):
88
db = DAL("sqlite:memory")
9-
db.define_table("thing", Field("name"))
10-
builder = QueryBuilder(db.think)
9+
db.define_table("thing", Field("name"), Field("solid", "boolean"))
10+
builder = QueryBuilder(db.thing)
1111
query = builder.parse("name is null")
1212
self.assertEqual(str(query), '("thing"."name" IS NULL)')
1313
query = builder.parse("name is not null")
1414
self.assertEqual(str(query), '("thing"."name" IS NOT NULL)')
15-
query = builder.parse("name is Max")
16-
self.assertEqual(str(query), '("thing"."name" = \'Max\')')
17-
query = builder.parse("name is equal to Max")
18-
self.assertEqual(str(query), '("thing"."name" = \'Max\')')
19-
query = builder.parse("name == Max")
20-
self.assertEqual(str(query), '("thing"."name" = \'Max\')')
21-
query = builder.parse('name == "Max"')
22-
self.assertEqual(str(query), '("thing"."name" = \'Max\')')
23-
query = builder.parse('name == "Ma\\"x"')
24-
self.assertEqual(str(query), '("thing"."name" = \'Ma\\"x\')')
25-
query = builder.parse('name != "Max"')
26-
self.assertEqual(str(query), '("thing"."name" <> \'Max\')')
27-
query = builder.parse('name < "Max"')
28-
self.assertEqual(str(query), '("thing"."name" < \'Max\')')
29-
query = builder.parse('name > "Max"')
30-
self.assertEqual(str(query), '("thing"."name" > \'Max\')')
31-
query = builder.parse('name <= "Max"')
32-
self.assertEqual(str(query), '("thing"."name" <= \'Max\')')
33-
query = builder.parse('name >= "Max"')
34-
self.assertEqual(str(query), '("thing"."name" >= \'Max\')')
35-
query = builder.parse("name in Max, John")
36-
self.assertEqual(str(query), "(\"thing\".\"name\" IN ('John','Max'))")
37-
query = builder.parse("name belongs Max, John")
38-
self.assertEqual(str(query), "(\"thing\".\"name\" IN ('John','Max'))")
39-
query = builder.parse('name belongs "Max", "John"')
40-
self.assertEqual(str(query), '("thing"."name" IN (\'Max", "John\'))')
41-
query = builder.parse('name contains "Max"')
15+
query = builder.parse("solid is true")
16+
self.assertEqual(str(query), '("thing"."solid" = \'T\')')
17+
query = builder.parse("solid is false")
18+
self.assertEqual(str(query), '("thing"."solid" = \'F\')')
19+
query = builder.parse("name is Chair")
20+
self.assertEqual(str(query), '("thing"."name" = \'Chair\')')
21+
query = builder.parse("name is equal to Chair")
22+
self.assertEqual(str(query), '("thing"."name" = \'Chair\')')
23+
query = builder.parse("name == Chair")
24+
self.assertEqual(str(query), '("thing"."name" = \'Chair\')')
25+
query = builder.parse('name == "Chair"')
26+
self.assertEqual(str(query), '("thing"."name" = \'Chair\')')
27+
query = builder.parse('name == "Cha\\"ir"')
28+
self.assertEqual(str(query), '("thing"."name" = \'Cha\\"ir\')')
29+
query = builder.parse('name != "Chair"')
30+
self.assertEqual(str(query), '("thing"."name" <> \'Chair\')')
31+
query = builder.parse('name < "Chair"')
32+
self.assertEqual(str(query), '("thing"."name" < \'Chair\')')
33+
query = builder.parse('name > "Chair"')
34+
self.assertEqual(str(query), '("thing"."name" > \'Chair\')')
35+
query = builder.parse('name <= "Chair"')
36+
self.assertEqual(str(query), '("thing"."name" <= \'Chair\')')
37+
query = builder.parse('name >= "Chair"')
38+
self.assertEqual(str(query), '("thing"."name" >= \'Chair\')')
39+
query = builder.parse("name in Chair, John")
40+
self.assertEqual(str(query), "(\"thing\".\"name\" IN ('John','Chair'))")
41+
query = builder.parse("name belongs Chair, John")
42+
self.assertEqual(str(query), "(\"thing\".\"name\" IN ('John','Chair'))")
43+
query = builder.parse('name belongs "Chair", "John"')
44+
self.assertEqual(str(query), '("thing"."name" IN (\'Chair", "John\'))')
45+
query = builder.parse('name contains "Chair"')
4246
self.assertEqual(
43-
str(query), "(LOWER(\"thing\".\"name\") LIKE '%max%' ESCAPE '\\')"
47+
str(query), "(LOWER(\"thing\".\"name\") LIKE '%chair%' ESCAPE '\\')"
4448
)
45-
query = builder.parse('name startswith "Max"')
46-
self.assertEqual(str(query), "(\"thing\".\"name\" LIKE 'Max%' ESCAPE '\\')")
47-
query = builder.parse('name starts with "Max"')
48-
self.assertEqual(str(query), "(\"thing\".\"name\" LIKE 'Max%' ESCAPE '\\')")
49-
query = builder.parse("name lower == max")
50-
self.assertEqual(str(query), '(LOWER("thing"."name") = \'max\')')
51-
query = builder.parse("name lower is equal to max")
52-
self.assertEqual(str(query), '(LOWER("thing"."name") = \'max\')')
53-
query = builder.parse("name upper == MAX")
54-
self.assertEqual(str(query), '(UPPER("thing"."name") = \'MAX\')')
55-
query = builder.parse("not name == Max")
56-
self.assertEqual(str(query), '(NOT ("thing"."name" = \'Max\'))')
49+
query = builder.parse('name startswith "Chair"')
50+
self.assertEqual(str(query), "(\"thing\".\"name\" LIKE 'Chair%' ESCAPE '\\')")
51+
query = builder.parse('name starts with "Chair"')
52+
self.assertEqual(str(query), "(\"thing\".\"name\" LIKE 'Chair%' ESCAPE '\\')")
53+
query = builder.parse("name lower == chair")
54+
self.assertEqual(str(query), '(LOWER("thing"."name") = \'chair\')')
55+
query = builder.parse("name lower is equal to chair")
56+
self.assertEqual(str(query), '(LOWER("thing"."name") = \'chair\')')
57+
query = builder.parse("name upper == CHAIR")
58+
self.assertEqual(str(query), '(UPPER("thing"."name") = \'CHAIR\')')
59+
query = builder.parse("not name == Chair")
60+
self.assertEqual(str(query), '(NOT ("thing"."name" = \'Chair\'))')
5761

58-
query = builder.parse("not (name == Max)")
59-
self.assertEqual(str(query), '(NOT ("thing"."name" = \'Max\'))')
62+
query = builder.parse("not (name == Chair)")
63+
self.assertEqual(str(query), '(NOT ("thing"."name" = \'Chair\'))')
6064

61-
query = builder.parse("name == Max or name is John")
65+
query = builder.parse("name == Chair or name is John")
6266
self.assertEqual(
63-
str(query), '(("thing"."name" = \'Max\') OR ("thing"."name" = \'John\'))'
67+
str(query), '(("thing"."name" = \'Chair\') OR ("thing"."name" = \'John\'))'
6468
)
6569

66-
query = builder.parse("name == Max and not name is John")
70+
query = builder.parse("name == Chair and not name is John")
6771
self.assertEqual(
6872
str(query),
69-
'(("thing"."name" = \'Max\') AND (NOT ("thing"."name" = \'John\')))',
73+
'(("thing"."name" = \'Chair\') AND (NOT ("thing"."name" = \'John\')))',
7074
)
7175

72-
query = builder.parse("not ((name == Max) and not (name == John))")
76+
query = builder.parse("not ((name == Chair) and not (name == John))")
7377
self.assertEqual(
7478
str(query),
75-
'(NOT (("thing"."name" = \'Max\') AND (NOT ("thing"."name" = \'John\'))))',
79+
'(NOT (("thing"."name" = \'Chair\') AND (NOT ("thing"."name" = \'John\'))))',
7680
)
81+
82+
def test_translations(self):
83+
db = DAL("sqlite:memory")
84+
db.define_table("thing", Field("name"))
85+
field_aliases = {"id": "id", "nome": "name"}
86+
token_aliases = {"non è nullo": "is not null", "è uguale a": "=="}
87+
builder = QueryBuilder(
88+
db.thing, field_aliases=field_aliases, token_aliases=token_aliases
89+
)
90+
91+
query = builder.parse("nome non è nullo")
92+
self.assertEqual(str(query), '("thing"."name" IS NOT NULL)')
93+
query = builder.parse("nome è uguale a Chair")
94+
self.assertEqual(str(query), '("thing"."name" = \'Chair\')')

0 commit comments

Comments
 (0)