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.
Files changed (51) hide show
  1. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/PKG-INFO +254 -4
  2. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/README.md +253 -3
  3. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/pyproject.toml +1 -1
  4. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/__init__.py +1 -1
  5. nedb_engine-3.3.1/python/nedb/_native.abi3.so +0 -0
  6. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/client.py +50 -4
  7. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/engine.py +147 -31
  8. nedb_engine-3.3.1/python/nedb/nedbd-v2 +0 -0
  9. nedb_engine-3.3.1/python/nedb/nedbd-v2-darwin-arm64 +0 -0
  10. nedb_engine-3.3.1/python/nedb/nedbd-v2-darwin-x64 +0 -0
  11. nedb_engine-3.3.1/python/nedb/nedbd-v2.exe +0 -0
  12. nedb_engine-3.3.1/python/nedb/query.py +615 -0
  13. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/server.py +41 -3
  14. nedb_engine-3.2.2/python/nedb/_native.abi3.so +0 -0
  15. nedb_engine-3.2.2/python/nedb/nedbd-v2 +0 -0
  16. nedb_engine-3.2.2/python/nedb/nedbd-v2-darwin-arm64 +0 -0
  17. nedb_engine-3.2.2/python/nedb/nedbd-v2-darwin-x64 +0 -0
  18. nedb_engine-3.2.2/python/nedb/nedbd-v2.exe +0 -0
  19. nedb_engine-3.2.2/python/nedb/query.py +0 -305
  20. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/.gitignore +0 -0
  21. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/LICENSE +0 -0
  22. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/client/node/README.md +0 -0
  23. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/client/python/README.md +0 -0
  24. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/examples/agent-loop/README.md +0 -0
  25. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/examples/mini-chain/README.md +0 -0
  26. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/autoindex.py +0 -0
  27. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/backends/__init__.py +0 -0
  28. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/backends/dag.py +0 -0
  29. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/backends/redis_backend.py +0 -0
  30. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/cascade.py +0 -0
  31. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/concurrent.py +0 -0
  32. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/crypto.py +0 -0
  33. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/index.py +0 -0
  34. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/log.py +0 -0
  35. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/merkle.py +0 -0
  36. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/mongo.py +0 -0
  37. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/proof.py +0 -0
  38. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/redis_compat.py +0 -0
  39. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/relations.py +0 -0
  40. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/resp2.py +0 -0
  41. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/snapshot.py +0 -0
  42. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/sql.py +0 -0
  43. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/store.py +0 -0
  44. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/wrap_core.py +0 -0
  45. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/wrap_mongo.py +0 -0
  46. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/wrap_mysql.py +0 -0
  47. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/wrap_postgresql.py +0 -0
  48. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/wrap_redis.py +0 -0
  49. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/python/nedb/wrap_sqlite.py +0 -0
  50. {nedb_engine-3.2.2 → nedb_engine-3.3.1}/rust/crates/nedb-wrap/README.md +0 -0
  51. {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.2.2
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 <field> <op> <value> (AND ...) ] op: = != < <= > >=
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 <field> <op> <value> (AND ...) ] op: = != < <= > >=
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.2.2"
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"
@@ -102,4 +102,4 @@ __all__ = [
102
102
  "verify_proof", "fold_head",
103
103
  "_native", "__has_native__",
104
104
  ]
105
- __version__ = "3.2.2"
105
+ __version__ = "3.3.1"
@@ -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) -> Optional[dict]:
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
- rows = self.query(f'FROM {coll} WHERE _id = "{doc_id}"')
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: