nedb-engine 3.2.2__tar.gz → 3.3.1__tar.gz
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_engine-3.2.2 → nedb_engine-3.3.1}/PKG-INFO +254 -4
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/README.md +253 -3
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/pyproject.toml +1 -1
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/__init__.py +1 -1
- nedb_engine-3.3.1/python/nedb/_native.abi3.so +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/client.py +50 -4
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/engine.py +147 -31
- nedb_engine-3.3.1/python/nedb/nedbd-v2 +0 -0
- nedb_engine-3.3.1/python/nedb/nedbd-v2-darwin-arm64 +0 -0
- nedb_engine-3.3.1/python/nedb/nedbd-v2-darwin-x64 +0 -0
- nedb_engine-3.3.1/python/nedb/nedbd-v2.exe +0 -0
- nedb_engine-3.3.1/python/nedb/query.py +615 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/server.py +41 -3
- nedb_engine-3.2.2/python/nedb/_native.abi3.so +0 -0
- nedb_engine-3.2.2/python/nedb/nedbd-v2 +0 -0
- nedb_engine-3.2.2/python/nedb/nedbd-v2-darwin-arm64 +0 -0
- nedb_engine-3.2.2/python/nedb/nedbd-v2-darwin-x64 +0 -0
- nedb_engine-3.2.2/python/nedb/nedbd-v2.exe +0 -0
- nedb_engine-3.2.2/python/nedb/query.py +0 -305
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/.gitignore +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/LICENSE +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/client/node/README.md +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/client/python/README.md +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/examples/agent-loop/README.md +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/examples/mini-chain/README.md +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/autoindex.py +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/backends/__init__.py +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/backends/dag.py +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/backends/redis_backend.py +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/cascade.py +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/concurrent.py +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/crypto.py +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/index.py +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/log.py +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/merkle.py +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/mongo.py +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/proof.py +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/redis_compat.py +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/relations.py +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/resp2.py +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/snapshot.py +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/sql.py +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/store.py +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/wrap_core.py +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/wrap_mongo.py +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/wrap_mysql.py +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/wrap_postgresql.py +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/wrap_redis.py +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/wrap_sqlite.py +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/rust/crates/nedb-wrap/README.md +0 -0
- {nedb_engine-3.2.2 → nedb_engine-3.3.1}/rust/nedb-v2/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: nedb-engine
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.3.1
|
|
4
4
|
Summary: NEDB — a versioned, self-compressing, time-traveling embedded database (replay-protected, idempotent, relational, searchable) with durable AOF persistence and a server daemon (nedbd).
|
|
5
5
|
Project-URL: Homepage, https://github.com/aiassistsecure/nedb
|
|
6
6
|
Project-URL: Repository, https://github.com/aiassistsecure/nedb
|
|
@@ -47,6 +47,78 @@ One Rust core → ships to **PyPI** and **npm** from a single source.
|
|
|
47
47
|
|
|
48
48
|
---
|
|
49
49
|
|
|
50
|
+
## New in 3.3.0 — the query language grew up
|
|
51
|
+
|
|
52
|
+
`WHERE` was six operators wide (`= != > < >= <=`) joined by an implicit `AND`.
|
|
53
|
+
It now takes a full boolean expression, in **both** engines, and the clauses
|
|
54
|
+
around it run in SQL's order.
|
|
55
|
+
|
|
56
|
+
```sql
|
|
57
|
+
FROM jobs
|
|
58
|
+
WHERE (status IN ("open", "pending") OR fee > 100)
|
|
59
|
+
AND miner IS NOT NULL
|
|
60
|
+
AND NOT (region LIKE "eu-%")
|
|
61
|
+
GROUP BY region SUM fee
|
|
62
|
+
HAVING sum_fee > 10000
|
|
63
|
+
ORDER BY sum_fee DESC
|
|
64
|
+
LIMIT 20 OFFSET 40
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
| added | |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| `IN (…)` / `NOT IN (…)` | set membership |
|
|
70
|
+
| `BETWEEN a AND b` / `NOT BETWEEN` | inclusive both ends, as in SQL |
|
|
71
|
+
| `LIKE` / `NOT LIKE` / `ILIKE` | `%` any run, `_` any one char |
|
|
72
|
+
| `IS NULL` / `IS NOT NULL` | matches absent **and** explicitly-null |
|
|
73
|
+
| `AND` / `OR` / `NOT` / `(…)` | `AND` binds tighter; parens nest to any depth |
|
|
74
|
+
| `OFFSET n` | pagination; pairs with `LIMIT` |
|
|
75
|
+
| `ORDER BY a, b DESC` | multi-key, per-key direction |
|
|
76
|
+
| `HAVING <predicate>` | filters the aggregated rows |
|
|
77
|
+
| `COUNT` / `SUM f` / `AVG f` / `MIN f` / `MAX f` | whole-result aggregate, one row |
|
|
78
|
+
|
|
79
|
+
**Indexed range scans.** `=`, `IN`, `BETWEEN` and the inequalities are served
|
|
80
|
+
from a sorted index when one covers the field — a point lookup on 20,000 rows
|
|
81
|
+
goes from 137 ms to 0.01 ms, a 1%-selective `BETWEEN` from 186 ms to 1.1 ms.
|
|
82
|
+
See [**Indexes**](#indexes) for the measured table and the three cases that
|
|
83
|
+
deliberately decline the index.
|
|
84
|
+
|
|
85
|
+
**Nine silent defects fixed.** None of them crashed; they all returned a
|
|
86
|
+
confident wrong answer with HTTP 200. The worst:
|
|
87
|
+
|
|
88
|
+
- `GROUP BY status MAX fee` aggregated the *group* field, not the target —
|
|
89
|
+
answering `1.0` where the real maxima were 40 and 30.
|
|
90
|
+
- `LIMIT 2 GROUP BY status COUNT` truncated the aggregate's **input**, so
|
|
91
|
+
twelve rows across three statuses reported counts summing to 2.
|
|
92
|
+
- `ORDER BY count DESC` on grouped rows sorted the raw documents on a field
|
|
93
|
+
that only exists after grouping — silently inert.
|
|
94
|
+
- `FROM jobs OFFSET 2` and a misspelled `ORDRE BY fee` were silently **dropped**
|
|
95
|
+
and a different query answered. Unknown clauses are now a parse error.
|
|
96
|
+
- `SUM` ran through `f64`, losing integer precision above 2^53 — a real problem
|
|
97
|
+
for satoshi amounts and block heights. Integer inputs now stay in `i64`.
|
|
98
|
+
- An ordering comparison against a missing field was true in the Rust engine
|
|
99
|
+
(`WHERE fee < 5` returned rows with no `fee` at all) and false in the Python
|
|
100
|
+
reference. Now false in both, matching SQL.
|
|
101
|
+
- A field named like a keyword (`count`, `min`, `value`, `status`) was
|
|
102
|
+
unaddressable, because both lexers canonicalised case at field positions.
|
|
103
|
+
|
|
104
|
+
**Compatibility.** Verified by building a `nedbd` from the released v3.2.2 tag
|
|
105
|
+
and diffing every answer: **40 of 45 legacy queries byte-identical, zero
|
|
106
|
+
regressions.** The differences are the three bug fixes above, each documented
|
|
107
|
+
in `tests/test_backcompat.py`. `scripts/compare_engine_answers.py` reproduces
|
|
108
|
+
the comparison against any released binary.
|
|
109
|
+
|
|
110
|
+
**The DAG is untouched.** `tests/test_dag_preserved.py` runs the entire new
|
|
111
|
+
query surface against a live chain and asserts head, seq and `verify()` are
|
|
112
|
+
unchanged afterwards — and that `verify()` still returns *false* when the log
|
|
113
|
+
is tampered with. Reads are reads.
|
|
114
|
+
|
|
115
|
+
**Cross-engine parity is now gated.** Nothing previously checked that the
|
|
116
|
+
Python reference and the Rust core agreed, which is how they had drifted apart
|
|
117
|
+
in five places. Two suites now run the same battery through both and assert
|
|
118
|
+
identical answers.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
50
122
|
## New in 3.2.0 — wrap the databases you already run
|
|
51
123
|
|
|
52
124
|
NEDB adds **tamper-evident causal provenance to a database you already have**, in one line, without
|
|
@@ -292,6 +364,12 @@ db.query('FROM users WHERE status = "active" ORDER BY age ASC')
|
|
|
292
364
|
db.query('FROM users SEARCH "rust"')
|
|
293
365
|
db.query('FROM users GROUP BY status COUNT')
|
|
294
366
|
|
|
367
|
+
# Full boolean predicates — IN, BETWEEN, LIKE, IS NULL, OR, NOT, parentheses
|
|
368
|
+
db.query('FROM users WHERE status IN ("active", "trialing")')
|
|
369
|
+
db.query('FROM users WHERE age BETWEEN 25 AND 40')
|
|
370
|
+
db.query('FROM users WHERE bio LIKE "%rust%" AND NOT (status = "retired")')
|
|
371
|
+
db.query('FROM users WHERE (age < 25 OR age > 60) AND bio IS NOT NULL')
|
|
372
|
+
|
|
295
373
|
# Time-travel — AS OF any past sequence
|
|
296
374
|
snap = db.seq
|
|
297
375
|
db.put("users", "alice", {"name": "Alice", "age": 32, "status": "retired"})
|
|
@@ -516,13 +594,185 @@ redis-cli -p 6380 SELECT shop EVAL 'FROM beliefs TRACE caused_by' 0
|
|
|
516
594
|
FROM <collection>
|
|
517
595
|
[ AS OF <seq> ] transaction time (when was it written?)
|
|
518
596
|
[ VALID AS OF "<date>" ] valid time (when was it true in the world?)
|
|
519
|
-
[ WHERE <
|
|
597
|
+
[ WHERE <predicate> ] full boolean predicate, see below
|
|
520
598
|
[ SEARCH "<text>" ] full-text search
|
|
521
|
-
[ ORDER BY <field> [ASC|DESC] ]
|
|
522
599
|
[ TRAVERSE <relation> ] graph traversal
|
|
523
600
|
[ TRACE caused_by [REVERSE] ] causal provenance (why? / what did this cause?)
|
|
524
|
-
[ LIMIT <n> ]
|
|
525
601
|
[ GROUP BY <field> [COUNT|SUM f|AVG f|MIN f|MAX f] ]
|
|
602
|
+
[ COUNT | SUM f | AVG f | MIN f | MAX f ] whole-result aggregate, one row
|
|
603
|
+
[ HAVING <predicate> ] filters the AGGREGATED rows
|
|
604
|
+
[ ORDER BY <field> [ASC|DESC] (, ...) ]
|
|
605
|
+
[ LIMIT <n> ] [ OFFSET <n> ]
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
Clauses are evaluated in SQL's order, whatever order you write them in:
|
|
609
|
+
|
|
610
|
+
```
|
|
611
|
+
FROM → WHERE → GROUP BY → HAVING → ORDER BY → OFFSET → LIMIT
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
That matters, and before 3.3.0 it was wrong. `LIMIT` truncated the *input* to
|
|
615
|
+
an aggregate rather than the result, so `LIMIT 5 GROUP BY status COUNT` over
|
|
616
|
+
twelve rows reported counts summing to 5 — it said only five rows existed when
|
|
617
|
+
twelve did. `ORDER BY` ran before grouping, so sorting on `count` or `sum_fee`
|
|
618
|
+
silently did nothing. `VALID AS OF` was applied after `LIMIT` in the Python
|
|
619
|
+
engine, so a limited bi-temporal query returned fewer valid rows than exist.
|
|
620
|
+
|
|
621
|
+
### Predicates
|
|
622
|
+
|
|
623
|
+
`WHERE` takes a full boolean expression. `AND` binds tighter than `OR`;
|
|
624
|
+
parentheses override, and nest to any depth.
|
|
625
|
+
|
|
626
|
+
```
|
|
627
|
+
<predicate> := <or>
|
|
628
|
+
<or> := <and> [OR <and>]*
|
|
629
|
+
<and> := <not> [AND <not>]*
|
|
630
|
+
<not> := [NOT] <primary>
|
|
631
|
+
<primary> := "(" <predicate> ")" | <comparison>
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
| Comparison | Example |
|
|
635
|
+
| --- | --- |
|
|
636
|
+
| `= != < <= > >=` | `WHERE height > 600000` |
|
|
637
|
+
| `IN (…)` / `NOT IN (…)` | `WHERE status IN ("open", "pending")` |
|
|
638
|
+
| `BETWEEN a AND b` | `WHERE height BETWEEN 100 AND 200` — inclusive, as in SQL |
|
|
639
|
+
| `NOT BETWEEN a AND b` | `WHERE fee NOT BETWEEN 10 AND 20` |
|
|
640
|
+
| `LIKE` / `NOT LIKE` | `WHERE miner LIKE "Acme%"` — `%` any run, `_` any one char |
|
|
641
|
+
| `ILIKE` | `WHERE miner ILIKE "acme%"` — case-insensitive |
|
|
642
|
+
| `IS NULL` / `IS NOT NULL` | `WHERE miner IS NULL` — matches absent **and** explicitly-null |
|
|
643
|
+
|
|
644
|
+
```python
|
|
645
|
+
db.query('''FROM jobs
|
|
646
|
+
WHERE (status IN ("open", "pending") OR fee > 100)
|
|
647
|
+
AND miner IS NOT NULL
|
|
648
|
+
AND NOT (region LIKE "eu-%")
|
|
649
|
+
ORDER BY fee DESC LIMIT 20''')
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
Filterable metadata fields: `_id`, `_coll`, `_hash`, `_seq`.
|
|
653
|
+
|
|
654
|
+
`_id = "x"` is an O(1) index lookup rather than a scan — but only when it is a
|
|
655
|
+
genuine conjunct. Under an `OR` it cannot constrain the result set, so the
|
|
656
|
+
planner correctly declines the fast path there.
|
|
657
|
+
|
|
658
|
+
### Indexes
|
|
659
|
+
|
|
660
|
+
`=`, `IN (...)`, `BETWEEN` and the one-sided inequalities are served from a
|
|
661
|
+
sorted index when one covers the field, turning a full collection scan into a
|
|
662
|
+
bounded range walk:
|
|
663
|
+
|
|
664
|
+
```python
|
|
665
|
+
db.create_index("blocks", "height", "sorted")
|
|
666
|
+
db.query("FROM blocks WHERE height BETWEEN 600000 AND 600100")
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
Measured on 20,000 rows with `scripts/bench_index_range.py` — two identical
|
|
670
|
+
databases, one indexed, one not:
|
|
671
|
+
|
|
672
|
+
| Query | Scan | Indexed | Speedup |
|
|
673
|
+
| --- | --- | --- | --- |
|
|
674
|
+
| `WHERE fee = 10000` | 137 ms | 0.01 ms | 17,000× |
|
|
675
|
+
| `WHERE fee IN (a, b, c)` | 185 ms | 0.02 ms | 9,700× |
|
|
676
|
+
| `WHERE fee BETWEEN …` (1% of rows) | 186 ms | 1.1 ms | 170× |
|
|
677
|
+
| `WHERE fee BETWEEN …` (10% of rows) | 188 ms | 13 ms | 14× |
|
|
678
|
+
| unindexed field (control) | 188 ms | 187 ms | 1.0× |
|
|
679
|
+
|
|
680
|
+
The planner asks the index how many rows each candidate range covers and takes
|
|
681
|
+
the narrowest, so `WHERE region = "eu" AND height BETWEEN 600000 AND 600001`
|
|
682
|
+
uses the height index rather than whichever field it saw first. Same-field
|
|
683
|
+
bounds are merged, so `height > 100 AND height < 200` is one walk.
|
|
684
|
+
|
|
685
|
+
An index is only ever used to NARROW candidates — the full predicate is
|
|
686
|
+
re-evaluated on whatever comes back, so the answer never depends on whether an
|
|
687
|
+
index exists. Three cases deliberately decline it:
|
|
688
|
+
|
|
689
|
+
- **`IS NULL`.** A document whose field is absent is not in that field's
|
|
690
|
+
index, so an index scan would return the exact complement of the answer.
|
|
691
|
+
- **Anything under `OR` or `NOT`.** A disjunct does not constrain the result
|
|
692
|
+
set; narrowing on one arm would silently drop the rows the other arm matched.
|
|
693
|
+
- **`AS OF`.** The sorted index holds current versions only (a superseded hash
|
|
694
|
+
is dropped on overwrite), so it cannot answer a historical query.
|
|
695
|
+
|
|
696
|
+
> **Predicates over NULL follow SQL's three-valued logic.** An ordering
|
|
697
|
+
> comparison (`<` `<=` `>` `>=`, and therefore `BETWEEN`) against a missing or
|
|
698
|
+
> null field is never true — `WHERE fee < 5` will not return a row that has no
|
|
699
|
+
> `fee` at all. `LIKE` is false in *both* polarities, so a null row appears in
|
|
700
|
+
> neither `LIKE` nor `NOT LIKE`. `=` and `!=` do operate on null, so
|
|
701
|
+
> `WHERE fee = NULL` selects rows where the field is absent or null, and
|
|
702
|
+
> `WHERE fee != 5` includes them. Use `IS NULL` / `IS NOT NULL` to test
|
|
703
|
+
> presence explicitly.
|
|
704
|
+
|
|
705
|
+
### Unknown clauses are errors
|
|
706
|
+
|
|
707
|
+
A query containing a clause the engine does not implement is **rejected**, not
|
|
708
|
+
silently reinterpreted. Before 3.3.0 the parser skipped tokens it did not
|
|
709
|
+
recognise, so `FROM jobs OFFSET 2` returned un-offset rows with HTTP 200 and a
|
|
710
|
+
misspelled `ORDRE BY fee` returned unsorted rows — the engine answered a
|
|
711
|
+
*different query* than the one asked, and said nothing. Both now return
|
|
712
|
+
HTTP 400 with the offending token.
|
|
713
|
+
|
|
714
|
+
### Aggregates
|
|
715
|
+
|
|
716
|
+
`SUM`/`AVG`/`MIN`/`MAX` take the field to aggregate; `COUNT` (the default when
|
|
717
|
+
no aggregate is given) takes none.
|
|
718
|
+
|
|
719
|
+
```python
|
|
720
|
+
db.query('FROM items GROUP BY cat MAX price')
|
|
721
|
+
# → [{"cat": "x", "count": 3, "max_price": 10.0, "value": 10.0}, …]
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
`count` is the group size. The aggregate only considers rows whose target field
|
|
725
|
+
is numeric, so a group of 5 where 2 carry a numeric `price` reports `count: 5`
|
|
726
|
+
and averages over 2. An aggregate with no numeric input is `null`, never `0`.
|
|
727
|
+
The `value` key is a back-compatible alias for the aggregate result.
|
|
728
|
+
|
|
729
|
+
Integer inputs give integer results — `SUM`/`MIN`/`MAX` stay in 64-bit
|
|
730
|
+
integers rather than passing through a float, so a sum over satoshi amounts or
|
|
731
|
+
block heights above 2^53 is exact. `AVG` is always fractional.
|
|
732
|
+
|
|
733
|
+
Groups come back sorted by key unless you say otherwise, so results are stable
|
|
734
|
+
run to run and identical across engines.
|
|
735
|
+
|
|
736
|
+
Drop the `GROUP BY` for a whole-result aggregate, which returns exactly one row:
|
|
737
|
+
|
|
738
|
+
```python
|
|
739
|
+
db.query('FROM orders COUNT') # → [{"count": 1049, "value": 1049}]
|
|
740
|
+
db.query('FROM orders WHERE status = "paid" COUNT')
|
|
741
|
+
db.query('FROM orders SUM total') # → [{"count": 1049, "sum_total": 88123, …}]
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
`COUNT` of an empty result is one row holding `0` — a caller asking "how many?"
|
|
745
|
+
always gets a number. `SUM` of an empty result is `null`.
|
|
746
|
+
|
|
747
|
+
### HAVING
|
|
748
|
+
|
|
749
|
+
`WHERE` filters rows before they are grouped; `HAVING` filters the groups.
|
|
750
|
+
|
|
751
|
+
```python
|
|
752
|
+
db.query('FROM orders GROUP BY region SUM total HAVING sum_total > 10000')
|
|
753
|
+
db.query('FROM orders GROUP BY region COUNT HAVING count BETWEEN 5 AND 50')
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
`HAVING` runs through the same evaluator as `WHERE`, so it gets the whole
|
|
757
|
+
predicate surface — `IN`, `BETWEEN`, `LIKE`, `OR`, `NOT`, parentheses — rather
|
|
758
|
+
than a poorer second copy.
|
|
759
|
+
|
|
760
|
+
### Sorting and paging
|
|
761
|
+
|
|
762
|
+
```python
|
|
763
|
+
db.query('FROM orders ORDER BY region, total DESC') # ties broken by the next key
|
|
764
|
+
db.query('FROM orders ORDER BY total DESC LIMIT 20 OFFSET 40')
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
`OFFSET` skips rows of the result and pairs with `LIMIT` for pagination. An
|
|
768
|
+
offset past the end is an empty page, not an error.
|
|
769
|
+
|
|
770
|
+
A field whose name collides with a reserved word is still addressable — a
|
|
771
|
+
document may legitimately have a `count`, `min`, `value` or `status` field, and
|
|
772
|
+
`WHERE count > 3` reads that field rather than the aggregate:
|
|
773
|
+
|
|
774
|
+
```python
|
|
775
|
+
db.query('FROM metrics WHERE count > 3 ORDER BY count DESC')
|
|
526
776
|
```
|
|
527
777
|
|
|
528
778
|
Combine both time axes:
|
|
@@ -26,6 +26,78 @@ One Rust core → ships to **PyPI** and **npm** from a single source.
|
|
|
26
26
|
|
|
27
27
|
---
|
|
28
28
|
|
|
29
|
+
## New in 3.3.0 — the query language grew up
|
|
30
|
+
|
|
31
|
+
`WHERE` was six operators wide (`= != > < >= <=`) joined by an implicit `AND`.
|
|
32
|
+
It now takes a full boolean expression, in **both** engines, and the clauses
|
|
33
|
+
around it run in SQL's order.
|
|
34
|
+
|
|
35
|
+
```sql
|
|
36
|
+
FROM jobs
|
|
37
|
+
WHERE (status IN ("open", "pending") OR fee > 100)
|
|
38
|
+
AND miner IS NOT NULL
|
|
39
|
+
AND NOT (region LIKE "eu-%")
|
|
40
|
+
GROUP BY region SUM fee
|
|
41
|
+
HAVING sum_fee > 10000
|
|
42
|
+
ORDER BY sum_fee DESC
|
|
43
|
+
LIMIT 20 OFFSET 40
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
| added | |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `IN (…)` / `NOT IN (…)` | set membership |
|
|
49
|
+
| `BETWEEN a AND b` / `NOT BETWEEN` | inclusive both ends, as in SQL |
|
|
50
|
+
| `LIKE` / `NOT LIKE` / `ILIKE` | `%` any run, `_` any one char |
|
|
51
|
+
| `IS NULL` / `IS NOT NULL` | matches absent **and** explicitly-null |
|
|
52
|
+
| `AND` / `OR` / `NOT` / `(…)` | `AND` binds tighter; parens nest to any depth |
|
|
53
|
+
| `OFFSET n` | pagination; pairs with `LIMIT` |
|
|
54
|
+
| `ORDER BY a, b DESC` | multi-key, per-key direction |
|
|
55
|
+
| `HAVING <predicate>` | filters the aggregated rows |
|
|
56
|
+
| `COUNT` / `SUM f` / `AVG f` / `MIN f` / `MAX f` | whole-result aggregate, one row |
|
|
57
|
+
|
|
58
|
+
**Indexed range scans.** `=`, `IN`, `BETWEEN` and the inequalities are served
|
|
59
|
+
from a sorted index when one covers the field — a point lookup on 20,000 rows
|
|
60
|
+
goes from 137 ms to 0.01 ms, a 1%-selective `BETWEEN` from 186 ms to 1.1 ms.
|
|
61
|
+
See [**Indexes**](#indexes) for the measured table and the three cases that
|
|
62
|
+
deliberately decline the index.
|
|
63
|
+
|
|
64
|
+
**Nine silent defects fixed.** None of them crashed; they all returned a
|
|
65
|
+
confident wrong answer with HTTP 200. The worst:
|
|
66
|
+
|
|
67
|
+
- `GROUP BY status MAX fee` aggregated the *group* field, not the target —
|
|
68
|
+
answering `1.0` where the real maxima were 40 and 30.
|
|
69
|
+
- `LIMIT 2 GROUP BY status COUNT` truncated the aggregate's **input**, so
|
|
70
|
+
twelve rows across three statuses reported counts summing to 2.
|
|
71
|
+
- `ORDER BY count DESC` on grouped rows sorted the raw documents on a field
|
|
72
|
+
that only exists after grouping — silently inert.
|
|
73
|
+
- `FROM jobs OFFSET 2` and a misspelled `ORDRE BY fee` were silently **dropped**
|
|
74
|
+
and a different query answered. Unknown clauses are now a parse error.
|
|
75
|
+
- `SUM` ran through `f64`, losing integer precision above 2^53 — a real problem
|
|
76
|
+
for satoshi amounts and block heights. Integer inputs now stay in `i64`.
|
|
77
|
+
- An ordering comparison against a missing field was true in the Rust engine
|
|
78
|
+
(`WHERE fee < 5` returned rows with no `fee` at all) and false in the Python
|
|
79
|
+
reference. Now false in both, matching SQL.
|
|
80
|
+
- A field named like a keyword (`count`, `min`, `value`, `status`) was
|
|
81
|
+
unaddressable, because both lexers canonicalised case at field positions.
|
|
82
|
+
|
|
83
|
+
**Compatibility.** Verified by building a `nedbd` from the released v3.2.2 tag
|
|
84
|
+
and diffing every answer: **40 of 45 legacy queries byte-identical, zero
|
|
85
|
+
regressions.** The differences are the three bug fixes above, each documented
|
|
86
|
+
in `tests/test_backcompat.py`. `scripts/compare_engine_answers.py` reproduces
|
|
87
|
+
the comparison against any released binary.
|
|
88
|
+
|
|
89
|
+
**The DAG is untouched.** `tests/test_dag_preserved.py` runs the entire new
|
|
90
|
+
query surface against a live chain and asserts head, seq and `verify()` are
|
|
91
|
+
unchanged afterwards — and that `verify()` still returns *false* when the log
|
|
92
|
+
is tampered with. Reads are reads.
|
|
93
|
+
|
|
94
|
+
**Cross-engine parity is now gated.** Nothing previously checked that the
|
|
95
|
+
Python reference and the Rust core agreed, which is how they had drifted apart
|
|
96
|
+
in five places. Two suites now run the same battery through both and assert
|
|
97
|
+
identical answers.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
29
101
|
## New in 3.2.0 — wrap the databases you already run
|
|
30
102
|
|
|
31
103
|
NEDB adds **tamper-evident causal provenance to a database you already have**, in one line, without
|
|
@@ -271,6 +343,12 @@ db.query('FROM users WHERE status = "active" ORDER BY age ASC')
|
|
|
271
343
|
db.query('FROM users SEARCH "rust"')
|
|
272
344
|
db.query('FROM users GROUP BY status COUNT')
|
|
273
345
|
|
|
346
|
+
# Full boolean predicates — IN, BETWEEN, LIKE, IS NULL, OR, NOT, parentheses
|
|
347
|
+
db.query('FROM users WHERE status IN ("active", "trialing")')
|
|
348
|
+
db.query('FROM users WHERE age BETWEEN 25 AND 40')
|
|
349
|
+
db.query('FROM users WHERE bio LIKE "%rust%" AND NOT (status = "retired")')
|
|
350
|
+
db.query('FROM users WHERE (age < 25 OR age > 60) AND bio IS NOT NULL')
|
|
351
|
+
|
|
274
352
|
# Time-travel — AS OF any past sequence
|
|
275
353
|
snap = db.seq
|
|
276
354
|
db.put("users", "alice", {"name": "Alice", "age": 32, "status": "retired"})
|
|
@@ -495,13 +573,185 @@ redis-cli -p 6380 SELECT shop EVAL 'FROM beliefs TRACE caused_by' 0
|
|
|
495
573
|
FROM <collection>
|
|
496
574
|
[ AS OF <seq> ] transaction time (when was it written?)
|
|
497
575
|
[ VALID AS OF "<date>" ] valid time (when was it true in the world?)
|
|
498
|
-
[ WHERE <
|
|
576
|
+
[ WHERE <predicate> ] full boolean predicate, see below
|
|
499
577
|
[ SEARCH "<text>" ] full-text search
|
|
500
|
-
[ ORDER BY <field> [ASC|DESC] ]
|
|
501
578
|
[ TRAVERSE <relation> ] graph traversal
|
|
502
579
|
[ TRACE caused_by [REVERSE] ] causal provenance (why? / what did this cause?)
|
|
503
|
-
[ LIMIT <n> ]
|
|
504
580
|
[ GROUP BY <field> [COUNT|SUM f|AVG f|MIN f|MAX f] ]
|
|
581
|
+
[ COUNT | SUM f | AVG f | MIN f | MAX f ] whole-result aggregate, one row
|
|
582
|
+
[ HAVING <predicate> ] filters the AGGREGATED rows
|
|
583
|
+
[ ORDER BY <field> [ASC|DESC] (, ...) ]
|
|
584
|
+
[ LIMIT <n> ] [ OFFSET <n> ]
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
Clauses are evaluated in SQL's order, whatever order you write them in:
|
|
588
|
+
|
|
589
|
+
```
|
|
590
|
+
FROM → WHERE → GROUP BY → HAVING → ORDER BY → OFFSET → LIMIT
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
That matters, and before 3.3.0 it was wrong. `LIMIT` truncated the *input* to
|
|
594
|
+
an aggregate rather than the result, so `LIMIT 5 GROUP BY status COUNT` over
|
|
595
|
+
twelve rows reported counts summing to 5 — it said only five rows existed when
|
|
596
|
+
twelve did. `ORDER BY` ran before grouping, so sorting on `count` or `sum_fee`
|
|
597
|
+
silently did nothing. `VALID AS OF` was applied after `LIMIT` in the Python
|
|
598
|
+
engine, so a limited bi-temporal query returned fewer valid rows than exist.
|
|
599
|
+
|
|
600
|
+
### Predicates
|
|
601
|
+
|
|
602
|
+
`WHERE` takes a full boolean expression. `AND` binds tighter than `OR`;
|
|
603
|
+
parentheses override, and nest to any depth.
|
|
604
|
+
|
|
605
|
+
```
|
|
606
|
+
<predicate> := <or>
|
|
607
|
+
<or> := <and> [OR <and>]*
|
|
608
|
+
<and> := <not> [AND <not>]*
|
|
609
|
+
<not> := [NOT] <primary>
|
|
610
|
+
<primary> := "(" <predicate> ")" | <comparison>
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
| Comparison | Example |
|
|
614
|
+
| --- | --- |
|
|
615
|
+
| `= != < <= > >=` | `WHERE height > 600000` |
|
|
616
|
+
| `IN (…)` / `NOT IN (…)` | `WHERE status IN ("open", "pending")` |
|
|
617
|
+
| `BETWEEN a AND b` | `WHERE height BETWEEN 100 AND 200` — inclusive, as in SQL |
|
|
618
|
+
| `NOT BETWEEN a AND b` | `WHERE fee NOT BETWEEN 10 AND 20` |
|
|
619
|
+
| `LIKE` / `NOT LIKE` | `WHERE miner LIKE "Acme%"` — `%` any run, `_` any one char |
|
|
620
|
+
| `ILIKE` | `WHERE miner ILIKE "acme%"` — case-insensitive |
|
|
621
|
+
| `IS NULL` / `IS NOT NULL` | `WHERE miner IS NULL` — matches absent **and** explicitly-null |
|
|
622
|
+
|
|
623
|
+
```python
|
|
624
|
+
db.query('''FROM jobs
|
|
625
|
+
WHERE (status IN ("open", "pending") OR fee > 100)
|
|
626
|
+
AND miner IS NOT NULL
|
|
627
|
+
AND NOT (region LIKE "eu-%")
|
|
628
|
+
ORDER BY fee DESC LIMIT 20''')
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
Filterable metadata fields: `_id`, `_coll`, `_hash`, `_seq`.
|
|
632
|
+
|
|
633
|
+
`_id = "x"` is an O(1) index lookup rather than a scan — but only when it is a
|
|
634
|
+
genuine conjunct. Under an `OR` it cannot constrain the result set, so the
|
|
635
|
+
planner correctly declines the fast path there.
|
|
636
|
+
|
|
637
|
+
### Indexes
|
|
638
|
+
|
|
639
|
+
`=`, `IN (...)`, `BETWEEN` and the one-sided inequalities are served from a
|
|
640
|
+
sorted index when one covers the field, turning a full collection scan into a
|
|
641
|
+
bounded range walk:
|
|
642
|
+
|
|
643
|
+
```python
|
|
644
|
+
db.create_index("blocks", "height", "sorted")
|
|
645
|
+
db.query("FROM blocks WHERE height BETWEEN 600000 AND 600100")
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
Measured on 20,000 rows with `scripts/bench_index_range.py` — two identical
|
|
649
|
+
databases, one indexed, one not:
|
|
650
|
+
|
|
651
|
+
| Query | Scan | Indexed | Speedup |
|
|
652
|
+
| --- | --- | --- | --- |
|
|
653
|
+
| `WHERE fee = 10000` | 137 ms | 0.01 ms | 17,000× |
|
|
654
|
+
| `WHERE fee IN (a, b, c)` | 185 ms | 0.02 ms | 9,700× |
|
|
655
|
+
| `WHERE fee BETWEEN …` (1% of rows) | 186 ms | 1.1 ms | 170× |
|
|
656
|
+
| `WHERE fee BETWEEN …` (10% of rows) | 188 ms | 13 ms | 14× |
|
|
657
|
+
| unindexed field (control) | 188 ms | 187 ms | 1.0× |
|
|
658
|
+
|
|
659
|
+
The planner asks the index how many rows each candidate range covers and takes
|
|
660
|
+
the narrowest, so `WHERE region = "eu" AND height BETWEEN 600000 AND 600001`
|
|
661
|
+
uses the height index rather than whichever field it saw first. Same-field
|
|
662
|
+
bounds are merged, so `height > 100 AND height < 200` is one walk.
|
|
663
|
+
|
|
664
|
+
An index is only ever used to NARROW candidates — the full predicate is
|
|
665
|
+
re-evaluated on whatever comes back, so the answer never depends on whether an
|
|
666
|
+
index exists. Three cases deliberately decline it:
|
|
667
|
+
|
|
668
|
+
- **`IS NULL`.** A document whose field is absent is not in that field's
|
|
669
|
+
index, so an index scan would return the exact complement of the answer.
|
|
670
|
+
- **Anything under `OR` or `NOT`.** A disjunct does not constrain the result
|
|
671
|
+
set; narrowing on one arm would silently drop the rows the other arm matched.
|
|
672
|
+
- **`AS OF`.** The sorted index holds current versions only (a superseded hash
|
|
673
|
+
is dropped on overwrite), so it cannot answer a historical query.
|
|
674
|
+
|
|
675
|
+
> **Predicates over NULL follow SQL's three-valued logic.** An ordering
|
|
676
|
+
> comparison (`<` `<=` `>` `>=`, and therefore `BETWEEN`) against a missing or
|
|
677
|
+
> null field is never true — `WHERE fee < 5` will not return a row that has no
|
|
678
|
+
> `fee` at all. `LIKE` is false in *both* polarities, so a null row appears in
|
|
679
|
+
> neither `LIKE` nor `NOT LIKE`. `=` and `!=` do operate on null, so
|
|
680
|
+
> `WHERE fee = NULL` selects rows where the field is absent or null, and
|
|
681
|
+
> `WHERE fee != 5` includes them. Use `IS NULL` / `IS NOT NULL` to test
|
|
682
|
+
> presence explicitly.
|
|
683
|
+
|
|
684
|
+
### Unknown clauses are errors
|
|
685
|
+
|
|
686
|
+
A query containing a clause the engine does not implement is **rejected**, not
|
|
687
|
+
silently reinterpreted. Before 3.3.0 the parser skipped tokens it did not
|
|
688
|
+
recognise, so `FROM jobs OFFSET 2` returned un-offset rows with HTTP 200 and a
|
|
689
|
+
misspelled `ORDRE BY fee` returned unsorted rows — the engine answered a
|
|
690
|
+
*different query* than the one asked, and said nothing. Both now return
|
|
691
|
+
HTTP 400 with the offending token.
|
|
692
|
+
|
|
693
|
+
### Aggregates
|
|
694
|
+
|
|
695
|
+
`SUM`/`AVG`/`MIN`/`MAX` take the field to aggregate; `COUNT` (the default when
|
|
696
|
+
no aggregate is given) takes none.
|
|
697
|
+
|
|
698
|
+
```python
|
|
699
|
+
db.query('FROM items GROUP BY cat MAX price')
|
|
700
|
+
# → [{"cat": "x", "count": 3, "max_price": 10.0, "value": 10.0}, …]
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
`count` is the group size. The aggregate only considers rows whose target field
|
|
704
|
+
is numeric, so a group of 5 where 2 carry a numeric `price` reports `count: 5`
|
|
705
|
+
and averages over 2. An aggregate with no numeric input is `null`, never `0`.
|
|
706
|
+
The `value` key is a back-compatible alias for the aggregate result.
|
|
707
|
+
|
|
708
|
+
Integer inputs give integer results — `SUM`/`MIN`/`MAX` stay in 64-bit
|
|
709
|
+
integers rather than passing through a float, so a sum over satoshi amounts or
|
|
710
|
+
block heights above 2^53 is exact. `AVG` is always fractional.
|
|
711
|
+
|
|
712
|
+
Groups come back sorted by key unless you say otherwise, so results are stable
|
|
713
|
+
run to run and identical across engines.
|
|
714
|
+
|
|
715
|
+
Drop the `GROUP BY` for a whole-result aggregate, which returns exactly one row:
|
|
716
|
+
|
|
717
|
+
```python
|
|
718
|
+
db.query('FROM orders COUNT') # → [{"count": 1049, "value": 1049}]
|
|
719
|
+
db.query('FROM orders WHERE status = "paid" COUNT')
|
|
720
|
+
db.query('FROM orders SUM total') # → [{"count": 1049, "sum_total": 88123, …}]
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
`COUNT` of an empty result is one row holding `0` — a caller asking "how many?"
|
|
724
|
+
always gets a number. `SUM` of an empty result is `null`.
|
|
725
|
+
|
|
726
|
+
### HAVING
|
|
727
|
+
|
|
728
|
+
`WHERE` filters rows before they are grouped; `HAVING` filters the groups.
|
|
729
|
+
|
|
730
|
+
```python
|
|
731
|
+
db.query('FROM orders GROUP BY region SUM total HAVING sum_total > 10000')
|
|
732
|
+
db.query('FROM orders GROUP BY region COUNT HAVING count BETWEEN 5 AND 50')
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
`HAVING` runs through the same evaluator as `WHERE`, so it gets the whole
|
|
736
|
+
predicate surface — `IN`, `BETWEEN`, `LIKE`, `OR`, `NOT`, parentheses — rather
|
|
737
|
+
than a poorer second copy.
|
|
738
|
+
|
|
739
|
+
### Sorting and paging
|
|
740
|
+
|
|
741
|
+
```python
|
|
742
|
+
db.query('FROM orders ORDER BY region, total DESC') # ties broken by the next key
|
|
743
|
+
db.query('FROM orders ORDER BY total DESC LIMIT 20 OFFSET 40')
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
`OFFSET` skips rows of the result and pairs with `LIMIT` for pagination. An
|
|
747
|
+
offset past the end is an empty page, not an error.
|
|
748
|
+
|
|
749
|
+
A field whose name collides with a reserved word is still addressable — a
|
|
750
|
+
document may legitimately have a `count`, `min`, `value` or `status` field, and
|
|
751
|
+
`WHERE count > 3` reads that field rather than the aggregate:
|
|
752
|
+
|
|
753
|
+
```python
|
|
754
|
+
db.query('FROM metrics WHERE count > 3 ORDER BY count DESC')
|
|
505
755
|
```
|
|
506
756
|
|
|
507
757
|
Combine both time axes:
|
|
@@ -10,7 +10,7 @@ build-backend = "hatchling.build"
|
|
|
10
10
|
|
|
11
11
|
[project]
|
|
12
12
|
name = "nedb-engine"
|
|
13
|
-
version = "3.
|
|
13
|
+
version = "3.3.1"
|
|
14
14
|
description = "NEDB — a versioned, self-compressing, time-traveling embedded database (replay-protected, idempotent, relational, searchable) with durable AOF persistence and a server daemon (nedbd)."
|
|
15
15
|
readme = "README.md"
|
|
16
16
|
requires-python = ">=3.8"
|
|
Binary file
|
|
@@ -53,6 +53,7 @@ import json
|
|
|
53
53
|
import os
|
|
54
54
|
import time
|
|
55
55
|
import urllib.error
|
|
56
|
+
import urllib.parse
|
|
56
57
|
import urllib.request
|
|
57
58
|
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, TypeVar
|
|
58
59
|
|
|
@@ -75,6 +76,16 @@ CAS_BACKOFF_CAP_S = 0.2 # … capped here
|
|
|
75
76
|
|
|
76
77
|
# ── errors ───────────────────────────────────────────────────────────────────
|
|
77
78
|
|
|
79
|
+
def _seg(value: str) -> str:
|
|
80
|
+
"""Percent-encode one URL path segment.
|
|
81
|
+
|
|
82
|
+
`/` MUST be encoded (hence ``safe=""``): an id containing a slash used to
|
|
83
|
+
split the path so the route matched a different id, and ``delete_doc``
|
|
84
|
+
returned "did not exist" for a document that did.
|
|
85
|
+
"""
|
|
86
|
+
return urllib.parse.quote(str(value), safe="")
|
|
87
|
+
|
|
88
|
+
|
|
78
89
|
class NedbError(RuntimeError):
|
|
79
90
|
"""Base error for daemon interactions. ``status`` is the HTTP status
|
|
80
91
|
(``None`` for transport failures); ``payload`` is the parsed error body."""
|
|
@@ -283,12 +294,47 @@ class NedbClient:
|
|
|
283
294
|
"""Run NQL, return the full envelope ``{rows, count, seq, head}``."""
|
|
284
295
|
return self.request("POST", self._dbp("query"), {"nql": nql})
|
|
285
296
|
|
|
286
|
-
def get_doc(self, coll: str, doc_id: str
|
|
297
|
+
def get_doc(self, coll: str, doc_id: str,
|
|
298
|
+
as_of: Optional[int] = None) -> Optional[dict]:
|
|
287
299
|
"""Fetch one doc by id (returns ``None`` when absent). Docs carry
|
|
288
|
-
``_id`` and ``_seq`` — feed ``_seq`` to ``op_put(if_seq=…)``.
|
|
300
|
+
``_id`` and ``_seq`` — feed ``_seq`` to ``op_put(if_seq=…)``.
|
|
301
|
+
|
|
302
|
+
With ``as_of``, returns the version at or before that sequence number.
|
|
303
|
+
|
|
304
|
+
Uses ``GET …/rows/{coll}/{id}``, which takes the id from the URL path.
|
|
305
|
+
This used to build ``FROM coll WHERE _id = "…"`` and interpolate the id
|
|
306
|
+
into it — so an id containing a double quote was REJECTED outright
|
|
307
|
+
(``unquotable identifier``) even though ``put`` accepts it, ``FROM
|
|
308
|
+
coll`` returns it and the engine stores it fine. The guard was there
|
|
309
|
+
because the interpolation was unsafe; taking the id from the path
|
|
310
|
+
removes the interpolation, so the id no longer needs guarding and
|
|
311
|
+
legitimate ids with quotes now work.
|
|
312
|
+
|
|
313
|
+
Injection is impossible by construction here: nothing is concatenated
|
|
314
|
+
into a query, so a crafted id can only ever name a document.
|
|
315
|
+
|
|
316
|
+
Falls back to the query path against a server predating the route
|
|
317
|
+
(nedb-engine < 3.3.0 answers 405 there, having registered only DELETE),
|
|
318
|
+
where the historical ``unquotable identifier`` guard still applies
|
|
319
|
+
because the interpolation is back.
|
|
320
|
+
"""
|
|
321
|
+
path = self._dbp(f"rows/{_seg(coll)}/{_seg(doc_id)}")
|
|
322
|
+
if as_of is not None:
|
|
323
|
+
path = f"{path}?as_of={int(as_of)}"
|
|
324
|
+
try:
|
|
325
|
+
# The route answers 200 with `row: null` for a missing document,
|
|
326
|
+
# precisely so this cannot be confused with "no such route".
|
|
327
|
+
return self.request("GET", path).get("row")
|
|
328
|
+
except NedbError as e:
|
|
329
|
+
# 404/405 => the server predates this route (the Rust daemon
|
|
330
|
+
# registered only DELETE on this path; the Python AOF server had
|
|
331
|
+
# no such path at all). Anything else is a real error.
|
|
332
|
+
if getattr(e, "status", None) not in (404, 405):
|
|
333
|
+
raise
|
|
289
334
|
if '"' in coll or '"' in str(doc_id):
|
|
290
335
|
raise NedbBadRequest(f"unquotable identifier: {coll}:{doc_id}")
|
|
291
|
-
|
|
336
|
+
as_of_clause = f" AS OF {int(as_of)}" if as_of is not None else ""
|
|
337
|
+
rows = self.query(f'FROM {coll}{as_of_clause} WHERE _id = "{doc_id}"')
|
|
292
338
|
return rows[0] if rows else None
|
|
293
339
|
|
|
294
340
|
def count(self, coll: str) -> int:
|
|
@@ -326,7 +372,7 @@ class NedbClient:
|
|
|
326
372
|
def delete(self, coll: str, doc_id: str) -> dict:
|
|
327
373
|
"""``DELETE …/rows/{coll}/{id}`` → ``{ok, seq, head}``."""
|
|
328
374
|
return self.request(
|
|
329
|
-
"DELETE", self._dbp(f"rows/{coll}/{doc_id}"))
|
|
375
|
+
"DELETE", self._dbp(f"rows/{_seg(coll)}/{_seg(doc_id)}"))
|
|
330
376
|
|
|
331
377
|
def tx(self, ops: List[Dict[str, Any]], *,
|
|
332
378
|
client: Optional[str] = None) -> dict:
|