nedb-engine 3.2.2 → 3.3.1

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.
package/README.md CHANGED
@@ -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:
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nedb-engine",
3
- "version": "3.2.2",
3
+ "version": "3.3.1",
4
4
  "description": "NEDB \u2014 hash-chained, time-traveling, bi-temporal embedded database with Rust native core. SQL, Redis, MongoDB adapters. Causal Write Provenance. RESP2 wire protocol.",
5
5
  "main": "index.js",
6
6
  "exports": {