fennecdb 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fennecdb/__init__.py +1481 -0
- fennecdb/builder.py +1867 -0
- fennecdb/langchain.py +307 -0
- fennecdb/llama_index.py +333 -0
- fennecdb/relay.py +83 -0
- fennecdb-0.2.0.dist-info/METADATA +301 -0
- fennecdb-0.2.0.dist-info/RECORD +9 -0
- fennecdb-0.2.0.dist-info/WHEEL +5 -0
- fennecdb-0.2.0.dist-info/top_level.txt +1 -0
fennecdb/builder.py
ADDED
|
@@ -0,0 +1,1867 @@
|
|
|
1
|
+
"""The query builder: FennecQL text and its parameters from a chain of calls.
|
|
2
|
+
|
|
3
|
+
rows = (db.collection("docs")
|
|
4
|
+
.select("title")
|
|
5
|
+
.where("year", ">=", 2024)
|
|
6
|
+
.near("embed", vector, ef=64)
|
|
7
|
+
.limit(5)
|
|
8
|
+
.rows())
|
|
9
|
+
|
|
10
|
+
It makes the text web/fennec.js's builder makes of the same chain, to the
|
|
11
|
+
byte, and so do the Go and .NET builders: integrations/builder-golden.json
|
|
12
|
+
holds the chains and what each must make, and every builder's tests run
|
|
13
|
+
it. Every value goes in as a parameter; a name -- a collection, a field, a
|
|
14
|
+
path into a json field -- cannot, so names are checked against FennecQL's
|
|
15
|
+
own rule, and that check is the injection boundary.
|
|
16
|
+
|
|
17
|
+
A query is immutable: each call hands back a new one, so a base query can
|
|
18
|
+
be kept and branched from. `to_fennecql()` is the text and its parameters,
|
|
19
|
+
for logging or another transport; `rows()`, `first()`, `count()` and
|
|
20
|
+
`explain()` send it through the client the query came from, and on an
|
|
21
|
+
`AsyncClient` they are awaited.
|
|
22
|
+
|
|
23
|
+
`call` is a named statement's: `call("transfer", {"from": 1, "to": 2,
|
|
24
|
+
"amount": 300}).to_call()` is `("call transfer $1", [{...}])`, the
|
|
25
|
+
arguments one parameter and the name -- the one thing written into the
|
|
26
|
+
text -- checked.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import array
|
|
32
|
+
import json
|
|
33
|
+
import math
|
|
34
|
+
import re
|
|
35
|
+
import unicodedata
|
|
36
|
+
from datetime import datetime, timezone
|
|
37
|
+
from typing import Any, Iterable, Mapping, NamedTuple, Sequence, TypeVar
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"AsyncQuery",
|
|
41
|
+
"Call",
|
|
42
|
+
"Computed",
|
|
43
|
+
"Cond",
|
|
44
|
+
"FacetCount",
|
|
45
|
+
"Param",
|
|
46
|
+
"Query",
|
|
47
|
+
"Rows",
|
|
48
|
+
"and_",
|
|
49
|
+
"bucket",
|
|
50
|
+
"call",
|
|
51
|
+
"collection",
|
|
52
|
+
"count_distinct",
|
|
53
|
+
"distance",
|
|
54
|
+
"expr",
|
|
55
|
+
"first",
|
|
56
|
+
"inc",
|
|
57
|
+
"last",
|
|
58
|
+
"not_",
|
|
59
|
+
"or_",
|
|
60
|
+
"param",
|
|
61
|
+
"raw",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
# Operator names, the symbols and the words for them.
|
|
65
|
+
_OPS = {
|
|
66
|
+
"=": "=", "eq": "=",
|
|
67
|
+
"!=": "!=", "ne": "!=", "neq": "!=",
|
|
68
|
+
"<": "<", "lt": "<",
|
|
69
|
+
"<=": "<=", "lte": "<=", "le": "<=",
|
|
70
|
+
">": ">", "gt": ">",
|
|
71
|
+
">=": ">=", "gte": ">=", "ge": ">=",
|
|
72
|
+
"~": "~", "like": "~", "contains": "~",
|
|
73
|
+
"has": "has",
|
|
74
|
+
"in": "in",
|
|
75
|
+
} # fmt: skip
|
|
76
|
+
|
|
77
|
+
# The collations the engine knows. The name is spliced into the text, so
|
|
78
|
+
# it is checked against the list rather than the name pattern.
|
|
79
|
+
_COLLATIONS = ("und", "tr")
|
|
80
|
+
|
|
81
|
+
# The engine refuses a deeper chain; failing here never sends a query.
|
|
82
|
+
MAX_LOOKUP_DEPTH = 8
|
|
83
|
+
|
|
84
|
+
# What JavaScript's String.prototype.trim takes off, which the JS builder
|
|
85
|
+
# trims an aggregate with: str.strip() also takes U+001C..U+001F and leaves
|
|
86
|
+
# U+FEFF.
|
|
87
|
+
_JS_SPACE = "\t\n\v\f\r " + "".join(map(chr, (0xA0, 0x1680, *range(0x2000, 0x200B), 0x2028, 0x2029, 0x202F, 0x205F, 0x3000, 0xFEFF)))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _err(message: str) -> Exception:
|
|
91
|
+
# Imported late: the package's __init__ imports this module.
|
|
92
|
+
from . import FennecError
|
|
93
|
+
|
|
94
|
+
return FennecError(message, 0)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _quote(name: Any) -> str:
|
|
98
|
+
"""A name in a message, as JSON.stringify writes it."""
|
|
99
|
+
try:
|
|
100
|
+
return json.dumps(name, ensure_ascii=False)
|
|
101
|
+
except (TypeError, ValueError):
|
|
102
|
+
return repr(name)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# FennecQL's identifier: a Unicode letter or `_`, then letters, digits and
|
|
106
|
+
# `_`, as the lexer reads it -- JavaScript's \p{Alphabetic} and \p{N}. The
|
|
107
|
+
# standard library has no Alphabetic property, which is the letters, the
|
|
108
|
+
# letter numbers and the marks of Other_Alphabetic (a Devanagari vowel
|
|
109
|
+
# sign); after the first character every mark is taken, a name the lexer
|
|
110
|
+
# would refuse being refused by the server rather than let through as text.
|
|
111
|
+
def _starts(ch: str) -> bool:
|
|
112
|
+
cat = unicodedata.category(ch)
|
|
113
|
+
return ch == "_" or cat[0] == "L" or cat == "Nl"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _continues(ch: str) -> bool:
|
|
117
|
+
cat = unicodedata.category(ch)
|
|
118
|
+
return ch == "_" or cat[0] in "LN" or cat in ("Mn", "Mc")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _is_ident(s: str) -> bool:
|
|
122
|
+
return bool(s) and _starts(s[0]) and all(_continues(c) for c in s[1:])
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _ident(name: Any, what: str = "field") -> str:
|
|
126
|
+
if not isinstance(name, str) or not _is_ident(name):
|
|
127
|
+
raise _err(f"invalid {what} name: {_quote(name)}")
|
|
128
|
+
return name
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _path(name: Any) -> str:
|
|
132
|
+
"""A field's name, or a path into a json field: `meta.lang`."""
|
|
133
|
+
if not isinstance(name, str) or not all(_is_ident(p) for p in name.split(".")):
|
|
134
|
+
raise _err(f"invalid field name: {_quote(name)}")
|
|
135
|
+
return name
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _ascii_ident(s: str) -> bool:
|
|
139
|
+
return (
|
|
140
|
+
bool(s)
|
|
141
|
+
and s.isascii()
|
|
142
|
+
and (s[0].isalpha() or s[0] == "_")
|
|
143
|
+
and all(c.isalnum() or c == "_" for c in s)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# A select item spelled as FennecQL spells an aggregate of one -- `count(*)`,
|
|
148
|
+
# `count(distinct user)`, `sum(total)`, `avg(f)`, `min(f)`, `max(f)`,
|
|
149
|
+
# `first(f)`, `last(f)`, a path where a field goes. ASCII-only and its case
|
|
150
|
+
# folded as ASCII alone, as the JS builder's pattern is: a Unicode-aware one
|
|
151
|
+
# folds the Kelvin sign onto `k` and `ſ` onto `s`, where JavaScript's does
|
|
152
|
+
# not. `\s` there is JavaScript's space, which `_JS_SPACE` spells out.
|
|
153
|
+
_NAMES = r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*"
|
|
154
|
+
_SP = "[" + re.escape(_JS_SPACE) + "]"
|
|
155
|
+
_AGGREGATE = re.compile(
|
|
156
|
+
rf"(count)\(\*?\)|count\({_SP}*distinct{_SP}+({_NAMES}){_SP}*\)|(sum|avg|min|max|first|last)\(({_NAMES})\)",
|
|
157
|
+
re.ASCII | re.IGNORECASE,
|
|
158
|
+
)
|
|
159
|
+
# What makes an expression an aggregate's: a call of one.
|
|
160
|
+
_AGGREGATE_CALL = re.compile(
|
|
161
|
+
rf"(?<![A-Za-z0-9_])(count|sum|avg|min|max|first|last){_SP}*\(", re.ASCII | re.IGNORECASE
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _column(name: Any) -> tuple[Any, bool]:
|
|
166
|
+
"""A select item: a field, an aggregate spelled as FennecQL spells it,
|
|
167
|
+
answering under that name, or an expression (`Computed`), answering
|
|
168
|
+
under the name its `as_` gives it."""
|
|
169
|
+
if isinstance(name, Computed) and name.kind == "expr":
|
|
170
|
+
return name, bool(_AGGREGATE_CALL.search(name.sql))
|
|
171
|
+
m = _AGGREGATE.fullmatch(name.strip(_JS_SPACE)) if isinstance(name, str) else None
|
|
172
|
+
if not m:
|
|
173
|
+
return _path(name), False
|
|
174
|
+
if m[1]:
|
|
175
|
+
return "count(*)", True
|
|
176
|
+
if m[2]:
|
|
177
|
+
return f"count(distinct {m[2]})", True
|
|
178
|
+
return f"{m[3].lower()}({m[4]})", True
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _group_keys(flat: list) -> list:
|
|
182
|
+
"""`group`'s keys: a field or a path, a name the list gives, or an
|
|
183
|
+
expression."""
|
|
184
|
+
if not flat:
|
|
185
|
+
raise _err("group takes at least one key")
|
|
186
|
+
return [k if isinstance(k, Computed) and k.kind == "expr" else _path(k) for k in flat]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _key(name: Any) -> str:
|
|
190
|
+
"""An `order` key: a field, a path, or an aggregate by its name."""
|
|
191
|
+
return _column(name)[0] if isinstance(name, str) else _path(name)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _column_text(c: Any, bind: "_Binder") -> str:
|
|
195
|
+
"""A select item or a group key as text, an expression's values bound."""
|
|
196
|
+
if isinstance(c, str):
|
|
197
|
+
return c
|
|
198
|
+
text = _value("select", c, bind, "")
|
|
199
|
+
return f"{text} as {c.name}" if c.name is not None else text
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _direction(d: Any) -> bool:
|
|
203
|
+
low = str(d).lower()
|
|
204
|
+
if low not in ("asc", "desc"):
|
|
205
|
+
raise _err(f"order direction must be 'asc' or 'desc': {d}")
|
|
206
|
+
return low == "asc"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _collation(name: Any) -> str | None:
|
|
210
|
+
if name is None:
|
|
211
|
+
return None
|
|
212
|
+
if name not in _COLLATIONS:
|
|
213
|
+
raise _err(f"unknown collation: {_quote(name)}; there are 'und' and 'tr'")
|
|
214
|
+
return name
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _js_number(n: float) -> str:
|
|
218
|
+
"""A number as JavaScript's `String(n)` writes it, the text every other
|
|
219
|
+
builder makes: a whole float without its `.0`."""
|
|
220
|
+
if isinstance(n, float) and n.is_integer() and abs(n) < 1e21:
|
|
221
|
+
return str(int(n))
|
|
222
|
+
return repr(n)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _whole(n: Any, what: str) -> int:
|
|
226
|
+
"""A count, a whole number JavaScript holds exactly. `limit`, `offset`
|
|
227
|
+
and a facet's `top` go as parameters, so a page of any size or place is
|
|
228
|
+
one text; `ef`, `candidates`, `k` and a snippet's words are written in,
|
|
229
|
+
as FennecQL takes them."""
|
|
230
|
+
if isinstance(n, bool) or not isinstance(n, int) or n < 0 or n > 2**53 - 1:
|
|
231
|
+
raise _err(f"{what} must be a non-negative integer: {_quote(n) if isinstance(n, bool) else n}")
|
|
232
|
+
return n
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _text(v: Any, what: str) -> str:
|
|
236
|
+
if not isinstance(v, str):
|
|
237
|
+
raise _err(f"{what} must be text: {_quote(v)}")
|
|
238
|
+
return v
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _tags(pre: Any, post: Any, what: str) -> dict:
|
|
242
|
+
"""A mark's tags: both or neither, each text."""
|
|
243
|
+
if pre is None and post is None:
|
|
244
|
+
return {}
|
|
245
|
+
if pre is None or post is None:
|
|
246
|
+
raise _err(f"{what} takes both pre and post, or neither")
|
|
247
|
+
return {"pre": _text(pre, f"{what} pre"), "post": _text(post, f"{what} post")}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _js_date(d: datetime) -> str:
|
|
251
|
+
"""A datetime as JavaScript's toISOString writes it, which the JS
|
|
252
|
+
builder sends a Date as: UTC, to the millisecond. A naive one is taken
|
|
253
|
+
for UTC."""
|
|
254
|
+
if d.tzinfo is not None:
|
|
255
|
+
d = d.astimezone(timezone.utc)
|
|
256
|
+
return (
|
|
257
|
+
f"{d.year:04d}-{d.month:02d}-{d.day:02d}T{d.hour:02d}:{d.minute:02d}:"
|
|
258
|
+
f"{d.second:02d}.{d.microsecond // 1000:03d}Z"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _normalize(v: Any, what: str = "value") -> Any:
|
|
263
|
+
"""A value as it goes into the parameters' JSON: a datetime as its ISO
|
|
264
|
+
text, an `array.array` or anything with `tolist()` (a NumPy array) as
|
|
265
|
+
a list, a mapping as a json field's object."""
|
|
266
|
+
if v is None or isinstance(v, (bool, int, float, str)):
|
|
267
|
+
return v
|
|
268
|
+
if isinstance(v, datetime):
|
|
269
|
+
return _js_date(v)
|
|
270
|
+
if isinstance(v, (list, tuple, array.array)):
|
|
271
|
+
return [_normalize(x, what) for x in v]
|
|
272
|
+
if isinstance(v, Mapping):
|
|
273
|
+
return {k: _normalize(x, what) for k, x in v.items()}
|
|
274
|
+
# A param is bound where a whole value stands, never from inside a list
|
|
275
|
+
# or a json value; every builder refuses it in these words
|
|
276
|
+
# (builder-golden.json).
|
|
277
|
+
if isinstance(v, Param):
|
|
278
|
+
raise _err("this object cannot be used as a fennecdb value (a param() inside a value)")
|
|
279
|
+
tolist = getattr(v, "tolist", None)
|
|
280
|
+
if callable(tolist):
|
|
281
|
+
return _normalize(tolist(), what)
|
|
282
|
+
raise _err(f"this object cannot be used as a fennecdb value ({what})")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ---------------------------------------------------- one value in two places
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class Param:
|
|
289
|
+
"""What `param()` makes: a value bound once, whichever places it is
|
|
290
|
+
used in. Each use writes the same `$n`, where the same value given twice
|
|
291
|
+
is two parameters -- `balance - $1 ... balance >= $1`, the amount taken
|
|
292
|
+
and the amount checked one value, never two a caller could let drift
|
|
293
|
+
apart. A param is told by its identity, never its value: two params of
|
|
294
|
+
equal values stay two parameters, as the JavaScript builder's do."""
|
|
295
|
+
|
|
296
|
+
__slots__ = ("value",)
|
|
297
|
+
|
|
298
|
+
def __init__(self, value: Any) -> None:
|
|
299
|
+
if isinstance(value, Param):
|
|
300
|
+
raise _err("param() takes a value, not another param()")
|
|
301
|
+
object.__setattr__(self, "value", value)
|
|
302
|
+
|
|
303
|
+
# Immutable, as a query is: a param changed after a query took it would
|
|
304
|
+
# change a statement already written.
|
|
305
|
+
def __setattr__(self, key: str, value: Any) -> None:
|
|
306
|
+
raise AttributeError("a param is immutable")
|
|
307
|
+
|
|
308
|
+
def __delattr__(self, key: str) -> None:
|
|
309
|
+
raise AttributeError("a param is immutable")
|
|
310
|
+
|
|
311
|
+
def __repr__(self) -> str:
|
|
312
|
+
return f"param({self.value!r})"
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def param(value: Any) -> Param:
|
|
316
|
+
"""`param(value)`: one parameter, used wherever a value goes -- a filter,
|
|
317
|
+
a document, `expr()`, `raw()`, `distance()`, `near`, `match` -- and bound
|
|
318
|
+
once, at its first place in the text:
|
|
319
|
+
|
|
320
|
+
amount = param(300)
|
|
321
|
+
(collection("accounts").where({"name": "ada", "balance": {"gte": amount}})
|
|
322
|
+
.to_update({"balance": expr("balance - ?", amount)}, require=1))
|
|
323
|
+
# ("set accounts {balance: balance - $1} where name = $2 and balance >= $1 require 1",
|
|
324
|
+
# [300, "ada"])
|
|
325
|
+
|
|
326
|
+
`None` is a value here, FennecQL's null."""
|
|
327
|
+
return Param(value)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _value_of(v: Any) -> Any:
|
|
331
|
+
"""A value as the builder checks it: a param's own."""
|
|
332
|
+
return v.value if isinstance(v, Param) else v
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# ---------------------------------------------------------- the condition tree
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class Cond:
|
|
339
|
+
"""A condition `or_`, `and_`, `not_` or `raw` made, or one the builder
|
|
340
|
+
made of a field's. A dict is a condition too -- `{"year": {"gte": 2024}}`
|
|
341
|
+
-- and stays one: only this class is taken for a node."""
|
|
342
|
+
|
|
343
|
+
__slots__ = ("t", "items", "field", "op", "value", "negated", "sql", "params")
|
|
344
|
+
|
|
345
|
+
def __init__(self, t: str, **kw: Any):
|
|
346
|
+
self.t = t
|
|
347
|
+
self.items: list[Cond] = kw.get("items", [])
|
|
348
|
+
self.field: str = kw.get("field", "")
|
|
349
|
+
self.op: str = kw.get("op", "")
|
|
350
|
+
self.value: Any = kw.get("value")
|
|
351
|
+
self.negated: bool = kw.get("negated", False)
|
|
352
|
+
self.sql: str = kw.get("sql", "")
|
|
353
|
+
self.params: tuple = kw.get("params", ())
|
|
354
|
+
|
|
355
|
+
def __repr__(self) -> str:
|
|
356
|
+
return f"Cond({self.t})"
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _flat(conds: Iterable[Any]) -> list:
|
|
360
|
+
out = []
|
|
361
|
+
for c in conds:
|
|
362
|
+
out.extend(c) if isinstance(c, (list, tuple)) else out.append(c)
|
|
363
|
+
return out
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def or_(*conds: Any) -> Cond:
|
|
367
|
+
"""`or_(a, b)` / `or_([a, b])`: joins conditions with `or`."""
|
|
368
|
+
return Cond("or", items=[_to_cond(c) for c in _flat(conds)])
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def and_(*conds: Any) -> Cond:
|
|
372
|
+
"""`and_(a, b)`: `where` already ands; this is only needed inside `or_`."""
|
|
373
|
+
return Cond("and", items=[_to_cond(c) for c in _flat(conds)])
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def not_(cond: Any) -> Cond:
|
|
377
|
+
return Cond("not", items=[_to_cond(cond)])
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def raw(sql: str, *params: Any) -> Cond:
|
|
381
|
+
"""Everything the builder cannot express (a function call): each `?` is
|
|
382
|
+
bound to the next parameter -- a literal `?` goes in as one too.
|
|
383
|
+
|
|
384
|
+
.where(raw("cosine(embed, ?) > ?", vector, 0.5))
|
|
385
|
+
"""
|
|
386
|
+
if not isinstance(sql, str):
|
|
387
|
+
raise _err("raw() expects text")
|
|
388
|
+
return Cond("raw", sql=sql, params=params)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class Computed:
|
|
392
|
+
"""A value worked out over a row: what `inc` and `expr` make for a
|
|
393
|
+
write, and what `expr`, `bucket`, `count_distinct`, `first` and `last`
|
|
394
|
+
make for a select list or a group, rendered as FennecQL with its values
|
|
395
|
+
as parameters."""
|
|
396
|
+
|
|
397
|
+
__slots__ = ("kind", "by", "sql", "params", "name")
|
|
398
|
+
|
|
399
|
+
def __init__(
|
|
400
|
+
self, kind: str, by: Any = None, sql: str = "", params: tuple = (), name: str | None = None
|
|
401
|
+
):
|
|
402
|
+
self.kind, self.by, self.sql, self.params, self.name = kind, by, sql, params, name
|
|
403
|
+
|
|
404
|
+
def as_(self, name: str) -> "Computed":
|
|
405
|
+
"""The name the column answers under: `select ... as <name>`
|
|
406
|
+
(`as` is Python's keyword, hence the underscore)."""
|
|
407
|
+
return Computed(self.kind, self.by, self.sql, self.params, _ident(name, "column"))
|
|
408
|
+
|
|
409
|
+
def __repr__(self) -> str:
|
|
410
|
+
return f"Computed({self.kind})"
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def inc(by: Any = 1) -> Computed:
|
|
414
|
+
"""`{"n": inc(1)}` in an update: the field plus `by`, counting from 0
|
|
415
|
+
where it is null -- `n: coalesce(n, 0) + $1` -- worked out under the
|
|
416
|
+
server's write lock, so increments from many clients all land."""
|
|
417
|
+
if isinstance(by, bool) or not isinstance(by, (int, float)) or not math.isfinite(by):
|
|
418
|
+
text = json.dumps(by, ensure_ascii=False, separators=(",", ":"), default=str)
|
|
419
|
+
raise _err(f"inc() takes a number: {text}")
|
|
420
|
+
return Computed("inc", by=by)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def expr(sql: str, *params: Any) -> Computed:
|
|
424
|
+
"""A value as a FennecQL expression over the row it is written into,
|
|
425
|
+
each `?` bound to the next parameter: `{"at": expr("now()")}`,
|
|
426
|
+
`{"total": expr("price * ?", 1.2)}`.
|
|
427
|
+
|
|
428
|
+
The text is trusted FennecQL, written into the statement as it is: the
|
|
429
|
+
code's own, never a value from a user, which goes in as a `?`'s
|
|
430
|
+
parameter. Names and this text are where an injection would get
|
|
431
|
+
through."""
|
|
432
|
+
if not isinstance(sql, str):
|
|
433
|
+
raise _err("expr() expects text")
|
|
434
|
+
return Computed("expr", sql=sql, params=params)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# `15m`, `1h`, `1d`, `1w`, `3mo`, `1y`: what `bucket` takes, written into
|
|
438
|
+
# the text as a literal, since it is a part of the statement's shape.
|
|
439
|
+
_INTERVAL = re.compile(r"[1-9][0-9]*(ms|s|m|h|d|w|mo|y)", re.ASCII)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def bucket(field: str, interval: str, zone: str | None = None) -> Computed:
|
|
443
|
+
"""`bucket(field, interval [, zone])`: the start of the interval a
|
|
444
|
+
timestamp falls in -- `"15m"`, `"1h"`, `"1d"`, `"1w"` (from a Monday),
|
|
445
|
+
`"1mo"`, `"1y"`, in UTC -- for a select list or a group:
|
|
446
|
+
|
|
447
|
+
.select(bucket("at", "1m").as_("minute"), "count(*)").group("minute")
|
|
448
|
+
|
|
449
|
+
Given a zone -- an IANA name, `"Europe/Istanbul"`, or an offset,
|
|
450
|
+
`"+03:00"` -- by its clock: a day from local midnight, 23 or 25 hours
|
|
451
|
+
where it changes to or from daylight time. It goes as a parameter.
|
|
452
|
+
"""
|
|
453
|
+
if not isinstance(interval, str) or not _INTERVAL.fullmatch(interval):
|
|
454
|
+
raise _err(
|
|
455
|
+
"bucket() takes an interval such as '15m', '1h', '1d', '1w' or '1mo': "
|
|
456
|
+
+ json.dumps(interval, ensure_ascii=False, default=str)
|
|
457
|
+
)
|
|
458
|
+
if zone is None:
|
|
459
|
+
return Computed("expr", sql=f"bucket({_path(field)}, {interval})")
|
|
460
|
+
if not isinstance(zone, str) or zone == "":
|
|
461
|
+
raise _err(
|
|
462
|
+
"bucket() takes a zone's name, such as 'Europe/Istanbul', or an offset, such as "
|
|
463
|
+
"'+03:00': " + json.dumps(zone, ensure_ascii=False, default=str)
|
|
464
|
+
)
|
|
465
|
+
return Computed("expr", sql=f"bucket({_path(field)}, {interval}, ?)", params=(zone,))
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def count_distinct(field: str) -> Computed:
|
|
469
|
+
"""`count(distinct field)`: how many distinct values the rows hold."""
|
|
470
|
+
return Computed("expr", sql=f"count(distinct {_path(field)})")
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def distance(field: str, point: Sequence[float]) -> Computed:
|
|
474
|
+
"""`distance(field, point)`: the metres from a point, `[lon, lat]`, to
|
|
475
|
+
the row's, for a select list -- `distance("loc", [13.4, 52.5]).as_("m")`,
|
|
476
|
+
what Redis's `GEOSEARCH ... WITHDIST` answers with."""
|
|
477
|
+
return Computed("expr", sql=f"distance({_path(field)}, ?)", params=(point,))
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def first(field: str, by: str | None = None) -> Computed:
|
|
481
|
+
"""`first(field)`, or `first(field by key)`: the value of the row least
|
|
482
|
+
by `key` -- by the order the rows were written without one -- that has
|
|
483
|
+
a value; a bar's open is `first("px", "at")`."""
|
|
484
|
+
return _pick("first", field, by)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def last(field: str, by: str | None = None) -> Computed:
|
|
488
|
+
"""`last(field [by key])`: as `first`, the row greatest by `key`."""
|
|
489
|
+
return _pick("last", field, by)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _pick(fn: str, field: str, by: str | None) -> Computed:
|
|
493
|
+
return Computed("expr", sql=f"{fn}({_path(field)}{'' if by is None else f' by {_path(by)}'})")
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _value(k: str, v: Any, bind: "_Binder", write: str) -> str:
|
|
497
|
+
"""A document's value: `inc`'s and `expr`'s text, or a parameter."""
|
|
498
|
+
if not isinstance(v, Computed):
|
|
499
|
+
return bind(v, k)
|
|
500
|
+
if v.kind == "inc":
|
|
501
|
+
if write == "insert":
|
|
502
|
+
raise _err(f"inc() reads the row it changes: use it in update (field: {k})")
|
|
503
|
+
return f"coalesce({k}, 0) + {bind(v.by, k)}"
|
|
504
|
+
out, i = [], 0
|
|
505
|
+
parts = v.sql.split("?")
|
|
506
|
+
for part in parts[:-1]:
|
|
507
|
+
if i >= len(v.params):
|
|
508
|
+
raise _err("expr(): more `?` placeholders than parameters")
|
|
509
|
+
out.append(part + bind(v.params[i], k))
|
|
510
|
+
i += 1
|
|
511
|
+
if i != len(v.params):
|
|
512
|
+
raise _err("expr(): too many parameters given")
|
|
513
|
+
out.append(parts[-1])
|
|
514
|
+
return "".join(out)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _to_cond(x: Any) -> Cond:
|
|
518
|
+
if isinstance(x, Cond):
|
|
519
|
+
return x
|
|
520
|
+
if isinstance(x, Mapping):
|
|
521
|
+
return _object_cond(x)
|
|
522
|
+
raise _err(f"expected an object as a condition: {_quote(x)}")
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _object_cond(obj: Mapping) -> Cond:
|
|
526
|
+
items = [_field_cond(_path(f), spec) for f, spec in obj.items()]
|
|
527
|
+
return items[0] if len(items) == 1 else Cond("and", items=items)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _field_cond(field: str, spec: Any) -> Cond:
|
|
531
|
+
"""A raw value is equality, a mapping an operator map."""
|
|
532
|
+
if spec is None:
|
|
533
|
+
return Cond("null", field=field)
|
|
534
|
+
if not isinstance(spec, Mapping):
|
|
535
|
+
return _cmp(field, "=", spec)
|
|
536
|
+
items = []
|
|
537
|
+
for k, v in spec.items():
|
|
538
|
+
if k == "not":
|
|
539
|
+
items.append(
|
|
540
|
+
Cond("null", field=field, negated=True)
|
|
541
|
+
if v is None
|
|
542
|
+
else Cond("not", items=[_field_cond(field, v)])
|
|
543
|
+
)
|
|
544
|
+
continue
|
|
545
|
+
# A point's: `{"loc": {"within": [w, s, e, n]}}` and `{"loc":
|
|
546
|
+
# {"distance": {"from": [lon, lat], "lte": 500}}}`.
|
|
547
|
+
if k == "within":
|
|
548
|
+
items.append(Cond("within", field=field, value=v))
|
|
549
|
+
continue
|
|
550
|
+
if k == "distance":
|
|
551
|
+
items.append(_distance_cond(field, v))
|
|
552
|
+
continue
|
|
553
|
+
op = _OPS.get(k) if isinstance(k, str) else None
|
|
554
|
+
if not op:
|
|
555
|
+
raise _err(f"unknown operator `{k}` (field: {field})")
|
|
556
|
+
items.append(_in_cond(field, v) if op == "in" else _cmp(field, op, v))
|
|
557
|
+
if not items:
|
|
558
|
+
raise _err(f"empty condition object (field: {field})")
|
|
559
|
+
return items[0] if len(items) == 1 else Cond("and", items=items)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _distance_cond(field: str, spec: Any) -> Cond:
|
|
563
|
+
"""`{"distance": {"from": [lon, lat], "lte": 500}}`: the metres from a
|
|
564
|
+
point, compared -- `distance(loc, $1) <= $2`, each comparison given its
|
|
565
|
+
own."""
|
|
566
|
+
# A param's value is checked, the param itself bound: its `$n` is the one
|
|
567
|
+
# its other places write.
|
|
568
|
+
if not isinstance(spec, Mapping) or not isinstance(_value_of(spec.get("from")), (list, tuple)):
|
|
569
|
+
raise _err(f"distance takes {{ from: [lon, lat], <op>: metres }} (field: {field})")
|
|
570
|
+
items = []
|
|
571
|
+
for k, v in spec.items():
|
|
572
|
+
if k == "from":
|
|
573
|
+
continue
|
|
574
|
+
op = _OPS.get(k) if isinstance(k, str) else None
|
|
575
|
+
n = _value_of(v)
|
|
576
|
+
number = isinstance(n, (int, float)) and not isinstance(n, bool)
|
|
577
|
+
if not op or op in ("in", "has", "~") or not number:
|
|
578
|
+
raise _err(
|
|
579
|
+
f"distance compares metres with lt, lte, gt, gte, eq or ne: {k} (field: {field})"
|
|
580
|
+
)
|
|
581
|
+
items.append(Cond("dist", field=field, op=op, value=(spec["from"], v)))
|
|
582
|
+
if not items:
|
|
583
|
+
raise _err(f"distance needs a comparison, as lte: metres (field: {field})")
|
|
584
|
+
return items[0] if len(items) == 1 else Cond("and", items=items)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _in_cond(field: str, values: Any) -> Cond:
|
|
588
|
+
# `{"customer": {"in": db.collection("customers").select("id").where(...)}}`:
|
|
589
|
+
# the inner query runs once, before the outer one, and its one column is
|
|
590
|
+
# the list -- `customer in (get customers select id where ...)`.
|
|
591
|
+
if isinstance(values, _Builder):
|
|
592
|
+
return Cond("sub", field=field, value=values)
|
|
593
|
+
if not isinstance(values, (list, tuple)):
|
|
594
|
+
raise _err(f"`in` expects an array (field: {field})")
|
|
595
|
+
if not values:
|
|
596
|
+
raise _err(f"`in` does not accept an empty array (field: {field})")
|
|
597
|
+
return Cond("in", field=field, items=list(values))
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _cmp(field: str, op: str, value: Any) -> Cond:
|
|
601
|
+
# `= null` is never true in FennecQL; what is meant is `is null`.
|
|
602
|
+
if value is None:
|
|
603
|
+
if op == "=":
|
|
604
|
+
return Cond("null", field=field)
|
|
605
|
+
if op == "!=":
|
|
606
|
+
return Cond("null", field=field, negated=True)
|
|
607
|
+
raise _err(f"`{op}` cannot be used with null (field: {field})")
|
|
608
|
+
return Cond("cmp", field=field, op=op, value=value)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _cond_of(args: tuple) -> Cond:
|
|
612
|
+
if len(args) == 1:
|
|
613
|
+
return _to_cond(args[0])
|
|
614
|
+
if len(args) == 2:
|
|
615
|
+
return _field_cond(_path(args[0]), args[1])
|
|
616
|
+
if len(args) == 3:
|
|
617
|
+
op = _OPS.get(args[1]) if isinstance(args[1], str) else None
|
|
618
|
+
if not op:
|
|
619
|
+
raise _err(f"unknown operator `{args[1]}`")
|
|
620
|
+
field = _path(args[0])
|
|
621
|
+
return _in_cond(field, args[2]) if op == "in" else _cmp(field, op, args[2])
|
|
622
|
+
raise _err("where(field, op, value) | where(field, value) | where(object)")
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _prune(c: Cond) -> Cond | None:
|
|
626
|
+
"""Flattens empty and single-child junctions before rendering: the
|
|
627
|
+
parentheses depend on the child count, and rendering binds parameters."""
|
|
628
|
+
if c.t in ("and", "or"):
|
|
629
|
+
items = [x for x in (_prune(i) for i in c.items) if x is not None]
|
|
630
|
+
if not items:
|
|
631
|
+
return None
|
|
632
|
+
return items[0] if len(items) == 1 else Cond(c.t, items=items)
|
|
633
|
+
if c.t == "not":
|
|
634
|
+
item = _prune(c.items[0])
|
|
635
|
+
return Cond("not", items=[item]) if item is not None else None
|
|
636
|
+
return c
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
class _Binder:
|
|
640
|
+
def __init__(self) -> None:
|
|
641
|
+
self.params: list = []
|
|
642
|
+
# A `param()` takes its `$n` at its first use, and every use after
|
|
643
|
+
# writes that one: kept by the param's identity (`Param` defines no
|
|
644
|
+
# `__eq__`), so two params of one value stay two parameters.
|
|
645
|
+
self._once: dict[Param, str] = {}
|
|
646
|
+
|
|
647
|
+
def __call__(self, v: Any, what: str) -> str:
|
|
648
|
+
if isinstance(v, Param):
|
|
649
|
+
at = self._once.get(v)
|
|
650
|
+
if at is None:
|
|
651
|
+
self.params.append(_normalize(v.value, what))
|
|
652
|
+
at = self._once[v] = f"${len(self.params)}"
|
|
653
|
+
return at
|
|
654
|
+
self.params.append(_normalize(v, what))
|
|
655
|
+
return f"${len(self.params)}"
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _render(c: Cond, bind: _Binder, parent: str | None = None) -> str:
|
|
659
|
+
if c.t in ("and", "or"):
|
|
660
|
+
s = f" {c.t} ".join(_render(x, bind, c.t) for x in c.items)
|
|
661
|
+
# `and` binds tighter than `or`: one inside the other needs parens.
|
|
662
|
+
return f"({s})" if parent and parent != c.t else s
|
|
663
|
+
if c.t == "not":
|
|
664
|
+
return f"not ({_render(c.items[0], bind)})"
|
|
665
|
+
if c.t == "null":
|
|
666
|
+
return f"{c.field} is {'not ' if c.negated else ''}null"
|
|
667
|
+
if c.t == "in":
|
|
668
|
+
return f"{c.field} in [{', '.join(bind(v, c.field) for v in c.items)}]"
|
|
669
|
+
if c.t == "sub":
|
|
670
|
+
# Its parameters numbered where its text stands, after the outer
|
|
671
|
+
# query's before it.
|
|
672
|
+
return f"{c.field} in ({c.value._inner(bind)})"
|
|
673
|
+
if c.t == "cmp":
|
|
674
|
+
return f"{c.field} {c.op} {bind(c.value, c.field)}"
|
|
675
|
+
if c.t == "within":
|
|
676
|
+
return f"within({c.field}, {bind(c.value, c.field)})"
|
|
677
|
+
if c.t == "dist":
|
|
678
|
+
point, metres = c.value
|
|
679
|
+
return f"distance({c.field}, {bind(point, c.field)}) {c.op} {bind(metres, c.field)}"
|
|
680
|
+
# raw
|
|
681
|
+
out, i = [], 0
|
|
682
|
+
for part in c.sql.split("?")[:-1]:
|
|
683
|
+
if i >= len(c.params):
|
|
684
|
+
raise _err("raw(): more `?` placeholders than parameters")
|
|
685
|
+
out.append(part + bind(c.params[i], "raw"))
|
|
686
|
+
i += 1
|
|
687
|
+
if i != len(c.params):
|
|
688
|
+
raise _err("raw(): too many parameters given")
|
|
689
|
+
out.append(c.sql.split("?")[-1])
|
|
690
|
+
return "".join(out)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
# ---------------------------------------------------------------- the query
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _order_keys(spec: Any) -> list[tuple[str, bool, str | None]]:
|
|
697
|
+
"""A lookup's `order`: `"created"`, or `[("created", "desc"), ...]`, a
|
|
698
|
+
pair taking `{"collate": ...}` third; a bare name in the list is one
|
|
699
|
+
ascending key."""
|
|
700
|
+
if spec is None:
|
|
701
|
+
return []
|
|
702
|
+
if isinstance(spec, str):
|
|
703
|
+
return [(_path(spec), True, None)]
|
|
704
|
+
keys = []
|
|
705
|
+
for k in spec:
|
|
706
|
+
parts = [k] if isinstance(k, str) else list(k)
|
|
707
|
+
field = parts[0] if parts else None
|
|
708
|
+
d = parts[1] if len(parts) > 1 else "asc"
|
|
709
|
+
opts = parts[2] if len(parts) > 2 else {}
|
|
710
|
+
# Over groups a key may name an aggregate of the list.
|
|
711
|
+
keys.append((_key(field), _direction(d), _collation((opts or {}).get("collate"))))
|
|
712
|
+
return keys
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _sort_key(k: tuple[Any, bool, str | None], bind: _Binder) -> str:
|
|
716
|
+
"""`name collate tr desc`: one key of an `order`, an expression's values
|
|
717
|
+
bound where its text stands."""
|
|
718
|
+
key, asc, collate = k
|
|
719
|
+
text = _value("order", key, bind, "") if isinstance(key, Computed) else key
|
|
720
|
+
return f"{text}{f' collate {collate}' if collate else ''} {'asc' if asc else 'desc'}"
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
Q = TypeVar("Q", bound="_Builder")
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
class _Builder:
|
|
727
|
+
"""The chain, shared by `Query` and `AsyncQuery`: everything but sending."""
|
|
728
|
+
|
|
729
|
+
__slots__ = ("_s",)
|
|
730
|
+
|
|
731
|
+
def __init__(self, collection: str, client: Any = None, _state: dict | None = None):
|
|
732
|
+
if _state is not None:
|
|
733
|
+
self._s = _state
|
|
734
|
+
return
|
|
735
|
+
self._s = {
|
|
736
|
+
"collection": _ident(collection, "collection"),
|
|
737
|
+
"client": client,
|
|
738
|
+
"project": None,
|
|
739
|
+
"aggregate": False,
|
|
740
|
+
"group": None,
|
|
741
|
+
"cond": [],
|
|
742
|
+
"near": None,
|
|
743
|
+
"match": None,
|
|
744
|
+
# `fuse` writes its two searches in the order they were first
|
|
745
|
+
# called: `match` first where it came while no `near` was there.
|
|
746
|
+
"match_first": False,
|
|
747
|
+
"rerank": None,
|
|
748
|
+
"fuse": None,
|
|
749
|
+
"order": [],
|
|
750
|
+
"limit": None,
|
|
751
|
+
"offset": 0,
|
|
752
|
+
"count": False,
|
|
753
|
+
"lookups": [],
|
|
754
|
+
"marks": [],
|
|
755
|
+
"facets": [],
|
|
756
|
+
"require": "",
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
def _with(self: Q, **patch: Any) -> Q:
|
|
760
|
+
return type(self)("", _state={**self._s, **patch})
|
|
761
|
+
|
|
762
|
+
@property
|
|
763
|
+
def collection(self) -> str:
|
|
764
|
+
return self._s["collection"]
|
|
765
|
+
|
|
766
|
+
def select(self: Q, *cols: Any) -> Q:
|
|
767
|
+
"""`select a, b`; none, or `"*"`, is every field. Aggregates go in the
|
|
768
|
+
same list as FennecQL spells them and answer under that name:
|
|
769
|
+
`select("status", "count(*)", "sum(total)").group("status")`; an
|
|
770
|
+
expression -- `expr()`, `bucket()`, `count_distinct()`, `first()`,
|
|
771
|
+
`last()` -- under the name its `as_` gives it:
|
|
772
|
+
`select("sym", expr("sum(px * qty) / sum(qty)").as_("vwap")).group("sym")`."""
|
|
773
|
+
flat = _flat(cols)
|
|
774
|
+
if not flat or any(isinstance(c, str) and c == "*" for c in flat):
|
|
775
|
+
return self._with(project=None, aggregate=False)
|
|
776
|
+
cs = [_column(c) for c in flat]
|
|
777
|
+
return self._with(project=[t for t, _ in cs], aggregate=any(a for _, a in cs))
|
|
778
|
+
|
|
779
|
+
def highlight(self: Q, field: str, *, pre: str | None = None, post: str | None = None) -> Q:
|
|
780
|
+
"""`highlight(field)` in the select list: where the terms `match`
|
|
781
|
+
found stand in the field's text -- `[start, end]` pairs of UTF-16
|
|
782
|
+
offsets, a JavaScript string's own, as the server counts them -- or,
|
|
783
|
+
given `pre` and `post`, the text with each mark between them. The
|
|
784
|
+
text is not escaped: a page that renders it as HTML escapes it
|
|
785
|
+
first, or builds it from the offsets. Answers under
|
|
786
|
+
`highlight(field)`, after the fields `select` named."""
|
|
787
|
+
return self._mark({"field": _ident(field), "words": None, **_tags(pre, post, "highlight")})
|
|
788
|
+
|
|
789
|
+
def snippet(
|
|
790
|
+
self: Q,
|
|
791
|
+
field: str,
|
|
792
|
+
words: int,
|
|
793
|
+
*,
|
|
794
|
+
ellipsis: str | None = None,
|
|
795
|
+
pre: str | None = None,
|
|
796
|
+
post: str | None = None,
|
|
797
|
+
) -> Q:
|
|
798
|
+
"""`snippet(field, words)`: the window of `words` words around the
|
|
799
|
+
densest marks, `{"text", "marks"}` -- or the marked text, given
|
|
800
|
+
`pre` and `post` -- with `ellipsis` where it leaves text out.
|
|
801
|
+
Answers under `snippet(field)`."""
|
|
802
|
+
mark = {"field": _ident(field), "words": _whole(words, "snippet words"), **_tags(pre, post, "snippet")}
|
|
803
|
+
if mark["words"] == 0:
|
|
804
|
+
raise _err("snippet shows at least one word")
|
|
805
|
+
if ellipsis is not None:
|
|
806
|
+
mark["ellipsis"] = _text(ellipsis, "snippet ellipsis")
|
|
807
|
+
return self._mark(mark)
|
|
808
|
+
|
|
809
|
+
def _mark(self: Q, mark: dict) -> Q:
|
|
810
|
+
# Each answers under its label, and a row holds a name once.
|
|
811
|
+
def kind(m: dict) -> str:
|
|
812
|
+
return "highlight" if m["words"] is None else "snippet"
|
|
813
|
+
|
|
814
|
+
if any(kind(m) == kind(mark) and m["field"] == mark["field"] for m in self._s["marks"]):
|
|
815
|
+
raise _err(f"{kind(mark)}({mark['field']}) is asked twice")
|
|
816
|
+
return self._with(marks=[*self._s["marks"], mark])
|
|
817
|
+
|
|
818
|
+
def facet(
|
|
819
|
+
self: Q,
|
|
820
|
+
field: str,
|
|
821
|
+
*,
|
|
822
|
+
top: int | None = None,
|
|
823
|
+
ranges: Sequence[float] | None = None,
|
|
824
|
+
disjunctive: bool = False,
|
|
825
|
+
) -> Q:
|
|
826
|
+
"""`facet field [top N]`: each value the field -- or a path into a
|
|
827
|
+
json field -- holds over every row the query matches, not only the
|
|
828
|
+
page, and how many rows hold it, most first; `top` keeps the
|
|
829
|
+
commonest. A list counts once a row for each value. The counts come
|
|
830
|
+
back beside the rows, as `rows().facets`.
|
|
831
|
+
|
|
832
|
+
(db.collection("products").match("title", "phone").where("price", "<", 500)
|
|
833
|
+
.facet("brand", top=10).facet("color").limit(20).rows().facets)
|
|
834
|
+
|
|
835
|
+
`ranges=[0, 25, 50]` counts the rows in each range of numbers from
|
|
836
|
+
one bound up to the next, every range in order, its value
|
|
837
|
+
`[from, to]`; `disjunctive=True` counts as if the filter's own
|
|
838
|
+
conditions on the field were not there, so the other values a
|
|
839
|
+
shopper could add are counted too.
|
|
840
|
+
"""
|
|
841
|
+
name = _path(field)
|
|
842
|
+
top = None if top is None else _whole(top, "facet top")
|
|
843
|
+
if top == 0:
|
|
844
|
+
raise _err(f"facet {name} top 0 answers nothing")
|
|
845
|
+
if any(g[0] == name for g in self._s["facets"]):
|
|
846
|
+
raise _err(f"facet {name} is asked twice")
|
|
847
|
+
bounds = None
|
|
848
|
+
if ranges is not None:
|
|
849
|
+
# The engine's rule, refused before anything is sent.
|
|
850
|
+
ok = (
|
|
851
|
+
top is None
|
|
852
|
+
and isinstance(ranges, (list, tuple))
|
|
853
|
+
and len(ranges) >= 2
|
|
854
|
+
and all(
|
|
855
|
+
isinstance(b, (int, float))
|
|
856
|
+
and not isinstance(b, bool)
|
|
857
|
+
and math.isfinite(b)
|
|
858
|
+
and (i == 0 or b > ranges[i - 1])
|
|
859
|
+
for i, b in enumerate(ranges)
|
|
860
|
+
)
|
|
861
|
+
)
|
|
862
|
+
if not ok:
|
|
863
|
+
raise _err(
|
|
864
|
+
f"facet {name} ranges takes 2 to 10 001 numbers, each above the one before, "
|
|
865
|
+
"and no top: every range answers, in order"
|
|
866
|
+
)
|
|
867
|
+
bounds = [_js_number(b) for b in ranges]
|
|
868
|
+
if not isinstance(disjunctive, bool):
|
|
869
|
+
raise _err(f"facet {name} disjunctive is true or false")
|
|
870
|
+
return self._with(facets=[*self._s["facets"], (name, top, bounds, disjunctive)])
|
|
871
|
+
|
|
872
|
+
def group(self: Q, *keys: Any) -> Q:
|
|
873
|
+
"""`group a, b`: a row per distinct set of the keys' values, for a
|
|
874
|
+
select list that aggregates. A key is a field or a path, a name the
|
|
875
|
+
list gives a column with `as_`, or an expression: `bucket("at", "1h")`."""
|
|
876
|
+
flat = _flat(keys)
|
|
877
|
+
if not flat:
|
|
878
|
+
raise _err("group takes at least one key")
|
|
879
|
+
return self._with(group=_group_keys(flat))
|
|
880
|
+
|
|
881
|
+
def where(self: Q, *args: Any) -> Q:
|
|
882
|
+
"""`where(field, op, value)`, `where(field, value)` -- a value is
|
|
883
|
+
equality, a dict an operator map -- or `where(condition)`: a dict of
|
|
884
|
+
fields, or what `or_`, `and_`, `not_` and `raw` make. Successive
|
|
885
|
+
calls join with `and`."""
|
|
886
|
+
return self._with(cond=[*self._s["cond"], _cond_of(args)])
|
|
887
|
+
|
|
888
|
+
def or_where(self: Q, *args: Any) -> Q:
|
|
889
|
+
"""Joins everything conditioned so far to a new condition with `or`."""
|
|
890
|
+
right = _cond_of(args)
|
|
891
|
+
left = self._s["cond"]
|
|
892
|
+
if not left:
|
|
893
|
+
return self._with(cond=[right])
|
|
894
|
+
return self._with(cond=[Cond("or", items=[Cond("and", items=list(left)), right])])
|
|
895
|
+
|
|
896
|
+
def near(self: Q, field: str, vector: Any, *, ef: int | None = None, exact: bool = False) -> Q:
|
|
897
|
+
"""`near field $n [ef N] [exact]`."""
|
|
898
|
+
return self._with(
|
|
899
|
+
match_first=False if self._s["match"] is None else self._s["match_first"],
|
|
900
|
+
near=(_ident(field), vector, None if ef is None else _whole(ef, "ef"), bool(exact)),
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
def match(
|
|
904
|
+
self: Q, field: str, query: str, *, fuzzy: bool | int = False, prefix: bool = False
|
|
905
|
+
) -> Q:
|
|
906
|
+
"""`match field $n [fuzzy [N]] [prefix]`: BM25 over a `@text` index.
|
|
907
|
+
`fuzzy=True` (`fuzzy` alone, 2 edits) matches each word within the
|
|
908
|
+
edits its length allows -- none under 5 characters, one up to 8, two
|
|
909
|
+
from 9 -- `fuzzy=1` one at most; `prefix=True` matches the last word
|
|
910
|
+
as the start of a word too, as a search box is typed into."""
|
|
911
|
+
# `True` is an `int` too: asked first, it is the parser's default,
|
|
912
|
+
# written as `fuzzy` alone, and never 1.
|
|
913
|
+
if fuzzy is True:
|
|
914
|
+
edits = True
|
|
915
|
+
elif fuzzy is False:
|
|
916
|
+
edits = None
|
|
917
|
+
elif isinstance(fuzzy, int) and 0 <= fuzzy <= 2:
|
|
918
|
+
edits = fuzzy
|
|
919
|
+
else:
|
|
920
|
+
raise _err(f"fuzzy is true, false or 0 to 2 edits: {fuzzy}")
|
|
921
|
+
if not isinstance(prefix, bool):
|
|
922
|
+
raise _err(f"prefix is true or false: {prefix}")
|
|
923
|
+
return self._with(
|
|
924
|
+
match_first=True if self._s["near"] is None else self._s["match_first"],
|
|
925
|
+
match=(_ident(field), query, edits, prefix),
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
def fuse(self: Q, *, k: int | None = None, candidates: int | None = None) -> Q:
|
|
929
|
+
"""`fuse [k N] [candidates N]`: with both `match` and `near`, ranks by
|
|
930
|
+
both -- a document scores `1 / (k + rank)` from each list it is on."""
|
|
931
|
+
return self._with(
|
|
932
|
+
fuse=(
|
|
933
|
+
None if k is None else _whole(k, "k"),
|
|
934
|
+
None if candidates is None else _whole(candidates, "candidates"),
|
|
935
|
+
)
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
def rerank(self: Q, field: str, vector: Any, *, candidates: int | None = None) -> Q:
|
|
939
|
+
"""`rerank field $n [candidates N]`: reorders what `match` found by
|
|
940
|
+
exact distance, the vectors read out of the store."""
|
|
941
|
+
return self._with(
|
|
942
|
+
rerank=(
|
|
943
|
+
_ident(field),
|
|
944
|
+
vector,
|
|
945
|
+
None if candidates is None else _whole(candidates, "candidates"),
|
|
946
|
+
)
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
def lookup(
|
|
950
|
+
self: Q,
|
|
951
|
+
name: str,
|
|
952
|
+
*,
|
|
953
|
+
on: str | Sequence[str] | None = None,
|
|
954
|
+
parent_key: str | Sequence[str] | None = None,
|
|
955
|
+
select: Any = None,
|
|
956
|
+
group: Any = None,
|
|
957
|
+
where: Any = None,
|
|
958
|
+
required: bool = False,
|
|
959
|
+
order: Any = None,
|
|
960
|
+
limit: int | None = None,
|
|
961
|
+
offset: int | None = None,
|
|
962
|
+
) -> Q:
|
|
963
|
+
"""`lookup name on child [= parent] ...`: each row's children,
|
|
964
|
+
attached to it. Everything here binds to the looked-up collection;
|
|
965
|
+
`limit` counts children per parent. `required` drops a parent no
|
|
966
|
+
child matches. A key over several fields names a field of each side
|
|
967
|
+
for each part, `on=["org", "order"], parent_key=["org", "id"]`.
|
|
968
|
+
Called again, it chains onto the collection the call before named.
|
|
969
|
+
|
|
970
|
+
`select` may aggregate, as a query's own list does: each parent's
|
|
971
|
+
children folded, one row a parent, or with `group` one a group,
|
|
972
|
+
ordered and paged a parent at a time; ungrouped, `order`, `limit`
|
|
973
|
+
and `offset` pick the children folded. A query whose list
|
|
974
|
+
aggregates hangs a lookup off its groups, `parent_key` a column."""
|
|
975
|
+
names = [on] if isinstance(on, str) else list(on or [])
|
|
976
|
+
if not names or not names[0]:
|
|
977
|
+
raise _err("lookup needs `on`: the child field holding the key")
|
|
978
|
+
child = [_ident(f) for f in names]
|
|
979
|
+
above = (
|
|
980
|
+
[]
|
|
981
|
+
if parent_key is None
|
|
982
|
+
else [_ident(f) for f in ([parent_key] if isinstance(parent_key, str) else parent_key)]
|
|
983
|
+
)
|
|
984
|
+
# One child field alone pairs with `id` unless named; several, each
|
|
985
|
+
# with its own.
|
|
986
|
+
if (len(child) > 1 or parent_key is not None) and len(above) != len(child):
|
|
987
|
+
raise _err(f"lookup pairs {len(child)} child fields with {len(above)} parent fields")
|
|
988
|
+
cols = None if select is None else _flat((select,))
|
|
989
|
+
listed = None if cols is None or "*" in cols else [_column(c) for c in cols]
|
|
990
|
+
aggregate = any(a for _, a in listed or [])
|
|
991
|
+
keys = None if group is None else _group_keys(_flat((group,)))
|
|
992
|
+
# The engine refuses it too; failing here never sends a query.
|
|
993
|
+
if keys and not aggregate:
|
|
994
|
+
text = ", ".join(k if isinstance(k, str) else k.sql for k in keys)
|
|
995
|
+
raise _err(f"group {text} needs an aggregate in select: 'count(*)'")
|
|
996
|
+
# A level selects its children's fields, or aggregates of them.
|
|
997
|
+
if listed and not aggregate and any(not isinstance(c, str) for c, _ in listed):
|
|
998
|
+
raise _err("a lookup selects its children's fields, or aggregates of them")
|
|
999
|
+
level = {
|
|
1000
|
+
"collection": _ident(name, "collection"),
|
|
1001
|
+
"on": child,
|
|
1002
|
+
"parent": above,
|
|
1003
|
+
"project": None if listed is None else [c for c, _ in listed],
|
|
1004
|
+
"group": keys,
|
|
1005
|
+
"cond": [] if where is None else [_cond_of((where,))],
|
|
1006
|
+
"required": bool(required),
|
|
1007
|
+
"order": _order_keys(order),
|
|
1008
|
+
"limit": None if limit is None else _whole(limit, "limit"),
|
|
1009
|
+
"offset": 0 if offset is None else _whole(offset, "offset"),
|
|
1010
|
+
}
|
|
1011
|
+
return self._with(lookups=[*self._s["lookups"], level])
|
|
1012
|
+
|
|
1013
|
+
def order(
|
|
1014
|
+
self: Q, field: str | Computed, direction: str = "asc", *, collate: str | None = None
|
|
1015
|
+
) -> Q:
|
|
1016
|
+
"""`order field asc|desc`; each call adds a key. A key may be an
|
|
1017
|
+
expression over the row -- `order(expr("n * 2"), "desc")`,
|
|
1018
|
+
`order(distance("pickup", point))` -- ties going in id order, and
|
|
1019
|
+
picks the rows of an `update` or a `delete` too. `collate="tr"` puts
|
|
1020
|
+
text in Turkish order, `"und"` in Unicode's root order."""
|
|
1021
|
+
if isinstance(field, Computed) and field.kind == "expr":
|
|
1022
|
+
# A name given with `as_` would read as the key's own; the
|
|
1023
|
+
# statement has no such column to order by.
|
|
1024
|
+
if field.name is not None:
|
|
1025
|
+
raise _err(
|
|
1026
|
+
f"an order key is an expression or a name: order by {field.name} itself, without as"
|
|
1027
|
+
)
|
|
1028
|
+
key: Any = field
|
|
1029
|
+
else:
|
|
1030
|
+
# Over groups a key may be an aggregate of the list, by its name.
|
|
1031
|
+
key = _key(field)
|
|
1032
|
+
return self._with(
|
|
1033
|
+
order=[*self._s["order"], (key, _direction(direction), _collation(collate))]
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
def limit(self: Q, n: int) -> Q:
|
|
1037
|
+
return self._with(limit=_whole(n, "limit"))
|
|
1038
|
+
|
|
1039
|
+
def offset(self: Q, n: int) -> Q:
|
|
1040
|
+
return self._with(offset=_whole(n, "offset"))
|
|
1041
|
+
|
|
1042
|
+
def require(self: Q, n: int, *, reason: str | None = None) -> Q:
|
|
1043
|
+
"""`require n`: the rows the query answers, after `limit`, must
|
|
1044
|
+
number `n`, or it is refused (412, `unmet`) and the batch it is in
|
|
1045
|
+
put back, as a write's `require=n` is -- a checkout's guard on a
|
|
1046
|
+
read: `.where(sku=sku, price=price).require(1)`.
|
|
1047
|
+
|
|
1048
|
+
`reason` says why in the refusal, `require n else '<reason>'`: the
|
|
1049
|
+
error's `reason`, by which a caller tells "no such coupon" from
|
|
1050
|
+
"insufficient funds" where a batch's `at` moves with every statement
|
|
1051
|
+
added in front -- `.require(1, reason="no such coupon")`."""
|
|
1052
|
+
return self._with(require=_require_clause(n, reason))
|
|
1053
|
+
|
|
1054
|
+
# ---------------------------------------------------------- the text
|
|
1055
|
+
|
|
1056
|
+
def to_fennecql(self) -> tuple[str, list]:
|
|
1057
|
+
"""The statement and its parameters, as they would be sent."""
|
|
1058
|
+
bind = _Binder()
|
|
1059
|
+
return self._text(bind), bind.params
|
|
1060
|
+
|
|
1061
|
+
def _inner(self, bind: _Binder) -> str:
|
|
1062
|
+
"""The text as an inner query of `in`, its values bound by the
|
|
1063
|
+
query around it: numbered where its text stands, and a param the two
|
|
1064
|
+
share one `$n`."""
|
|
1065
|
+
s = self._s
|
|
1066
|
+
# One column is the list; the engine refuses the rest too.
|
|
1067
|
+
if (
|
|
1068
|
+
s["project"] is None
|
|
1069
|
+
or len(s["project"]) != 1
|
|
1070
|
+
or s["count"]
|
|
1071
|
+
or s["lookups"]
|
|
1072
|
+
or s["marks"]
|
|
1073
|
+
or s["facets"]
|
|
1074
|
+
):
|
|
1075
|
+
# The JavaScript builder's spelling, word for word: every
|
|
1076
|
+
# builder's refusal is the same text.
|
|
1077
|
+
raise _err(
|
|
1078
|
+
f"an inner query of `in` selects exactly one column: "
|
|
1079
|
+
f"from('{s['collection']}').select('id')"
|
|
1080
|
+
)
|
|
1081
|
+
if s["require"]:
|
|
1082
|
+
raise _err("an inner query of `in` takes no require: the query around it does")
|
|
1083
|
+
return self._text(bind)
|
|
1084
|
+
|
|
1085
|
+
def _text(self, bind: _Binder) -> str:
|
|
1086
|
+
s = self._s
|
|
1087
|
+
near, match, rerank, fuse = s["near"], s["match"], s["rerank"], s["fuse"]
|
|
1088
|
+
lookups, count, order = s["lookups"], s["count"], s["order"]
|
|
1089
|
+
marks, facets = s["marks"], s["facets"]
|
|
1090
|
+
# The engine refuses each of these too; failing here sends nothing.
|
|
1091
|
+
if s["group"] and not s["aggregate"]:
|
|
1092
|
+
keys = ", ".join(k if isinstance(k, str) else k.sql for k in s["group"])
|
|
1093
|
+
raise _err(f"group {keys} needs an aggregate in select: 'count(*)'")
|
|
1094
|
+
if s["aggregate"]:
|
|
1095
|
+
# Ungrouped, `order`, `limit` and `offset` pick the rows folded;
|
|
1096
|
+
# a lookup hangs off the groups, keyed by a column. Beside them
|
|
1097
|
+
# `match` is a filter: the rows it finds are folded.
|
|
1098
|
+
clash = "near" if near else "count" if count and not s["group"] else None
|
|
1099
|
+
if clash:
|
|
1100
|
+
raise _err(f"aggregates cannot be combined with {clash}")
|
|
1101
|
+
if not s["group"] and s["require"]:
|
|
1102
|
+
raise _err("require counts the rows a query answers, and an aggregate answers one")
|
|
1103
|
+
if marks:
|
|
1104
|
+
what = "highlight" if marks[0]["words"] is None else "snippet"
|
|
1105
|
+
if not match:
|
|
1106
|
+
raise _err(f"{what} needs match: it marks the terms match found")
|
|
1107
|
+
if s["aggregate"]:
|
|
1108
|
+
raise _err(f"{what} marks a row's text; aggregates answer groups")
|
|
1109
|
+
if facets:
|
|
1110
|
+
if near:
|
|
1111
|
+
raise _err(
|
|
1112
|
+
"facet counts the rows a filter or match selects, and near ranks every "
|
|
1113
|
+
"row: ask the facets without near"
|
|
1114
|
+
)
|
|
1115
|
+
if s["aggregate"]:
|
|
1116
|
+
raise _err("facet cannot be combined with aggregates: group counts by value")
|
|
1117
|
+
if rerank and not match:
|
|
1118
|
+
raise _err("rerank needs match: it reorders what match found")
|
|
1119
|
+
if match and near and not fuse:
|
|
1120
|
+
raise _err("match and near cannot be combined: both order the result; fuse() ranks by both")
|
|
1121
|
+
if fuse and not (match and near):
|
|
1122
|
+
raise _err("fuse combines match and near: the query needs both")
|
|
1123
|
+
if fuse and rerank:
|
|
1124
|
+
raise _err("fuse and rerank are two ways to use a vector with match: pick one")
|
|
1125
|
+
if lookups:
|
|
1126
|
+
clash = "near" if near else "match" if match else "rerank" if rerank else None
|
|
1127
|
+
if clash:
|
|
1128
|
+
raise _err(f"lookup cannot be combined with {clash}")
|
|
1129
|
+
if count and not lookups[0]["required"]:
|
|
1130
|
+
raise _err(
|
|
1131
|
+
"count cannot be used with lookup unless it is required: there is "
|
|
1132
|
+
"nothing to attach children to"
|
|
1133
|
+
)
|
|
1134
|
+
if len(lookups) > MAX_LOOKUP_DEPTH:
|
|
1135
|
+
raise _err(f"lookup chained too deep: at most {MAX_LOOKUP_DEPTH} levels")
|
|
1136
|
+
seen = [s["collection"]]
|
|
1137
|
+
for level in lookups:
|
|
1138
|
+
if level["collection"] in seen:
|
|
1139
|
+
raise _err(
|
|
1140
|
+
f"{level['collection']} cannot look itself up: both sides would "
|
|
1141
|
+
"answer to the same name"
|
|
1142
|
+
)
|
|
1143
|
+
seen.append(level["collection"])
|
|
1144
|
+
if count:
|
|
1145
|
+
# Beside `count` `match` is a filter: it counts the rows it finds.
|
|
1146
|
+
extra = self._extra_clause(counts=True) or ("require" if s["require"] else None)
|
|
1147
|
+
if extra:
|
|
1148
|
+
raise _err(f"count cannot be used with `{extra}`")
|
|
1149
|
+
sql = f"get {s['collection']}"
|
|
1150
|
+
# The marks after the fields `select` named, or after every field.
|
|
1151
|
+
items = []
|
|
1152
|
+
for m in marks:
|
|
1153
|
+
item = f"{'highlight' if m['words'] is None else 'snippet'}({m['field']}"
|
|
1154
|
+
if m["words"] is not None:
|
|
1155
|
+
item += f", {m['words']}"
|
|
1156
|
+
# A snippet's tags come after its ellipsis, so tags without one
|
|
1157
|
+
# bind the empty one.
|
|
1158
|
+
if "ellipsis" in m or (m["words"] is not None and "pre" in m):
|
|
1159
|
+
item += f", {bind(m.get('ellipsis', ''), 'snippet')}"
|
|
1160
|
+
if "pre" in m:
|
|
1161
|
+
item += f", {bind(m['pre'], m['field'])}, {bind(m['post'], m['field'])}"
|
|
1162
|
+
items.append(item + ")")
|
|
1163
|
+
# The list's values are bound first: they come first in the text.
|
|
1164
|
+
cols = None if s["project"] is None else [_column_text(c, bind) for c in s["project"]]
|
|
1165
|
+
if cols or items:
|
|
1166
|
+
sql += f" select {', '.join([*(cols or ['*']), *items])}"
|
|
1167
|
+
where = self._where(bind)
|
|
1168
|
+
if where:
|
|
1169
|
+
sql += f" where {where}"
|
|
1170
|
+
if s["group"]:
|
|
1171
|
+
sql += f" group {', '.join(_column_text(k, bind) for k in s['group'])}"
|
|
1172
|
+
def near_text() -> str:
|
|
1173
|
+
out = f" near {near[0]} {bind(near[1], near[0])}"
|
|
1174
|
+
if near[2] is not None:
|
|
1175
|
+
out += f" ef {near[2]}"
|
|
1176
|
+
return out + (" exact" if near[3] else "")
|
|
1177
|
+
|
|
1178
|
+
# Before `match` unless `match` was called first: a `fuse` reads both
|
|
1179
|
+
# ways, and the text keeps the chain's order.
|
|
1180
|
+
match_first = bool(match) and s["match_first"]
|
|
1181
|
+
if near and not match_first:
|
|
1182
|
+
sql += near_text()
|
|
1183
|
+
if match:
|
|
1184
|
+
sql += f" match {match[0]} {bind(match[1], match[0])}"
|
|
1185
|
+
# `True` is the parser's default, `fuzzy` alone (2 edits); a
|
|
1186
|
+
# number is written.
|
|
1187
|
+
if match[2] is True:
|
|
1188
|
+
sql += " fuzzy"
|
|
1189
|
+
elif match[2] is not None:
|
|
1190
|
+
sql += f" fuzzy {match[2]}"
|
|
1191
|
+
if match[3]:
|
|
1192
|
+
sql += " prefix"
|
|
1193
|
+
if near and match_first:
|
|
1194
|
+
sql += near_text()
|
|
1195
|
+
if rerank:
|
|
1196
|
+
sql += f" rerank {rerank[0]} {bind(rerank[1], rerank[0])}"
|
|
1197
|
+
if rerank[2] is not None:
|
|
1198
|
+
sql += f" candidates {rerank[2]}"
|
|
1199
|
+
if fuse:
|
|
1200
|
+
sql += " fuse"
|
|
1201
|
+
if fuse[0] is not None:
|
|
1202
|
+
sql += f" k {fuse[0]}"
|
|
1203
|
+
if fuse[1] is not None:
|
|
1204
|
+
sql += f" candidates {fuse[1]}"
|
|
1205
|
+
for i, k in enumerate(order):
|
|
1206
|
+
sql += f"{' order ' if i == 0 else ', '}{_sort_key(k, bind)}"
|
|
1207
|
+
if s["limit"] is not None:
|
|
1208
|
+
sql += f" limit {bind(s['limit'], 'limit')}"
|
|
1209
|
+
if s["offset"]:
|
|
1210
|
+
sql += f" offset {bind(s['offset'], 'offset')}"
|
|
1211
|
+
# Before a lookup, whose clauses are the children's.
|
|
1212
|
+
sql += s["require"]
|
|
1213
|
+
if count:
|
|
1214
|
+
sql += " count"
|
|
1215
|
+
if facets:
|
|
1216
|
+
sql += " facet " + ", ".join(
|
|
1217
|
+
f
|
|
1218
|
+
+ ("" if top is None else f" top {bind(top, 'top')}")
|
|
1219
|
+
+ ("" if bounds is None else f" ranges [{', '.join(bounds)}]")
|
|
1220
|
+
+ (" disjunctive" if disjunctive else "")
|
|
1221
|
+
for f, top, bounds, disjunctive in facets
|
|
1222
|
+
)
|
|
1223
|
+
# Terminal, so every clause after it is the child's -- and last, so
|
|
1224
|
+
# its parameters come after the parent's.
|
|
1225
|
+
def side(names: list[str]) -> str:
|
|
1226
|
+
return f"({', '.join(names)})" if len(names) > 1 else names[0]
|
|
1227
|
+
|
|
1228
|
+
for level in lookups:
|
|
1229
|
+
sql += f" lookup {level['collection']} on {side(level['on'])}"
|
|
1230
|
+
if level["parent"]:
|
|
1231
|
+
sql += f" = {side(level['parent'])}"
|
|
1232
|
+
if level["required"]:
|
|
1233
|
+
sql += " required"
|
|
1234
|
+
if level["project"]:
|
|
1235
|
+
sql += f" select {', '.join(_column_text(c, bind) for c in level['project'])}"
|
|
1236
|
+
root = _prune(Cond("and", items=level["cond"]))
|
|
1237
|
+
if root is not None:
|
|
1238
|
+
sql += f" where {_render(root, bind)}"
|
|
1239
|
+
if level["group"]:
|
|
1240
|
+
sql += f" group {', '.join(_column_text(k, bind) for k in level['group'])}"
|
|
1241
|
+
for i, k in enumerate(level["order"]):
|
|
1242
|
+
sql += f"{' order ' if i == 0 else ', '}{_sort_key(k, bind)}"
|
|
1243
|
+
if level["limit"] is not None:
|
|
1244
|
+
sql += f" limit {bind(level['limit'], 'limit')}"
|
|
1245
|
+
if level["offset"]:
|
|
1246
|
+
sql += f" offset {bind(level['offset'], 'offset')}"
|
|
1247
|
+
return sql
|
|
1248
|
+
|
|
1249
|
+
def to_insert(
|
|
1250
|
+
self,
|
|
1251
|
+
docs: Mapping | Sequence[Mapping],
|
|
1252
|
+
*,
|
|
1253
|
+
if_absent: bool = False,
|
|
1254
|
+
require: int | None = None,
|
|
1255
|
+
reason: str | None = None,
|
|
1256
|
+
returning: Any = None,
|
|
1257
|
+
) -> tuple[str, list]:
|
|
1258
|
+
"""The `put` of a document or a list of them, not sent. With
|
|
1259
|
+
`if_absent=True`, `put ... if absent`: a document whose id or
|
|
1260
|
+
`@unique` value is held is passed over, and not counted. With
|
|
1261
|
+
`require=n` on any write, `... require n`: unless it wrote exactly
|
|
1262
|
+
`n` rows it is refused (412) and its batch put back; `reason=` beside
|
|
1263
|
+
it, `require n else '<reason>'`, is the refusal's `reason`.
|
|
1264
|
+
`returning=True`, or a list of fields, answers the rows written, ids
|
|
1265
|
+
and all."""
|
|
1266
|
+
return self._put("put", docs, if_absent, require, reason, returning)
|
|
1267
|
+
|
|
1268
|
+
def to_insert_new(
|
|
1269
|
+
self,
|
|
1270
|
+
docs: Mapping | Sequence[Mapping],
|
|
1271
|
+
*,
|
|
1272
|
+
require: int | None = None,
|
|
1273
|
+
reason: str | None = None,
|
|
1274
|
+
returning: Any = None,
|
|
1275
|
+
) -> tuple[str, list]:
|
|
1276
|
+
"""The `insert` of a document or a list of them, not sent: one
|
|
1277
|
+
naming an id a row holds is refused -- 409, `duplicate` -- and the
|
|
1278
|
+
statement, a block of one, put back whole, where `to_insert`'s `put`
|
|
1279
|
+
writes over that row. `require`, `reason` and `returning` as
|
|
1280
|
+
`to_insert`'s; no `if_absent`, which passes over a held id where
|
|
1281
|
+
this refuses it."""
|
|
1282
|
+
return self._put("insert", docs, False, require, reason, returning)
|
|
1283
|
+
|
|
1284
|
+
def _put(
|
|
1285
|
+
self, verb: str, docs: Any, if_absent: Any, require: Any, reason: Any, returning: Any
|
|
1286
|
+
) -> tuple[str, list]:
|
|
1287
|
+
# Both verbs are checked as `insert`, so their refusals read alike.
|
|
1288
|
+
self._assert_plain("insert")
|
|
1289
|
+
items = list(docs) if isinstance(docs, (list, tuple)) else [docs]
|
|
1290
|
+
if not items:
|
|
1291
|
+
raise _err("cannot write an empty document list")
|
|
1292
|
+
bind = _Binder()
|
|
1293
|
+
body = ", ".join(_render_doc(d, bind, "insert") for d in items)
|
|
1294
|
+
absent = " if absent" if if_absent is True else ""
|
|
1295
|
+
tail = _returning_clause(returning) + _require_clause(require, reason)
|
|
1296
|
+
many = body if len(items) == 1 else f"[{body}]"
|
|
1297
|
+
return f"{verb} {self.collection} {many}{absent}{tail}", bind.params
|
|
1298
|
+
|
|
1299
|
+
def to_update(
|
|
1300
|
+
self,
|
|
1301
|
+
patch: Mapping,
|
|
1302
|
+
*,
|
|
1303
|
+
all: bool = False,
|
|
1304
|
+
require: int | None = None,
|
|
1305
|
+
reason: str | None = None,
|
|
1306
|
+
returning: Any = None,
|
|
1307
|
+
) -> tuple[str, list]:
|
|
1308
|
+
"""The `set` of the rows the filter names, not sent. `order` and
|
|
1309
|
+
`limit` before it pick the rows it writes -- the page a `get` with
|
|
1310
|
+
them answers -- and `returning=True`, or a list of fields, answers
|
|
1311
|
+
them as written: a job queue's claim,
|
|
1312
|
+
`q.where(raw("run_at <= now()")).order("run_at").limit(10)
|
|
1313
|
+
.update({...}, returning=True)`."""
|
|
1314
|
+
self._assert_plain("update")
|
|
1315
|
+
bind = _Binder()
|
|
1316
|
+
body = _render_doc(patch, bind, "update")
|
|
1317
|
+
where = self._require_filter("update", all, bind)
|
|
1318
|
+
tail = f"{self._pick(returning, bind)}{_require_clause(require, reason)}"
|
|
1319
|
+
return f"set {self.collection} {body}{where}{tail}", bind.params
|
|
1320
|
+
|
|
1321
|
+
def to_upsert(
|
|
1322
|
+
self,
|
|
1323
|
+
docs: Mapping | Sequence[Mapping],
|
|
1324
|
+
patch: Mapping,
|
|
1325
|
+
*,
|
|
1326
|
+
require: int | None = None,
|
|
1327
|
+
reason: str | None = None,
|
|
1328
|
+
returning: Any = None,
|
|
1329
|
+
) -> tuple[str, list]:
|
|
1330
|
+
"""The upsert, `put ... if absent else set {patch}`, not sent: a
|
|
1331
|
+
document whose id or first `@unique` value a row holds sets that row
|
|
1332
|
+
by the patch -- an update's, `inc(n)` and `expr(...)` with it, and
|
|
1333
|
+
in an expression `new.f` the document's own `f` -- and every other
|
|
1334
|
+
one is inserted. The documents' parameters come first, then the
|
|
1335
|
+
patch's."""
|
|
1336
|
+
self._assert_plain("upsert")
|
|
1337
|
+
items = list(docs) if isinstance(docs, (list, tuple)) else [docs]
|
|
1338
|
+
if not items:
|
|
1339
|
+
raise _err("cannot write an empty document list")
|
|
1340
|
+
bind = _Binder()
|
|
1341
|
+
body = ", ".join(_render_doc(d, bind, "insert") for d in items)
|
|
1342
|
+
patched = _render_doc(patch, bind, "update")
|
|
1343
|
+
tail = _returning_clause(returning) + _require_clause(require, reason)
|
|
1344
|
+
many = body if len(items) == 1 else f"[{body}]"
|
|
1345
|
+
return f"put {self.collection} {many} if absent else set {patched}{tail}", bind.params
|
|
1346
|
+
|
|
1347
|
+
def to_delete(
|
|
1348
|
+
self,
|
|
1349
|
+
*,
|
|
1350
|
+
all: bool = False,
|
|
1351
|
+
require: int | None = None,
|
|
1352
|
+
reason: str | None = None,
|
|
1353
|
+
returning: Any = None,
|
|
1354
|
+
) -> tuple[str, list]:
|
|
1355
|
+
"""The `del` of the rows the filter names, not sent; `order`,
|
|
1356
|
+
`limit` and `returning` as `to_update`'s, the rows answered as they
|
|
1357
|
+
were: a pop."""
|
|
1358
|
+
self._assert_plain("delete")
|
|
1359
|
+
bind = _Binder()
|
|
1360
|
+
where = self._require_filter("delete", all, bind)
|
|
1361
|
+
tail = f"{self._pick(returning, bind)}{_require_clause(require, reason)}"
|
|
1362
|
+
return f"del {self.collection}{where}{tail}", bind.params
|
|
1363
|
+
|
|
1364
|
+
# `near`, `offset`, `select` mean something only to a read; dropped from
|
|
1365
|
+
# a write, `.offset(1).delete()` would delete from the first row. `order`
|
|
1366
|
+
# and `limit` pick the rows an update or a delete writes.
|
|
1367
|
+
def _assert_plain(self, verb: str) -> None:
|
|
1368
|
+
if self._s["require"]:
|
|
1369
|
+
raise _err(f"{verb} takes require as its option: {verb}(..., {{ require: n }})")
|
|
1370
|
+
extra = self._extra_clause(verb in ("update", "delete"))
|
|
1371
|
+
if extra:
|
|
1372
|
+
raise _err(f"{verb} cannot be used with `{extra}`")
|
|
1373
|
+
if self._s["lookups"]:
|
|
1374
|
+
raise _err(f"{verb} cannot be used with `lookup`")
|
|
1375
|
+
if self._s["facets"]:
|
|
1376
|
+
raise _err(f"{verb} cannot be used with `facet`")
|
|
1377
|
+
if verb in ("insert", "upsert") and self._s["cond"]:
|
|
1378
|
+
raise _err(f"{verb} cannot be used with `where`")
|
|
1379
|
+
|
|
1380
|
+
def _extra_clause(self, picks: bool = False, counts: bool = False) -> str | None:
|
|
1381
|
+
s = self._s
|
|
1382
|
+
return (
|
|
1383
|
+
"near" if s["near"]
|
|
1384
|
+
else "match" if s["match"] and not counts
|
|
1385
|
+
else "rerank" if s["rerank"]
|
|
1386
|
+
else "order" if s["order"] and not picks
|
|
1387
|
+
else "limit" if s["limit"] is not None and not picks
|
|
1388
|
+
else "offset" if s["offset"]
|
|
1389
|
+
else "select" if s["project"]
|
|
1390
|
+
else None
|
|
1391
|
+
) # fmt: skip
|
|
1392
|
+
|
|
1393
|
+
# An update's or a delete's ` order ... limit $n returning ...`.
|
|
1394
|
+
def _pick(self, returning: Any, bind: _Binder) -> str:
|
|
1395
|
+
sql = ""
|
|
1396
|
+
for i, k in enumerate(self._s["order"]):
|
|
1397
|
+
sql += f"{' order ' if i == 0 else ', '}{_sort_key(k, bind)}"
|
|
1398
|
+
if self._s["limit"] is not None:
|
|
1399
|
+
sql += f" limit {bind(self._s['limit'], 'limit')}"
|
|
1400
|
+
return sql + _returning_clause(returning)
|
|
1401
|
+
|
|
1402
|
+
# An update or delete of every row is too easy to do by accident and
|
|
1403
|
+
# cannot be undone: it has to be asked for. A `limit` bounds it.
|
|
1404
|
+
def _require_filter(self, verb: str, everything: bool, bind: _Binder) -> str:
|
|
1405
|
+
where = self._where(bind)
|
|
1406
|
+
if where:
|
|
1407
|
+
return f" where {where}"
|
|
1408
|
+
if everything is True or self._s["limit"] is not None:
|
|
1409
|
+
return ""
|
|
1410
|
+
raise _err(
|
|
1411
|
+
f"an unfiltered {verb} covers the whole collection; if you mean it, "
|
|
1412
|
+
f"{verb}({{ all: true }})"
|
|
1413
|
+
)
|
|
1414
|
+
|
|
1415
|
+
def _where(self, bind: _Binder) -> str | None:
|
|
1416
|
+
root = _prune(Cond("and", items=self._s["cond"]))
|
|
1417
|
+
return None if root is None else _render(root, bind)
|
|
1418
|
+
|
|
1419
|
+
def _client(self) -> Any:
|
|
1420
|
+
client = self._s["client"]
|
|
1421
|
+
if client is None:
|
|
1422
|
+
raise _err(
|
|
1423
|
+
"query is not bound to a connection: use db.collection(...) "
|
|
1424
|
+
"(to_fennecql() if you only want the text)"
|
|
1425
|
+
)
|
|
1426
|
+
return client
|
|
1427
|
+
|
|
1428
|
+
def _counted(self: Q) -> Q:
|
|
1429
|
+
return self._with(count=True)
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
def _render_doc(doc: Any, bind: _Binder, write: str) -> str:
|
|
1433
|
+
if not isinstance(doc, Mapping):
|
|
1434
|
+
raise _err("expected a document object")
|
|
1435
|
+
pairs = [f"{_path(k)}: {_value(k, v, bind, write)}" for k, v in doc.items()]
|
|
1436
|
+
if not pairs:
|
|
1437
|
+
raise _err("cannot write an empty document")
|
|
1438
|
+
return "{" + ", ".join(pairs) + "}"
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
class FacetCount(NamedTuple):
|
|
1442
|
+
"""A value a facet counted -- any JSON value, `None` for the rows whose
|
|
1443
|
+
field is null -- and how many rows hold it."""
|
|
1444
|
+
|
|
1445
|
+
value: Any
|
|
1446
|
+
count: int
|
|
1447
|
+
|
|
1448
|
+
|
|
1449
|
+
class Rows(list):
|
|
1450
|
+
"""A query's rows, a dict each -- a list, and equal to one -- with what
|
|
1451
|
+
`facet` counted beside them as `facets`: each field asked, in the order
|
|
1452
|
+
asked, to its values most first. Empty when the query asked none."""
|
|
1453
|
+
|
|
1454
|
+
facets: dict[str, list[FacetCount]]
|
|
1455
|
+
|
|
1456
|
+
def __init__(self, rows: Iterable = (), facets: Mapping | None = None):
|
|
1457
|
+
super().__init__(rows)
|
|
1458
|
+
self.facets = {
|
|
1459
|
+
k: [FacetCount(c.get("value"), c.get("count", 0)) for c in v]
|
|
1460
|
+
for k, v in (facets or {}).items()
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
|
|
1464
|
+
def _rows(answer: Any) -> Rows:
|
|
1465
|
+
"""The rows of a `/query` answer: the bare array, or -- for a query that
|
|
1466
|
+
asked facets -- `{"rows": [...], "facets": {...}}`, the counts beside
|
|
1467
|
+
the rows since they answer for every row matched, not one."""
|
|
1468
|
+
if isinstance(answer, Rows):
|
|
1469
|
+
return answer
|
|
1470
|
+
if isinstance(answer, list):
|
|
1471
|
+
return Rows(answer)
|
|
1472
|
+
if isinstance(answer, Mapping) and isinstance(answer.get("rows"), list):
|
|
1473
|
+
return Rows(answer["rows"], answer.get("facets"))
|
|
1474
|
+
return Rows()
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
def _affected(answer: Any) -> int:
|
|
1478
|
+
return answer.get("affected", 0) if isinstance(answer, dict) else 0
|
|
1479
|
+
|
|
1480
|
+
|
|
1481
|
+
def _written(answer: Any, returning: Any) -> Any:
|
|
1482
|
+
"""A write's answer: its count, or with `returning` its rows."""
|
|
1483
|
+
if returning is None or returning is False:
|
|
1484
|
+
return _affected(answer)
|
|
1485
|
+
return _rows(answer)
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
def _returning_clause(returning: Any) -> str:
|
|
1489
|
+
"""` returning ...` for a write's `returning`: `True` or `["*"]` every
|
|
1490
|
+
field, or the fields named, each a field or a path."""
|
|
1491
|
+
if returning is None or returning is False:
|
|
1492
|
+
return ""
|
|
1493
|
+
if returning is True:
|
|
1494
|
+
return " returning *"
|
|
1495
|
+
if isinstance(returning, str) or not isinstance(returning, (list, tuple)) or not returning:
|
|
1496
|
+
raise _err("returning names the fields it answers, or * for every one")
|
|
1497
|
+
if "*" in returning:
|
|
1498
|
+
if len(returning) > 1:
|
|
1499
|
+
raise _err("returning * answers every field: it takes no other")
|
|
1500
|
+
return " returning *"
|
|
1501
|
+
return " returning " + ", ".join(_path(f) for f in returning)
|
|
1502
|
+
|
|
1503
|
+
|
|
1504
|
+
def _count(answer: Any) -> int:
|
|
1505
|
+
rows = _rows(answer)
|
|
1506
|
+
return rows[0].get("count", 0) if rows and isinstance(rows[0], dict) else 0
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
class _Built:
|
|
1510
|
+
"""The `read` a builder's read hands `Client` and `AsyncClient`, true as
|
|
1511
|
+
`read=True` is: a read the builder made itself, a `get` whatever the
|
|
1512
|
+
caller says, which may go again after a reset (`Retry`). A caller's
|
|
1513
|
+
`read=True` is a claim nothing checks at home, and grants no resend."""
|
|
1514
|
+
|
|
1515
|
+
__slots__ = ()
|
|
1516
|
+
|
|
1517
|
+
def __bool__(self) -> bool:
|
|
1518
|
+
return True
|
|
1519
|
+
|
|
1520
|
+
def __repr__(self) -> str:
|
|
1521
|
+
return "_BUILT"
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
_BUILT = _Built()
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
def _read_kw(client: Any) -> dict:
|
|
1528
|
+
"""What a query's read passes its client beside the text: `read=_BUILT`
|
|
1529
|
+
to `Client` and `AsyncClient` -- one reading near (`reads="nearest"`)
|
|
1530
|
+
marks it for a read replica near it, and either sends it again after a
|
|
1531
|
+
reset, as a read the builder made may be (`Retry`); nothing to any
|
|
1532
|
+
other, a client of one's own making among them."""
|
|
1533
|
+
return {"read": _BUILT} if getattr(client, "reads", None) in ("home", "nearest") else {}
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
class Query(_Builder):
|
|
1537
|
+
"""A query over a `Client`: immutable, its endpoints sending it."""
|
|
1538
|
+
|
|
1539
|
+
__slots__ = ()
|
|
1540
|
+
|
|
1541
|
+
def rows(self) -> Rows:
|
|
1542
|
+
"""Every row the query answers, a dict each -- and, for a query that
|
|
1543
|
+
asked facets, the counts as the list's `facets`."""
|
|
1544
|
+
client = self._client()
|
|
1545
|
+
return _rows(client.query(*self.to_fennecql(), **_read_kw(client)))
|
|
1546
|
+
|
|
1547
|
+
def first(self) -> dict | None:
|
|
1548
|
+
"""The first row, or None: the query with `limit 1`."""
|
|
1549
|
+
rows = self.limit(1).rows()
|
|
1550
|
+
return rows[0] if rows else None
|
|
1551
|
+
|
|
1552
|
+
def count(self) -> int:
|
|
1553
|
+
"""How many rows match: `get ... count`, no row decoded."""
|
|
1554
|
+
q = self._counted()
|
|
1555
|
+
client = q._client()
|
|
1556
|
+
return _count(client.query(*q.to_fennecql(), **_read_kw(client)))
|
|
1557
|
+
|
|
1558
|
+
def explain(self) -> list[str]:
|
|
1559
|
+
"""The path the query took, a line a step; the query runs to tell."""
|
|
1560
|
+
sql, params = self.to_fennecql()
|
|
1561
|
+
client = self._client()
|
|
1562
|
+
answer = client.query(f"explain {sql}", params, **_read_kw(client))
|
|
1563
|
+
return [r.get("plan") for r in _rows(answer)]
|
|
1564
|
+
|
|
1565
|
+
def insert(
|
|
1566
|
+
self,
|
|
1567
|
+
docs: Mapping | Sequence[Mapping],
|
|
1568
|
+
*,
|
|
1569
|
+
if_absent: bool = False,
|
|
1570
|
+
require: int | None = None,
|
|
1571
|
+
reason: str | None = None,
|
|
1572
|
+
returning: Any = None,
|
|
1573
|
+
) -> Any:
|
|
1574
|
+
"""`put` of a document or a list of them: how many were written --
|
|
1575
|
+
with `if_absent=True` those whose id or `@unique` value was held
|
|
1576
|
+
left out, so a lock taken answers 1 and one held 0 -- or with
|
|
1577
|
+
`returning` the rows written, ids and all, in the documents' order.
|
|
1578
|
+
With `require=n` (on `update` and `delete` too) a write that did not
|
|
1579
|
+
write exactly `n` rows is refused, 412, and its batch put back;
|
|
1580
|
+
`reason="..."` beside it is the `FennecError`'s `reason`, which tells
|
|
1581
|
+
one refusal from another where a batch's `at` moves with every
|
|
1582
|
+
statement added in front."""
|
|
1583
|
+
if isinstance(docs, (list, tuple)) and not docs:
|
|
1584
|
+
return _written({}, returning)
|
|
1585
|
+
text = self.to_insert(
|
|
1586
|
+
docs, if_absent=if_absent, require=require, reason=reason, returning=returning
|
|
1587
|
+
)
|
|
1588
|
+
return _written(self._client().query(*text), returning)
|
|
1589
|
+
|
|
1590
|
+
def insert_new(
|
|
1591
|
+
self,
|
|
1592
|
+
docs: Mapping | Sequence[Mapping],
|
|
1593
|
+
*,
|
|
1594
|
+
require: int | None = None,
|
|
1595
|
+
reason: str | None = None,
|
|
1596
|
+
returning: Any = None,
|
|
1597
|
+
) -> Any:
|
|
1598
|
+
"""`insert` of a document or a list of them, refused whole (409,
|
|
1599
|
+
`duplicate`) where one names an id a row holds: a row made once,
|
|
1600
|
+
never written over, where `insert`'s `put` writes over it. How many
|
|
1601
|
+
were written, or with `returning` the rows written, ids and all."""
|
|
1602
|
+
if isinstance(docs, (list, tuple)) and not docs:
|
|
1603
|
+
return _written({}, returning)
|
|
1604
|
+
text = self.to_insert_new(docs, require=require, reason=reason, returning=returning)
|
|
1605
|
+
return _written(self._client().query(*text), returning)
|
|
1606
|
+
|
|
1607
|
+
def upsert(
|
|
1608
|
+
self,
|
|
1609
|
+
docs: Mapping | Sequence[Mapping],
|
|
1610
|
+
patch: Mapping,
|
|
1611
|
+
*,
|
|
1612
|
+
require: int | None = None,
|
|
1613
|
+
reason: str | None = None,
|
|
1614
|
+
returning: Any = None,
|
|
1615
|
+
) -> Any:
|
|
1616
|
+
"""`put ... if absent else set`: each document inserted, or where a
|
|
1617
|
+
row holds its id or `@unique` value, that row set by the patch --
|
|
1618
|
+
`upsert({"key": k, "n": 1}, {"n": expr("n + new.n")})`. How many
|
|
1619
|
+
rows it set and made together, or with `returning` those rows."""
|
|
1620
|
+
if isinstance(docs, (list, tuple)) and not docs:
|
|
1621
|
+
return _written({}, returning)
|
|
1622
|
+
text = self.to_upsert(docs, patch, require=require, reason=reason, returning=returning)
|
|
1623
|
+
return _written(self._client().query(*text), returning)
|
|
1624
|
+
|
|
1625
|
+
def update(
|
|
1626
|
+
self,
|
|
1627
|
+
patch: Mapping,
|
|
1628
|
+
*,
|
|
1629
|
+
all: bool = False,
|
|
1630
|
+
require: int | None = None,
|
|
1631
|
+
reason: str | None = None,
|
|
1632
|
+
returning: Any = None,
|
|
1633
|
+
wait: float | None = None,
|
|
1634
|
+
) -> Any:
|
|
1635
|
+
"""`set` over the rows the filter names: how many it changed, or
|
|
1636
|
+
with `returning` the rows as written. With no filter it is refused
|
|
1637
|
+
unless `all=True` or a `limit` bounds it. With `wait`, one that
|
|
1638
|
+
writes nothing is held at the server until it can, or that many
|
|
1639
|
+
seconds: a claim waiting for a job."""
|
|
1640
|
+
text = self.to_update(patch, all=all, require=require, reason=reason, returning=returning)
|
|
1641
|
+
return _written(self._client().query(*text, **_held(wait)), returning)
|
|
1642
|
+
|
|
1643
|
+
def delete(
|
|
1644
|
+
self,
|
|
1645
|
+
*,
|
|
1646
|
+
all: bool = False,
|
|
1647
|
+
require: int | None = None,
|
|
1648
|
+
reason: str | None = None,
|
|
1649
|
+
returning: Any = None,
|
|
1650
|
+
wait: float | None = None,
|
|
1651
|
+
) -> Any:
|
|
1652
|
+
"""`del` of the rows the filter names: how many it deleted, or with
|
|
1653
|
+
`returning` the rows as they were. With no filter it is refused
|
|
1654
|
+
unless `all=True` or a `limit` bounds it. `wait` as `update`'s."""
|
|
1655
|
+
text = self.to_delete(all=all, require=require, reason=reason, returning=returning)
|
|
1656
|
+
return _written(self._client().query(*text, **_held(wait)), returning)
|
|
1657
|
+
|
|
1658
|
+
|
|
1659
|
+
class AsyncQuery(_Builder):
|
|
1660
|
+
"""A query over an `AsyncClient`: `Query`'s endpoints, awaited."""
|
|
1661
|
+
|
|
1662
|
+
__slots__ = ()
|
|
1663
|
+
|
|
1664
|
+
async def rows(self) -> Rows:
|
|
1665
|
+
client = self._client()
|
|
1666
|
+
return _rows(await client.query(*self.to_fennecql(), **_read_kw(client)))
|
|
1667
|
+
|
|
1668
|
+
async def first(self) -> dict | None:
|
|
1669
|
+
rows = await self.limit(1).rows()
|
|
1670
|
+
return rows[0] if rows else None
|
|
1671
|
+
|
|
1672
|
+
async def count(self) -> int:
|
|
1673
|
+
q = self._counted()
|
|
1674
|
+
client = q._client()
|
|
1675
|
+
return _count(await client.query(*q.to_fennecql(), **_read_kw(client)))
|
|
1676
|
+
|
|
1677
|
+
async def explain(self) -> list[str]:
|
|
1678
|
+
sql, params = self.to_fennecql()
|
|
1679
|
+
client = self._client()
|
|
1680
|
+
answer = await client.query(f"explain {sql}", params, **_read_kw(client))
|
|
1681
|
+
return [r.get("plan") for r in _rows(answer)]
|
|
1682
|
+
|
|
1683
|
+
async def insert(
|
|
1684
|
+
self,
|
|
1685
|
+
docs: Mapping | Sequence[Mapping],
|
|
1686
|
+
*,
|
|
1687
|
+
if_absent: bool = False,
|
|
1688
|
+
require: int | None = None,
|
|
1689
|
+
reason: str | None = None,
|
|
1690
|
+
) -> int:
|
|
1691
|
+
if isinstance(docs, (list, tuple)) and not docs:
|
|
1692
|
+
return 0
|
|
1693
|
+
text = self.to_insert(docs, if_absent=if_absent, require=require, reason=reason)
|
|
1694
|
+
return _affected(await self._client().query(*text))
|
|
1695
|
+
|
|
1696
|
+
async def insert_new(
|
|
1697
|
+
self,
|
|
1698
|
+
docs: Mapping | Sequence[Mapping],
|
|
1699
|
+
*,
|
|
1700
|
+
require: int | None = None,
|
|
1701
|
+
reason: str | None = None,
|
|
1702
|
+
returning: Any = None,
|
|
1703
|
+
) -> Any:
|
|
1704
|
+
if isinstance(docs, (list, tuple)) and not docs:
|
|
1705
|
+
return _written({}, returning)
|
|
1706
|
+
text = self.to_insert_new(docs, require=require, reason=reason, returning=returning)
|
|
1707
|
+
return _written(await self._client().query(*text), returning)
|
|
1708
|
+
|
|
1709
|
+
async def upsert(
|
|
1710
|
+
self,
|
|
1711
|
+
docs: Mapping | Sequence[Mapping],
|
|
1712
|
+
patch: Mapping,
|
|
1713
|
+
*,
|
|
1714
|
+
require: int | None = None,
|
|
1715
|
+
reason: str | None = None,
|
|
1716
|
+
) -> int:
|
|
1717
|
+
if isinstance(docs, (list, tuple)) and not docs:
|
|
1718
|
+
return 0
|
|
1719
|
+
text = self.to_upsert(docs, patch, require=require, reason=reason)
|
|
1720
|
+
return _affected(await self._client().query(*text))
|
|
1721
|
+
|
|
1722
|
+
async def update(
|
|
1723
|
+
self,
|
|
1724
|
+
patch: Mapping,
|
|
1725
|
+
*,
|
|
1726
|
+
all: bool = False,
|
|
1727
|
+
require: int | None = None,
|
|
1728
|
+
reason: str | None = None,
|
|
1729
|
+
returning: Any = None,
|
|
1730
|
+
wait: float | None = None,
|
|
1731
|
+
) -> Any:
|
|
1732
|
+
text = self.to_update(patch, all=all, require=require, reason=reason, returning=returning)
|
|
1733
|
+
return _written(await self._client().query(*text, **_held(wait)), returning)
|
|
1734
|
+
|
|
1735
|
+
async def delete(
|
|
1736
|
+
self,
|
|
1737
|
+
*,
|
|
1738
|
+
all: bool = False,
|
|
1739
|
+
require: int | None = None,
|
|
1740
|
+
reason: str | None = None,
|
|
1741
|
+
returning: Any = None,
|
|
1742
|
+
wait: float | None = None,
|
|
1743
|
+
) -> Any:
|
|
1744
|
+
text = self.to_delete(all=all, require=require, reason=reason, returning=returning)
|
|
1745
|
+
return _written(await self._client().query(*text, **_held(wait)), returning)
|
|
1746
|
+
|
|
1747
|
+
|
|
1748
|
+
def _held(wait: float | None) -> dict:
|
|
1749
|
+
"""`wait` for the client's `query`, where it was given one: a client
|
|
1750
|
+
that holds nothing takes the statement alone."""
|
|
1751
|
+
return {} if wait is None else {"wait": wait}
|
|
1752
|
+
|
|
1753
|
+
|
|
1754
|
+
# ` require n` for a write's `require=n`: the rows it must write, a whole
|
|
1755
|
+
# number from 0 written in -- FennecQL takes no parameter there: the count is
|
|
1756
|
+
# what the statement guards, a constant of the code, where a page's `limit`
|
|
1757
|
+
# and `offset` change with each request and go as parameters. A bool is an
|
|
1758
|
+
# int to Python and is refused, as JavaScript's builder refuses `true`.
|
|
1759
|
+
def _require_clause(n: Any, reason: Any = None) -> str:
|
|
1760
|
+
if n is None:
|
|
1761
|
+
if reason is not None:
|
|
1762
|
+
raise _err("a reason says why a require refused, and goes with one")
|
|
1763
|
+
return ""
|
|
1764
|
+
if isinstance(n, bool) or not isinstance(n, int) or n < 0:
|
|
1765
|
+
raise _err(f"require takes a count of rows, a whole number from 0 (got {n})")
|
|
1766
|
+
return f" require {n}{_reason_clause(reason)}"
|
|
1767
|
+
|
|
1768
|
+
|
|
1769
|
+
# ` else '<reason>'` for a require's `reason`: written into the text, a
|
|
1770
|
+
# quote doubled and a backslash escaped, as FennecQL takes it -- never a
|
|
1771
|
+
# parameter, as the count is not: it is the statement's own words, by which
|
|
1772
|
+
# a caller tells one refusal from another. 1 to 200 bytes of UTF-8, as the
|
|
1773
|
+
# parser holds it: bytes, not characters, so "ş" counts 2; a lone surrogate
|
|
1774
|
+
# is no text UTF-8 can send, refused as one rather than a UnicodeEncodeError.
|
|
1775
|
+
# No backtick: the refusal quotes its statement with them, and the reason is
|
|
1776
|
+
# read back as its words up to the first "(`".
|
|
1777
|
+
def _reason_clause(reason: Any) -> str:
|
|
1778
|
+
if reason is None:
|
|
1779
|
+
return ""
|
|
1780
|
+
try:
|
|
1781
|
+
size = len(reason.encode("utf-8")) if isinstance(reason, str) else 0
|
|
1782
|
+
except UnicodeEncodeError:
|
|
1783
|
+
size = 0
|
|
1784
|
+
if not 1 <= size <= 200:
|
|
1785
|
+
raise _err("a require's reason is a text of 1 to 200 bytes")
|
|
1786
|
+
if "`" in reason:
|
|
1787
|
+
raise _err("a require's reason holds no backtick")
|
|
1788
|
+
return " else '" + reason.replace("\\", "\\\\").replace("'", "''") + "'"
|
|
1789
|
+
|
|
1790
|
+
|
|
1791
|
+
def collection(name: str) -> Query:
|
|
1792
|
+
"""A query bound to no client: for its text alone, `to_fennecql()`."""
|
|
1793
|
+
return Query(name)
|
|
1794
|
+
|
|
1795
|
+
|
|
1796
|
+
# A named statement's name: ASCII letters, digits and `_`, starting with a
|
|
1797
|
+
# letter, at most 59 bytes -- the engine's rule (`named::valid_name`), a path
|
|
1798
|
+
# segment with nothing to escape. The name is the one thing of a call written
|
|
1799
|
+
# into its text, so this is its injection boundary. `fullmatch`, not `$`,
|
|
1800
|
+
# which also matches before a trailing newline.
|
|
1801
|
+
_STATEMENT = re.compile(r"[A-Za-z][A-Za-z0-9_]{0,58}")
|
|
1802
|
+
|
|
1803
|
+
|
|
1804
|
+
def _given(v: Any, what: str) -> Mapping | None:
|
|
1805
|
+
"""A call's arguments or claims: an object of values by name, or none."""
|
|
1806
|
+
if v is None:
|
|
1807
|
+
return None
|
|
1808
|
+
if not isinstance(v, Mapping):
|
|
1809
|
+
raise _err(f"a call's {what} are an object of values by name")
|
|
1810
|
+
return v
|
|
1811
|
+
|
|
1812
|
+
|
|
1813
|
+
class Call:
|
|
1814
|
+
"""A call of a named statement: `call <name> $1`, its arguments the one
|
|
1815
|
+
parameter -- a mapping, by name -- and `claims=` after them `as $2`, the
|
|
1816
|
+
claims the server's token makes the call with (a scoped token's own are
|
|
1817
|
+
its token's, and it may not say them). Bound to no collection or client,
|
|
1818
|
+
so `call(...)` stands beside `collection(...)`:
|
|
1819
|
+
|
|
1820
|
+
call("transfer", {"from": 1, "to": 2, "amount": 300}).to_call()
|
|
1821
|
+
# ("call transfer $1", [{"from": 1, "to": 2, "amount": 300}])
|
|
1822
|
+
|
|
1823
|
+
`db.call(name, args)` runs it. No arguments is a statement of none --
|
|
1824
|
+
`call stamp` -- and an empty mapping is still one argument. The values
|
|
1825
|
+
are parameters as a query's are -- a `datetime` its ISO text, an
|
|
1826
|
+
`array.array` its numbers -- and never the text. Immutable."""
|
|
1827
|
+
|
|
1828
|
+
__slots__ = ("_name", "_args", "_claims")
|
|
1829
|
+
|
|
1830
|
+
def __init__(self, name: str, args: Mapping | None = None, *, claims: Mapping | None = None) -> None:
|
|
1831
|
+
if not isinstance(name, str) or not _STATEMENT.fullmatch(name):
|
|
1832
|
+
raise _err(f"invalid statement name: {_quote(name)}")
|
|
1833
|
+
object.__setattr__(self, "_name", name)
|
|
1834
|
+
object.__setattr__(self, "_args", _given(args, "arguments"))
|
|
1835
|
+
object.__setattr__(self, "_claims", _given(claims, "claims"))
|
|
1836
|
+
|
|
1837
|
+
def __setattr__(self, key: str, value: Any) -> None:
|
|
1838
|
+
raise AttributeError("a Call is immutable")
|
|
1839
|
+
|
|
1840
|
+
def __delattr__(self, key: str) -> None:
|
|
1841
|
+
raise AttributeError("a Call is immutable")
|
|
1842
|
+
|
|
1843
|
+
def __repr__(self) -> str:
|
|
1844
|
+
return f"Call({self._name!r})"
|
|
1845
|
+
|
|
1846
|
+
@property
|
|
1847
|
+
def name(self) -> str:
|
|
1848
|
+
"""The statement's name."""
|
|
1849
|
+
return self._name
|
|
1850
|
+
|
|
1851
|
+
def to_call(self) -> tuple[str, list]:
|
|
1852
|
+
"""The call's FennecQL and its parameters, `(text, params)`: what
|
|
1853
|
+
`Client.query` and a `batch` take."""
|
|
1854
|
+
bind = _Binder()
|
|
1855
|
+
sql = f"call {self._name}"
|
|
1856
|
+
if self._args is not None:
|
|
1857
|
+
sql += f" {bind(self._args, 'argument')}"
|
|
1858
|
+
if self._claims is not None:
|
|
1859
|
+
sql += f" as {bind(self._claims, 'claim')}"
|
|
1860
|
+
return sql, bind.params
|
|
1861
|
+
|
|
1862
|
+
|
|
1863
|
+
def call(name: str, args: Mapping | None = None, *, claims: Mapping | None = None) -> Call:
|
|
1864
|
+
"""A call of the named statement `name`, for its text alone,
|
|
1865
|
+
`to_call()`; `Client.call` sends one."""
|
|
1866
|
+
return Call(name, args, claims=claims)
|
|
1867
|
+
|