nitrodb 2.4.3__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.
- nedb/__init__.py +92 -0
- nedb/autoindex.py +142 -0
- nedb/backends/__init__.py +0 -0
- nedb/backends/redis_backend.py +115 -0
- nedb/cascade.py +130 -0
- nedb/concurrent.py +218 -0
- nedb/crypto.py +294 -0
- nedb/engine.py +783 -0
- nedb/index.py +98 -0
- nedb/log.py +216 -0
- nedb/merkle.py +62 -0
- nedb/mongo.py +824 -0
- nedb/proof.py +126 -0
- nedb/query.py +305 -0
- nedb/redis_compat.py +516 -0
- nedb/relations.py +51 -0
- nedb/resp2.py +250 -0
- nedb/server.py +1011 -0
- nedb/snapshot.py +216 -0
- nedb/sql.py +430 -0
- nedb/store.py +68 -0
- nedb/wrap_redis.py +725 -0
- nitrodb-2.4.3.dist-info/METADATA +64 -0
- nitrodb-2.4.3.dist-info/RECORD +27 -0
- nitrodb-2.4.3.dist-info/WHEEL +4 -0
- nitrodb-2.4.3.dist-info/entry_points.txt +2 -0
- nitrodb-2.4.3.dist-info/licenses/LICENSE +65 -0
nedb/proof.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
nedb.proof — Merkle inclusion proofs for the hash-chained op log.
|
|
3
|
+
|
|
4
|
+
Every NEDB write produces an Op whose ``hash`` field is the chain head after that op
|
|
5
|
+
(``op.hash = blake2b(prev_head.utf8 + canon(body))`` — see ``nedb.log``). The chain
|
|
6
|
+
head therefore commits cryptographically to the entire write history.
|
|
7
|
+
|
|
8
|
+
This module derives a *parallel*, compact Merkle commitment chain over the same
|
|
9
|
+
op-hash sequence so a client can verify — offline, with no server round-trip and
|
|
10
|
+
without re-running the engine — that a specific op-hash was at a specific seq in
|
|
11
|
+
the log, and that the log's head followed from chaining all subsequent op-hashes
|
|
12
|
+
on top of it.
|
|
13
|
+
|
|
14
|
+
The chain step used by both ``verify_proof`` (client) and the server endpoint is::
|
|
15
|
+
|
|
16
|
+
head_next = blake2b(bytes.fromhex(head_prev) || bytes.fromhex(op_hash)).hexdigest()
|
|
17
|
+
|
|
18
|
+
starting from the genesis head (the all-zero 32-byte string). Folding over the
|
|
19
|
+
full ordered list of op-hashes yields a deterministic head; tamper with any op
|
|
20
|
+
hash (or reorder ops) and the fold no longer matches. The Op's own ``hash`` field
|
|
21
|
+
already commits to its body (payload, seq, nonce, ts, …) under the engine's chain
|
|
22
|
+
formula, so this fold is a complete commitment to the write history through the
|
|
23
|
+
op-hashes alone — exactly the property a Merkle inclusion proof needs.
|
|
24
|
+
|
|
25
|
+
The fold's terminal value (``proof["head"]``) is a *derived* head, distinct from
|
|
26
|
+
``db.head`` (the engine's UTF-8-hex chain) but a deterministic function of the
|
|
27
|
+
same log. The server computes the derived head over the live log when issuing
|
|
28
|
+
each proof, so any divergence between client fold and server fold means the
|
|
29
|
+
proof was tampered with in transit.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import hashlib
|
|
34
|
+
from typing import Dict, List
|
|
35
|
+
|
|
36
|
+
# Genesis head matches the engine: all-zero 32-byte string in hex.
|
|
37
|
+
GENESIS = "0" * 64
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _blake(data: bytes) -> str:
|
|
41
|
+
"""BLAKE2b-256 over raw bytes, returned as hex — same primitive the engine uses."""
|
|
42
|
+
return hashlib.blake2b(data, digest_size=32).hexdigest()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _chain_step(head_hex: str, op_hash_hex: str) -> str:
|
|
46
|
+
"""One Merkle-chain step on raw bytes (decoded from hex).
|
|
47
|
+
|
|
48
|
+
Mirrors the user-facing spec exactly:
|
|
49
|
+
new_head = blake2b(prev_head_bytes || op_hash_bytes)
|
|
50
|
+
"""
|
|
51
|
+
return _blake(bytes.fromhex(head_hex) + bytes.fromhex(op_hash_hex))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def fold_head(op_hashes: List[str], start: str = GENESIS) -> str:
|
|
55
|
+
"""Fold an ordered list of op-hashes into a Merkle-chain head.
|
|
56
|
+
|
|
57
|
+
Used by the server to produce ``proof["head"]`` for any given log state and
|
|
58
|
+
by tests to sanity-check that the fold is order-sensitive and deterministic.
|
|
59
|
+
"""
|
|
60
|
+
head = start
|
|
61
|
+
for h in op_hashes:
|
|
62
|
+
head = _chain_step(head, h)
|
|
63
|
+
return head
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def verify_proof(proof: Dict) -> bool:
|
|
67
|
+
"""Verify a Merkle inclusion proof client-side — no server, no engine needed.
|
|
68
|
+
|
|
69
|
+
``proof`` shape::
|
|
70
|
+
|
|
71
|
+
{
|
|
72
|
+
"hash": "<64-hex>", # the document op's content hash (op.hash)
|
|
73
|
+
"seq": N, # sequence number of this op in the log
|
|
74
|
+
"prev_head": "<64-hex>", # chain head BEFORE this op (= op.prev_hash)
|
|
75
|
+
"subsequent": ["<64-hex>"], # op-hashes of every op AFTER this one
|
|
76
|
+
"head": "<64-hex>", # claimed final (derived) chain head
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
Verification (matches the spec):
|
|
80
|
+
1. ``head_at_seq = blake2b(prev_head_bytes || hash_bytes)``
|
|
81
|
+
2. For each ``sub_hash`` in subsequent:
|
|
82
|
+
``head = blake2b(head_bytes || sub_hash_bytes)``
|
|
83
|
+
3. ``final head == proof["head"]``
|
|
84
|
+
|
|
85
|
+
Returns True iff every step is internally consistent. Tampering with any
|
|
86
|
+
field — flipping a byte of ``hash``, reordering ``subsequent``, swapping
|
|
87
|
+
``prev_head``, etc. — will flip the fold and make this return False.
|
|
88
|
+
"""
|
|
89
|
+
if not isinstance(proof, dict):
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
# Required keys with the right shapes.
|
|
93
|
+
required = ("hash", "seq", "prev_head", "subsequent", "head")
|
|
94
|
+
if not all(k in proof for k in required):
|
|
95
|
+
return False
|
|
96
|
+
if not isinstance(proof["seq"], int) or proof["seq"] < 0:
|
|
97
|
+
return False
|
|
98
|
+
if not isinstance(proof["subsequent"], list):
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
# Hex shape check on the 64-char fields. Anything malformed → not verified.
|
|
102
|
+
def _is_hex32(s: object) -> bool:
|
|
103
|
+
if not isinstance(s, str) or len(s) != 64:
|
|
104
|
+
return False
|
|
105
|
+
try:
|
|
106
|
+
bytes.fromhex(s)
|
|
107
|
+
except ValueError:
|
|
108
|
+
return False
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
if not _is_hex32(proof["hash"]): return False
|
|
112
|
+
if not _is_hex32(proof["prev_head"]): return False
|
|
113
|
+
if not _is_hex32(proof["head"]): return False
|
|
114
|
+
for s in proof["subsequent"]:
|
|
115
|
+
if not _is_hex32(s):
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
# 1. Re-derive the chain head AT this op's seq from prev_head + this op's hash.
|
|
119
|
+
head = _chain_step(proof["prev_head"], proof["hash"])
|
|
120
|
+
|
|
121
|
+
# 2. Fold each subsequent op-hash on top to advance the chain to the tail.
|
|
122
|
+
for sub in proof["subsequent"]:
|
|
123
|
+
head = _chain_step(head, sub)
|
|
124
|
+
|
|
125
|
+
# 3. The fold must equal the claimed final head bit-for-bit.
|
|
126
|
+
return head == proof["head"]
|
nedb/query.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""
|
|
2
|
+
nedb.query — NQL (the NEDB Query Language) parser + the fluent query builder.
|
|
3
|
+
|
|
4
|
+
Both the text form and the fluent builder compile to the SAME plan dict, so the two
|
|
5
|
+
front-ends share identical semantics. In the production engine the parser lives once
|
|
6
|
+
in Rust; Python and Node get the exact same grammar for free.
|
|
7
|
+
|
|
8
|
+
NQL grammar (keywords case-insensitive):
|
|
9
|
+
|
|
10
|
+
FROM <collection>
|
|
11
|
+
[ AS OF <seq> ]
|
|
12
|
+
[ WHERE <field> <op> <value> (AND <field> <op> <value>)* ]
|
|
13
|
+
[ SEARCH "<text>" ]
|
|
14
|
+
[ ORDER BY <field> [ASC|DESC] ]
|
|
15
|
+
[ TRAVERSE <relation> ]
|
|
16
|
+
[ LIMIT <n> ]
|
|
17
|
+
|
|
18
|
+
op := = | != | < | <= | > | >=
|
|
19
|
+
value := number | "string" | 'string' | true | false | null
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from typing import Any, List, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
_TOKEN_RE = re.compile(
|
|
27
|
+
r"""\s+
|
|
28
|
+
| "(?P<dq>[^"]*)"
|
|
29
|
+
| '(?P<sq>[^']*)'
|
|
30
|
+
| (?P<num>-?\d+(?:\.\d+)?)
|
|
31
|
+
| (?P<op><=|>=|!=|=|<|>)
|
|
32
|
+
| (?P<word>[A-Za-z_][A-Za-z0-9_]*)
|
|
33
|
+
""",
|
|
34
|
+
re.VERBOSE,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
_KEYWORDS = {"from", "as", "of", "where", "and", "search", "order", "by",
|
|
38
|
+
"asc", "desc", "traverse", "trace", "reverse", "limit",
|
|
39
|
+
"valid", "true", "false", "null", "group", "count", "sum", "avg", "min", "max"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _lex(text: str) -> List[Tuple[str, Any]]:
|
|
43
|
+
toks: List[Tuple[str, Any]] = []
|
|
44
|
+
pos = 0
|
|
45
|
+
while pos < len(text):
|
|
46
|
+
m = _TOKEN_RE.match(text, pos)
|
|
47
|
+
if not m:
|
|
48
|
+
raise SyntaxError(f"NQL: cannot tokenize near: {text[pos:pos+20]!r}")
|
|
49
|
+
pos = m.end()
|
|
50
|
+
if m.group("dq") is not None:
|
|
51
|
+
toks.append(("str", m.group("dq")))
|
|
52
|
+
elif m.group("sq") is not None:
|
|
53
|
+
toks.append(("str", m.group("sq")))
|
|
54
|
+
elif m.group("num") is not None:
|
|
55
|
+
n = m.group("num")
|
|
56
|
+
toks.append(("num", float(n) if "." in n else int(n)))
|
|
57
|
+
elif m.group("op") is not None:
|
|
58
|
+
toks.append(("op", m.group("op")))
|
|
59
|
+
elif m.group("word") is not None:
|
|
60
|
+
w = m.group("word")
|
|
61
|
+
lw = w.lower()
|
|
62
|
+
toks.append(("kw", lw) if lw in _KEYWORDS else ("word", w))
|
|
63
|
+
# whitespace -> skip
|
|
64
|
+
return toks
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def empty_plan(coll: str) -> dict:
|
|
68
|
+
return {"from": coll, "as_of": None, "where": [], "search": None,
|
|
69
|
+
"order_by": None, "traverse": None, "limit": None,
|
|
70
|
+
"group_by": None, "aggregate": None,
|
|
71
|
+
"trace": None, "trace_reverse": False,
|
|
72
|
+
"valid_as_of": None}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_nql(text: str) -> dict:
|
|
76
|
+
toks = _lex(text)
|
|
77
|
+
i = 0
|
|
78
|
+
|
|
79
|
+
def peek():
|
|
80
|
+
return toks[i] if i < len(toks) else (None, None)
|
|
81
|
+
|
|
82
|
+
def expect_kw(kw):
|
|
83
|
+
nonlocal i
|
|
84
|
+
t, v = peek()
|
|
85
|
+
if t != "kw" or v != kw:
|
|
86
|
+
raise SyntaxError(f"NQL: expected '{kw.upper()}', got {v!r}")
|
|
87
|
+
i += 1
|
|
88
|
+
|
|
89
|
+
def value():
|
|
90
|
+
nonlocal i
|
|
91
|
+
t, v = peek()
|
|
92
|
+
if t in ("num", "str"):
|
|
93
|
+
i += 1
|
|
94
|
+
return v
|
|
95
|
+
if t == "kw" and v in ("true", "false", "null"):
|
|
96
|
+
i += 1
|
|
97
|
+
return {"true": True, "false": False, "null": None}[v]
|
|
98
|
+
if t == "word":
|
|
99
|
+
i += 1
|
|
100
|
+
return v
|
|
101
|
+
raise SyntaxError(f"NQL: expected value, got {v!r}")
|
|
102
|
+
|
|
103
|
+
expect_kw("from")
|
|
104
|
+
t, v = peek()
|
|
105
|
+
if t not in ("word", "kw"):
|
|
106
|
+
raise SyntaxError("NQL: expected collection after FROM")
|
|
107
|
+
i += 1
|
|
108
|
+
plan = empty_plan(v)
|
|
109
|
+
|
|
110
|
+
# AS OF <seq>
|
|
111
|
+
if peek() == ("kw", "as"):
|
|
112
|
+
i += 1
|
|
113
|
+
expect_kw("of")
|
|
114
|
+
t, v = peek()
|
|
115
|
+
if t != "num":
|
|
116
|
+
raise SyntaxError("NQL: AS OF expects an integer seq")
|
|
117
|
+
i += 1
|
|
118
|
+
plan["as_of"] = int(v)
|
|
119
|
+
|
|
120
|
+
# VALID AS OF <date> — bi-temporal valid-time filter
|
|
121
|
+
# Syntax: VALID AS OF "2024-02-15" (ISO 8601 date or datetime string)
|
|
122
|
+
# Can appear before or after WHERE; "valid" is the disambiguating keyword.
|
|
123
|
+
if peek() == ("kw", "valid"):
|
|
124
|
+
i += 1
|
|
125
|
+
# expect AS OF
|
|
126
|
+
t, v = peek()
|
|
127
|
+
if t == "kw" and v == "as":
|
|
128
|
+
i += 1
|
|
129
|
+
t, v = peek()
|
|
130
|
+
if t == "kw" and v == "of":
|
|
131
|
+
i += 1
|
|
132
|
+
t, v = peek()
|
|
133
|
+
if t != "str":
|
|
134
|
+
raise SyntaxError("NQL: VALID AS OF expects a quoted date string")
|
|
135
|
+
i += 1
|
|
136
|
+
plan["valid_as_of"] = v
|
|
137
|
+
|
|
138
|
+
# WHERE ... AND ...
|
|
139
|
+
if peek() == ("kw", "where"):
|
|
140
|
+
i += 1
|
|
141
|
+
while True:
|
|
142
|
+
t, field = peek()
|
|
143
|
+
if t not in ("word", "kw"):
|
|
144
|
+
raise SyntaxError("NQL: expected field in WHERE")
|
|
145
|
+
i += 1
|
|
146
|
+
t, op = peek()
|
|
147
|
+
if t != "op":
|
|
148
|
+
raise SyntaxError("NQL: expected operator in WHERE")
|
|
149
|
+
i += 1
|
|
150
|
+
plan["where"].append((field, op, value()))
|
|
151
|
+
if peek() == ("kw", "and"):
|
|
152
|
+
i += 1
|
|
153
|
+
continue
|
|
154
|
+
break
|
|
155
|
+
|
|
156
|
+
# VALID AS OF <date> — also accepted after WHERE (second position)
|
|
157
|
+
if peek() == ("kw", "valid") and plan["valid_as_of"] is None:
|
|
158
|
+
i += 1
|
|
159
|
+
for kw in ("as", "of"):
|
|
160
|
+
if peek()[1] == kw: i += 1
|
|
161
|
+
t, v = peek()
|
|
162
|
+
if t != "str":
|
|
163
|
+
raise SyntaxError("NQL: VALID AS OF expects a quoted date string")
|
|
164
|
+
i += 1
|
|
165
|
+
plan["valid_as_of"] = v
|
|
166
|
+
|
|
167
|
+
# SEARCH "text"
|
|
168
|
+
if peek() == ("kw", "search"):
|
|
169
|
+
i += 1
|
|
170
|
+
t, v = peek()
|
|
171
|
+
if t != "str":
|
|
172
|
+
raise SyntaxError("NQL: SEARCH expects a quoted string")
|
|
173
|
+
i += 1
|
|
174
|
+
plan["search"] = v
|
|
175
|
+
|
|
176
|
+
# ORDER BY field [ASC|DESC]
|
|
177
|
+
if peek() == ("kw", "order"):
|
|
178
|
+
i += 1
|
|
179
|
+
expect_kw("by")
|
|
180
|
+
t, field = peek()
|
|
181
|
+
if t not in ("word", "kw"):
|
|
182
|
+
raise SyntaxError("NQL: expected field after ORDER BY")
|
|
183
|
+
i += 1
|
|
184
|
+
direction = "ASC"
|
|
185
|
+
if peek() == ("kw", "asc"):
|
|
186
|
+
i += 1
|
|
187
|
+
elif peek() == ("kw", "desc"):
|
|
188
|
+
i += 1
|
|
189
|
+
direction = "DESC"
|
|
190
|
+
plan["order_by"] = (field, direction)
|
|
191
|
+
|
|
192
|
+
# TRAVERSE relation
|
|
193
|
+
if peek() == ("kw", "traverse"):
|
|
194
|
+
i += 1
|
|
195
|
+
t, rel = peek()
|
|
196
|
+
if t not in ("word", "kw"):
|
|
197
|
+
raise SyntaxError("NQL: expected relation after TRAVERSE")
|
|
198
|
+
i += 1
|
|
199
|
+
plan["traverse"] = rel
|
|
200
|
+
|
|
201
|
+
# TRACE <field> [REVERSE]
|
|
202
|
+
# Walks the causal provenance graph.
|
|
203
|
+
# TRACE caused_by → backward: which ops caused these documents?
|
|
204
|
+
# TRACE caused_by REVERSE → forward: which documents did these ops cause?
|
|
205
|
+
if peek() == ("kw", "trace"):
|
|
206
|
+
i += 1
|
|
207
|
+
t, tf = peek()
|
|
208
|
+
if t not in ("word", "kw"):
|
|
209
|
+
raise SyntaxError("NQL: expected field name after TRACE")
|
|
210
|
+
i += 1
|
|
211
|
+
plan["trace"] = tf
|
|
212
|
+
if peek() == ("kw", "reverse"):
|
|
213
|
+
i += 1
|
|
214
|
+
plan["trace_reverse"] = True
|
|
215
|
+
|
|
216
|
+
# LIMIT n
|
|
217
|
+
if peek() == ("kw", "limit"):
|
|
218
|
+
i += 1
|
|
219
|
+
t, v = peek()
|
|
220
|
+
if t != "num":
|
|
221
|
+
raise SyntaxError("NQL: LIMIT expects an integer")
|
|
222
|
+
i += 1
|
|
223
|
+
plan["limit"] = int(v)
|
|
224
|
+
|
|
225
|
+
# GROUP BY field [COUNT | SUM field | AVG field | MIN field | MAX field]
|
|
226
|
+
if peek() == ("kw", "group"):
|
|
227
|
+
i += 1
|
|
228
|
+
expect_kw("by")
|
|
229
|
+
t, field = peek()
|
|
230
|
+
if t not in ("word", "kw"):
|
|
231
|
+
raise SyntaxError("NQL: expected field after GROUP BY")
|
|
232
|
+
i += 1
|
|
233
|
+
plan["group_by"] = field
|
|
234
|
+
# optional aggregate after GROUP BY
|
|
235
|
+
t, agg = peek()
|
|
236
|
+
if t == "kw" and agg in ("count", "sum", "avg", "min", "max"):
|
|
237
|
+
i += 1
|
|
238
|
+
if agg == "count":
|
|
239
|
+
plan["aggregate"] = ("count", None)
|
|
240
|
+
else:
|
|
241
|
+
t2, agg_field = peek()
|
|
242
|
+
if t2 not in ("word", "kw"):
|
|
243
|
+
raise SyntaxError(f"NQL: {agg.upper()} expects a field name")
|
|
244
|
+
i += 1
|
|
245
|
+
plan["aggregate"] = (agg, agg_field)
|
|
246
|
+
|
|
247
|
+
if i != len(toks):
|
|
248
|
+
raise SyntaxError(f"NQL: unexpected trailing tokens: {toks[i:]}")
|
|
249
|
+
return plan
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def cmp(a, op, b) -> bool:
|
|
253
|
+
try:
|
|
254
|
+
if op == "=":
|
|
255
|
+
return a == b
|
|
256
|
+
if op == "!=":
|
|
257
|
+
return a != b
|
|
258
|
+
if a is None:
|
|
259
|
+
return False
|
|
260
|
+
if op == "<":
|
|
261
|
+
return a < b
|
|
262
|
+
if op == "<=":
|
|
263
|
+
return a <= b
|
|
264
|
+
if op == ">":
|
|
265
|
+
return a > b
|
|
266
|
+
if op == ">=":
|
|
267
|
+
return a >= b
|
|
268
|
+
except TypeError:
|
|
269
|
+
return False
|
|
270
|
+
return False
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class Query:
|
|
274
|
+
"""Fluent builder that compiles to the same plan dict as NQL text."""
|
|
275
|
+
|
|
276
|
+
def __init__(self, engine, coll: str) -> None:
|
|
277
|
+
self._engine = engine
|
|
278
|
+
self.plan = empty_plan(coll)
|
|
279
|
+
|
|
280
|
+
def as_of(self, seq: int) -> "Query":
|
|
281
|
+
self.plan["as_of"] = seq
|
|
282
|
+
return self
|
|
283
|
+
|
|
284
|
+
def where(self, field: str, op: str, value: Any) -> "Query":
|
|
285
|
+
self.plan["where"].append((field, op, value))
|
|
286
|
+
return self
|
|
287
|
+
|
|
288
|
+
def search(self, text: str) -> "Query":
|
|
289
|
+
self.plan["search"] = text
|
|
290
|
+
return self
|
|
291
|
+
|
|
292
|
+
def order_by(self, field: str, desc: bool = False) -> "Query":
|
|
293
|
+
self.plan["order_by"] = (field, "DESC" if desc else "ASC")
|
|
294
|
+
return self
|
|
295
|
+
|
|
296
|
+
def traverse(self, rel: str) -> "Query":
|
|
297
|
+
self.plan["traverse"] = rel
|
|
298
|
+
return self
|
|
299
|
+
|
|
300
|
+
def limit(self, n: int) -> "Query":
|
|
301
|
+
self.plan["limit"] = n
|
|
302
|
+
return self
|
|
303
|
+
|
|
304
|
+
def run(self) -> List[dict]:
|
|
305
|
+
return self._engine.execute(self.plan)
|