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/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
+