Skip to main content

siderite_orm/
capabilities.rs

1//! Backend capability model.
2//!
3//! Backends declare what they support; the query layer checks a plan against
4//! these declarations **before** execution so unsupported features fail with a
5//! [`BackendCapabilityError`] instead of being silently ignored.
6
7use crate::error::BackendCapabilityError;
8
9/// Family of a backend. Used for diagnostics and dialect selection.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11#[non_exhaustive]
12pub enum BackendKind {
13    /// PostgreSQL.
14    Postgres,
15    /// SQLite.
16    Sqlite,
17    /// MySQL / MariaDB.
18    MySql,
19    /// MongoDB.
20    MongoDb,
21    /// Redis (restricted key/value model; not a relational ORM backend).
22    Redis,
23}
24
25impl BackendKind {
26    /// Value of the `db.system` tracing attribute for this backend.
27    pub fn system_name(self) -> &'static str {
28        match self {
29            Self::Postgres => "postgresql",
30            Self::Sqlite => "sqlite",
31            Self::MySql => "mysql",
32            Self::MongoDb => "mongodb",
33            Self::Redis => "redis",
34        }
35    }
36}
37
38/// Transaction support level.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
40pub enum TransactionSupport {
41    /// No multi-statement transactions.
42    None,
43    /// Transactions without nested savepoints.
44    Flat,
45    /// Transactions with savepoints (nested transactions).
46    Savepoints,
47}
48
49/// Row-locking support level (`SELECT ... FOR UPDATE`).
50#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
51pub enum RowLocking {
52    /// No row locking.
53    None,
54    /// Plain `FOR UPDATE`.
55    Basic,
56    /// `FOR UPDATE` with `NOWAIT` and `SKIP LOCKED`.
57    Full,
58}
59
60/// Transaction isolation level. Backends list the levels they honour in
61/// [`BackendCapabilities::isolation_levels`]; requesting another one is a
62/// capability error, never a silent downgrade.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub enum IsolationLevel {
65    /// `READ COMMITTED`.
66    ReadCommitted,
67    /// `REPEATABLE READ`.
68    RepeatableRead,
69    /// `SERIALIZABLE`.
70    Serializable,
71}
72
73/// A single optional query feature that a plan may require.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
75#[non_exhaustive]
76pub enum Feature {
77    /// JOINs between sources.
78    Joins,
79    /// `RETURNING` clauses on writes.
80    Returning,
81    /// Row locking.
82    RowLocking,
83    /// `NOWAIT` / `SKIP LOCKED` lock modifiers.
84    LockModifiers,
85    /// Window functions.
86    WindowFunctions,
87    /// Regular expression matching.
88    Regex,
89    /// Native array columns.
90    Arrays,
91    /// `DISTINCT ON (...)`.
92    DistinctOn,
93    /// `ILIKE` style case-insensitive matching natively.
94    CaseInsensitiveLike,
95    /// `STDDEV` / `VARIANCE` aggregates.
96    StatisticalAggregates,
97    /// Nested transactions via savepoints.
98    Savepoints,
99    /// A specific transaction isolation level.
100    Isolation(IsolationLevel),
101    /// Subqueries: `EXISTS (..)`, scalar subqueries and `IN (subquery)`.
102    Subqueries,
103    /// Set operations (`UNION`, `INTERSECT`, `EXCEPT`).
104    SetOperations,
105    /// Raw SQL text (`fetch_raw`, `execute_raw`, `execute_script`).
106    RawSql,
107    /// Cursor (keyset) pagination: `QuerySet::cursor_page`.
108    KeysetPagination,
109    /// Cursor pagination over nullable ordering columns, which needs a
110    /// null-position sort key the backend can express.
111    NullableKeyset,
112}
113
114/// Declared capabilities of a backend.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct BackendCapabilities {
117    /// Backend family.
118    pub kind: BackendKind,
119    /// Transaction support level.
120    pub transactions: TransactionSupport,
121    /// Row-locking support level.
122    pub row_locking: RowLocking,
123    /// JOIN support.
124    pub joins: bool,
125    /// `RETURNING` support.
126    pub returning: bool,
127    /// Window function support.
128    pub window_functions: bool,
129    /// Regex lookups.
130    pub regex: bool,
131    /// Native arrays.
132    pub arrays: bool,
133    /// `DISTINCT ON`.
134    pub distinct_on: bool,
135    /// Native `ILIKE`.
136    pub ilike: bool,
137    /// `STDDEV` / `VARIANCE` aggregates.
138    pub statistical_aggregates: bool,
139    /// Most bind parameters one statement may carry; bulk operations chunk
140    /// their rows to stay below it.
141    pub max_params: usize,
142    /// Isolation levels that can be requested explicitly.
143    pub isolation_levels: &'static [IsolationLevel],
144}
145
146impl BackendCapabilities {
147    /// Capabilities of PostgreSQL (12+).
148    pub const fn postgres() -> Self {
149        Self {
150            kind: BackendKind::Postgres,
151            transactions: TransactionSupport::Savepoints,
152            row_locking: RowLocking::Full,
153            joins: true,
154            returning: true,
155            window_functions: true,
156            regex: true,
157            arrays: true,
158            distinct_on: true,
159            ilike: true,
160            statistical_aggregates: true,
161            max_params: 65_535,
162            isolation_levels: &[
163                IsolationLevel::ReadCommitted,
164                IsolationLevel::RepeatableRead,
165                IsolationLevel::Serializable,
166            ],
167        }
168    }
169
170    /// Capabilities of SQLite (3.35+ for `RETURNING`, 3.25+ for windows).
171    ///
172    /// SQLite has no row locking, no built-in `REGEXP` function and no
173    /// `STDDEV` / `VARIANCE`. Its transactions are always serializable. The
174    /// parameter limit is that of SQLite 3.32 and later.
175    pub const fn sqlite() -> Self {
176        Self {
177            kind: BackendKind::Sqlite,
178            transactions: TransactionSupport::Savepoints,
179            row_locking: RowLocking::None,
180            joins: true,
181            returning: true,
182            window_functions: true,
183            regex: false,
184            arrays: false,
185            distinct_on: false,
186            ilike: false,
187            statistical_aggregates: false,
188            max_params: 32_766,
189            isolation_levels: &[IsolationLevel::Serializable],
190        }
191    }
192
193    /// Capabilities of MySQL 8.0.31+ (tested on 9.x).
194    ///
195    /// * `returning` is `true` although MySQL has no `RETURNING` clause: the
196    ///   adapter emulates it (generated keys via `LAST_INSERT_ID()`, other
197    ///   writes by re-reading the affected rows in the same transaction).
198    /// * There is no `DISTINCT ON`, no native array type or array aggregate
199    ///   and no `ILIKE` (emulated with `LOWER(..) LIKE LOWER(..)`).
200    /// * Regex uses `REGEXP_LIKE`; row locking supports `NOWAIT` and
201    ///   `SKIP LOCKED`; all three isolation levels can be requested.
202    pub const fn mysql() -> Self {
203        Self {
204            kind: BackendKind::MySql,
205            transactions: TransactionSupport::Savepoints,
206            row_locking: RowLocking::Full,
207            joins: true,
208            returning: true,
209            window_functions: true,
210            regex: true,
211            arrays: false,
212            distinct_on: false,
213            ilike: false,
214            statistical_aggregates: true,
215            max_params: 65_535,
216            isolation_levels: &[
217                IsolationLevel::ReadCommitted,
218                IsolationLevel::RepeatableRead,
219                IsolationLevel::Serializable,
220            ],
221        }
222    }
223
224    /// Capabilities of MongoDB (5.0+; transactions need a replica set).
225    ///
226    /// The plan is compiled to filters and aggregation pipelines: no joins,
227    /// row locking, window functions, arrays, `DISTINCT ON`, subqueries, set
228    /// operations or raw SQL. `RETURNING` is emulated by the adapter.
229    /// Transactions are flat (no savepoints) and no isolation level can be
230    /// requested. `max_params` bounds the rows per `insert_many` batch.
231    pub const fn mongodb() -> Self {
232        Self {
233            kind: BackendKind::MongoDb,
234            transactions: TransactionSupport::Flat,
235            row_locking: RowLocking::None,
236            joins: false,
237            returning: true,
238            window_functions: false,
239            regex: true,
240            arrays: false,
241            distinct_on: false,
242            ilike: false,
243            statistical_aggregates: true,
244            max_params: 50_000,
245            isolation_levels: &[],
246        }
247    }
248
249    /// Capabilities of Redis as a key, hash and set store.
250    ///
251    /// Redis is not a QuerySet backend. Every relational feature is
252    /// unsupported, so a plan checked against these capabilities fails with a
253    /// [`BackendCapabilityError`] before any I/O. Multi-key atomicity is one
254    /// `MULTI`/`EXEC` ([`TransactionSupport::Flat`]): there are no savepoints
255    /// and no SQL isolation levels. `max_params` is `0` because Redis does not
256    /// bind SQL parameters.
257    pub const fn redis() -> Self {
258        Self {
259            kind: BackendKind::Redis,
260            transactions: TransactionSupport::Flat,
261            row_locking: RowLocking::None,
262            joins: false,
263            returning: false,
264            window_functions: false,
265            regex: false,
266            arrays: false,
267            distinct_on: false,
268            ilike: false,
269            statistical_aggregates: false,
270            max_params: 0,
271            isolation_levels: &[],
272        }
273    }
274
275    /// Whether `feature` is supported.
276    pub fn supports(&self, feature: Feature) -> bool {
277        match feature {
278            Feature::Joins => self.joins,
279            Feature::Returning => self.returning,
280            Feature::RowLocking => self.row_locking >= RowLocking::Basic,
281            Feature::LockModifiers => self.row_locking >= RowLocking::Full,
282            Feature::WindowFunctions => self.window_functions,
283            Feature::Regex => self.regex,
284            Feature::Arrays => self.arrays,
285            Feature::StatisticalAggregates => self.statistical_aggregates,
286            Feature::DistinctOn => self.distinct_on,
287            // Relational backends emulate this with LOWER(x) LIKE LOWER(?)
288            // when native ILIKE is absent, MongoDB with a case-insensitive `$regex`.
289            // Redis has no LIKE.
290            Feature::CaseInsensitiveLike => {
291                self.ilike
292                    || matches!(
293                        self.kind,
294                        BackendKind::Postgres
295                            | BackendKind::Sqlite
296                            | BackendKind::MySql
297                            | BackendKind::MongoDb
298                    )
299            }
300            Feature::Savepoints => self.transactions >= TransactionSupport::Savepoints,
301            Feature::Isolation(level) => self.isolation_levels.contains(&level),
302            // Query languages other than SQL cannot express these.
303            Feature::Subqueries | Feature::SetOperations | Feature::RawSql => {
304                !matches!(self.kind, BackendKind::MongoDb | BackendKind::Redis)
305            }
306            Feature::KeysetPagination => self.kind != BackendKind::Redis,
307            // MongoDB sorts null first; the computed sort key that would
308            // place it like the SQL backends is not verified yet.
309            Feature::NullableKeyset => {
310                !matches!(self.kind, BackendKind::MongoDb | BackendKind::Redis)
311            }
312        }
313    }
314
315    /// Return `Ok(())` if `feature` is supported, otherwise a typed error.
316    pub fn require(&self, feature: Feature) -> Result<(), BackendCapabilityError> {
317        if self.supports(feature) {
318            Ok(())
319        } else {
320            Err(BackendCapabilityError::from_feature(self.kind, feature))
321        }
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn sqlite_rejects_row_locking() {
331        let err = BackendCapabilities::sqlite().require(Feature::RowLocking);
332        assert!(matches!(
333            err,
334            Err(BackendCapabilityError::RowLockingUnsupported {
335                backend: BackendKind::Sqlite
336            })
337        ));
338    }
339
340    #[test]
341    fn postgres_supports_everything_modelled() {
342        let caps = BackendCapabilities::postgres();
343        for f in [
344            Feature::Joins,
345            Feature::Regex,
346            Feature::LockModifiers,
347            Feature::DistinctOn,
348            Feature::CaseInsensitiveLike,
349        ] {
350            assert!(caps.supports(f), "{f:?}");
351        }
352        assert!(BackendCapabilities::sqlite().supports(Feature::CaseInsensitiveLike));
353    }
354
355    #[test]
356    fn redis_rejects_relational_features_without_io() {
357        let caps = BackendCapabilities::redis();
358        assert_eq!(caps.kind, BackendKind::Redis);
359        assert_eq!(caps.transactions, TransactionSupport::Flat);
360        assert_eq!(caps.max_params, 0);
361        assert!(matches!(
362            caps.require(Feature::RowLocking),
363            Err(BackendCapabilityError::RowLockingUnsupported {
364                backend: BackendKind::Redis
365            })
366        ));
367        for feature in [
368            Feature::Joins,
369            Feature::Returning,
370            Feature::WindowFunctions,
371            Feature::Regex,
372            Feature::Arrays,
373            Feature::DistinctOn,
374            Feature::StatisticalAggregates,
375            Feature::Savepoints,
376            Feature::CaseInsensitiveLike,
377            Feature::Isolation(IsolationLevel::ReadCommitted),
378        ] {
379            assert!(
380                matches!(
381                    caps.require(feature),
382                    Err(BackendCapabilityError::Unsupported {
383                        backend: BackendKind::Redis,
384                        ..
385                    })
386                ),
387                "{feature:?}"
388            );
389        }
390    }
391
392    #[test]
393    fn mongodb_declares_its_subset() {
394        let caps = BackendCapabilities::mongodb();
395        for f in [
396            Feature::Joins,
397            Feature::RowLocking,
398            Feature::WindowFunctions,
399            Feature::Arrays,
400            Feature::DistinctOn,
401            Feature::Savepoints,
402            Feature::Subqueries,
403            Feature::SetOperations,
404            Feature::RawSql,
405            Feature::Isolation(IsolationLevel::Serializable),
406        ] {
407            assert!(!caps.supports(f), "{f:?}");
408        }
409        for f in [
410            Feature::Returning,
411            Feature::Regex,
412            Feature::StatisticalAggregates,
413            Feature::CaseInsensitiveLike,
414        ] {
415            assert!(caps.supports(f), "{f:?}");
416        }
417        assert!(BackendCapabilities::sqlite().supports(Feature::Subqueries));
418        assert!(BackendCapabilities::postgres().supports(Feature::RawSql));
419    }
420
421    #[test]
422    fn keyset_pagination_is_listed_per_backend() {
423        for caps in [
424            BackendCapabilities::postgres(),
425            BackendCapabilities::sqlite(),
426            BackendCapabilities::mysql(),
427        ] {
428            assert!(caps.supports(Feature::KeysetPagination));
429            assert!(caps.supports(Feature::NullableKeyset));
430        }
431        let mongo = BackendCapabilities::mongodb();
432        assert!(mongo.supports(Feature::KeysetPagination));
433        assert!(!mongo.supports(Feature::NullableKeyset));
434        let redis = BackendCapabilities::redis();
435        assert!(!redis.supports(Feature::KeysetPagination));
436        assert!(!redis.supports(Feature::NullableKeyset));
437    }
438
439    #[test]
440    fn mysql_rejects_distinct_on_and_arrays() {
441        let caps = BackendCapabilities::mysql();
442        assert!(caps.require(Feature::DistinctOn).is_err());
443        assert!(caps.require(Feature::Arrays).is_err());
444        assert!(caps.require(Feature::LockModifiers).is_ok());
445        assert!(
446            caps.require(Feature::Isolation(IsolationLevel::Serializable))
447                .is_ok()
448        );
449    }
450}