Skip to main content

siderite_backends/
mongodb.rs

1//! MongoDB backend: compiles the supported subset of a
2//! [`QueryPlan`](siderite_orm::QueryPlan) / [`WritePlan`](siderite_orm::WritePlan)
3//! to filters, update documents and aggregation pipelines.
4//!
5//! * [`compile`] is a pure compiler (no server needed, unit-tested).
6//! * [`MongoBackend`] executes compiled plans (MongoDB 5.0+; transactions
7//!   need a replica set) and implements the same
8//!   [`Backend`](siderite_orm::Backend) / [`Executor`](siderite_orm::Executor)
9//!   traits as the SQL adapters, so it plugs into [`Db`](siderite_orm::Db).
10//! * [`value`] maps [`Value`](siderite_orm::Value) to and from BSON.
11//!
12//! Anything the backend cannot express fails **before any I/O**, with a
13//! capability error ([`BackendCapabilities::mongodb`](siderite_orm::BackendCapabilities::mongodb),
14//! checked through `QueryPlan::check` / `WritePlan::check`) or
15//! [`QueryError::InvalidPlan`](siderite_orm::QueryError::InvalidPlan).
16//!
17//! # Collections and keys
18//!
19//! A table is a collection of the same name. Plans carry no model metadata,
20//! so the adapter needs to know which column is the primary key: it is the
21//! column named `id` unless [`Keys`] says otherwise
22//! (`MongoBackend::with_keys(Keys::default().with("tags", "slug"))`). That
23//! column is stored as `_id` and nowhere else; filters, projections, sort
24//! keys and update targets are translated, and `_id` is renamed back on
25//! read. A key the plan does not supply (an insert that omits it) is
26//! generated as an `i64` from a counter document in `siderite_counters`
27//! (`$inc` by the batch size, so a bulk insert gets consecutive keys in
28//! input order; the counter lives outside the transaction like a sequence,
29//! so a rollback leaves a gap). Explicitly supplied keys do not advance the
30//! counter. A collection whose key column is not registered still works, but
31//! its key column is then an ordinary field next to a generated `_id`, and
32//! duplicates of it are not rejected.
33//!
34//! # Mapping table
35//!
36//! ## `QueryPlan` fields
37//!
38//! | Field | MongoDB |
39//! |---|---|
40//! | `source` (table) | collection |
41//! | `source` (derived table, `from_subquery`) | inner pipeline followed by the outer stages (used by `count`, `exists`, `aggregate` on limited / distinct / grouped querysets) |
42//! | `projection` | final `$project` (`_id: 0`, one computed field per output name); empty = whole documents with `_id` renamed to the key column |
43//! | `joins` | capability error `Feature::Joins` (also `select_related` / `Related` columns) |
44//! | `filter` | leading `$match`: query operators where a predicate has a query form, otherwise `{$expr: ..}` |
45//! | `grouping` | `$group` with `_id: {k0: .., k1: ..}` (`_id: null` when only aggregates are projected) |
46//! | `having` | `$match` with `$expr` after `$group` |
47//! | `ordering` | `$sort` (plain columns sort directly, other expressions through a temporary `$addFields`; NULLs sort first ascending, as on SQLite) |
48//! | `limit` / `offset` | `$limit` / `$skip` |
49//! | `distinct: All` | `$group` on the projected fields, then `$replaceRoot`; ordering must then use projected expressions |
50//! | `distinct: On` | capability error `Feature::DistinctOn` |
51//! | `lock` | capability error `Feature::RowLocking` / `LockModifiers` |
52//! | `compound` (set operations) | capability error `Feature::SetOperations` |
53//!
54//! A grouped plan needs an explicit projection, and every non-aggregate
55//! expression in it, in `having` and in `ordering` must be a grouping key or
56//! a projected alias (anything else is `InvalidPlan`). A single ungrouped
57//! `COUNT(*)` compiles to `$count`. An ungrouped aggregate over no documents
58//! still yields one row (`COUNT` is 0, the rest `NULL`), as in SQL.
59//!
60//! ## `WritePlan`
61//!
62//! | Plan | MongoDB |
63//! |---|---|
64//! | `Insert` | `insert_many` (ordered); the key column becomes `_id`; keys are generated when absent (see above); `returning` echoes the stored documents in input order |
65//! | `Update` | `update_many`; literal assignments use `{$set: {..}}`, `F`-expression assignments use an update pipeline (`[{$set: {..}}]`, every expression sees the original document); `rows_affected` is the matched count; `returning` first pins the matching `_id`s, updates them, then reads them back by `_id`; the key column cannot be assigned |
66//! | `Delete` | `delete_many`; `returning` reads the documents first |
67//!
68//! ## `Expr` nodes
69//!
70//! Predicates follow SQL three-valued logic: a row passes only when the
71//! predicate is *true*. MongoDB's `$ne`, `$nin`, `$not` and `$nor` match
72//! NULL and missing fields and `$expr` comparisons use BSON type order, so
73//! negation is pushed down (De Morgan) with explicit non-NULL guards, and
74//! `$expr` comparisons, `NOT`, `AND` and `OR` are compiled with a NULL
75//! branch.
76//!
77//! | Node | Filter (query form) | Aggregation expression (`$expr`, projections, sorts, updates) |
78//! |---|---|---|
79//! | `Column` | field (`_id` for the key) | `"$field"`; boolean column as predicate: `{f: true}` |
80//! | `Value` | operand | `{$literal: bson}`; `NULL` is `null` |
81//! | `Binary` comparison | `{f: {$eq/$lt/$lte/$gt/$gte: v}}`; `<>` is `{$nin: [v, null]}`; `= NULL` is `{f: null}` | `$eq/$ne/$lt/$lte/$gt/$gte` guarded by `$cond` so a NULL operand gives NULL |
82//! | `Binary` arithmetic | not a predicate | `$add`, `$subtract`, `$multiply`, `$divide`, `$mod` (`$divide` is always floating point, unlike SQL integer division) |
83//! | `Unary` | `Not` pushes negation down; `Neg` n/a | `Not`: NULL-preserving `$not`; `Neg`: `$multiply: [x, -1]` |
84//! | `And` / `Or` | `$and` / `$or`; empty `And` is `{}`, empty `Or` is `{$expr: false}` | Kleene logic with `$in: [false/true/null, [..]]` |
85//! | `Lookup::IExact` | `{f: {$regex: "^escaped\\z", $options: "i"}}` | `$regexMatch` (text only, else NULL) |
86//! | `Lookup::Contains` / `StartsWith` / `EndsWith` | `$regex` on the escaped needle, `$options: "i"` when case-insensitive, anchored with `^` / `\z` | `$regexMatch` |
87//! | `Lookup::Regex` | `{f: {$regex: p}}` | `$regexMatch` |
88//! | `Lookup::In` | `$in` (`$nin` with `null` when negated) | `$in` with a literal array |
89//! | `Lookup::Range` | `{$gte, $lte}` (`$or` of `$lt` / `$gt` when negated) | `$and` of `$gte` / `$lte` |
90//! | `Lookup::IsNull` | `{f: null}` / `{f: {$ne: null}}` | `$eq: [{$ifNull: [x, null]}, null]` |
91//! | `Lookup::InSubquery`, `Exists`, `Subquery` | capability error `Feature::Subqueries` | same |
92//! | `OuterRef` | `InvalidPlan` (only valid inside a subquery) | same |
93//! | `Related` | capability error `Feature::Joins` | same |
94//! | `Func::Lower` / `Upper` / `Length` / `Trim` | via `$expr` | `$toLower`, `$toUpper`, `$strLenCP`, `$trim` (NULL in, NULL out) |
95//! | `Func::Coalesce` | via `$expr` | `$ifNull` |
96//! | `Func::Concat` | via `$expr` | `$concat` with each argument wrapped in `$ifNull: [x, ""]` |
97//! | `Func::Substr` | via `$expr` | `$substrCP` with `start - 1` (1-based), length defaults to the rest |
98//! | `Func::Replace` | via `$expr` | `$replaceAll` |
99//! | `Cast` | via `$expr` | `$convert` to `long` / `double` / `decimal` / `bool` / `string` / `date` (`SmallInt`, `Integer`, `BigInt`, `Real`, `Double`, `Decimal`, `Bool`, `Text`, `Timestamp`); other targets are `InvalidPlan` |
100//! | `Case` | via `$expr` | `$switch` (default NULL) |
101//! | `DatePart` | via `$expr` | `$year`, `$month`, `$dayOfMonth`, `$isoWeek`, quarter from `$month`, `$hour`, `$minute`, `$second`, `$dateToString` (`%Y-%m-%d`) for `Date`. Dates and times are stored as text, so a string operand is parsed with `$dateFromString` (dates) or sliced (times) |
102//! | `Aggregate::Count(*)` | | `{$sum: 1}` (`$count` alone) |
103//! | `Aggregate::Count(x)` | | `$sum` of 1 per non-NULL value; `DISTINCT` via `$addToSet` and `$size` |
104//! | `Aggregate::Sum` | | `$sum` plus a count of numeric values so an all-NULL group is NULL, not 0 |
105//! | `Aggregate::Avg` / `Min` / `Max` | | `$avg` / `$min` / `$max` |
106//! | `Aggregate::StdDev` / `Variance` | | `$stdDevSamp` / `$stdDevPop`; variance is `$pow` of the deviation by 2 (statistical aggregates are supported) |
107//! | `Aggregate::StringAgg` | | `$push` (or `$addToSet`) then `$reduce` with `$concat` (element order is unspecified) |
108//! | `Aggregate::ArrayAgg` | | capability error `Feature::Arrays` |
109//! | `Aggregate` `filter` | | the argument becomes `{$cond: [filter, x, null]}`, which every accumulator ignores |
110//! | `Window` | capability error `Feature::WindowFunctions` | same |
111//!
112//! # Values
113//!
114//! | `Value` | BSON |
115//! |---|---|
116//! | `Null` | null |
117//! | `Bool` | boolean |
118//! | `Int` | int64 (int32 and int64 both decode) |
119//! | `Float` | double |
120//! | `Decimal` | Decimal128 (exact, sums and averages stay decimal) |
121//! | `Text` | string |
122//! | `Bytes` | binary, generic subtype |
123//! | `Uuid` | binary subtype 4 (16 bytes) |
124//! | `Timestamp` | BSON datetime, **millisecond precision**: microseconds are truncated on write, so a value read back can differ from the one written; `insert`/`update` `returning` rows reflect what was stored |
125//! | `Date`, `Time` | canonical text as on SQLite (`%Y-%m-%d`, `%H:%M:%S%.6f`), which sorts and compares correctly |
126//! | `Json` | objects and arrays are embedded documents / arrays (numbers become int64 or double; integers above `i64::MAX` are rejected); JSON scalars are stored as their JSON text (`5`, `"x"`) so they decode back unchanged |
127//!
128//! Decoding is by BSON type: documents and arrays become `Value::Json`,
129//! ObjectIds become hex text, other unsupported types are decode errors.
130//!
131//! # Transactions
132//!
133//! `begin` starts a session and a transaction (`TransactionSupport::Flat`).
134//! There are no savepoints, so a nested `Db::transaction` (and therefore
135//! `bulk_create` on a transactional handle) is a capability error, and no
136//! isolation level can be requested. Dropping a transaction aborts it.
137//! MongoDB has no foreign keys: `ON DELETE` actions do nothing, and unique
138//! constraints exist only where [`MongoBackend::create_unique_index`] made
139//! one (plus `_id`). Duplicate-key and document-validation failures map to
140//! [`BackendError::Constraint`](siderite_orm::BackendError::Constraint).
141//!
142//! # Raw access
143//!
144//! `raw_sql`, `raw_execute` and `execute_script` are capability errors
145//! (`Feature::RawSql`); [`MongoBackend::raw_command`] runs a database
146//! command instead.
147
148pub mod compile;
149#[cfg(test)]
150mod compile_tests;
151mod exec;
152pub mod value;
153
154pub use compile::Keys;
155pub use exec::MongoBackend;