Skip to main content

toasty_core/driver/
capability.rs

1use crate::{schema::db, stmt};
2
3/// Describes what a database driver supports.
4///
5/// The query planner reads these flags to decide which [`Operation`](super::Operation)
6/// variants to generate. For example, a SQL driver sets `sql: true` and
7/// receives `QuerySql` operations, while DynamoDB sets `sql: false` and
8/// receives key-value operations like `GetByKey` and `QueryPk`.
9///
10/// Pre-built configurations are available as associated constants:
11/// [`SQLITE`](Self::SQLITE), [`POSTGRESQL`](Self::POSTGRESQL),
12/// [`MYSQL`](Self::MYSQL), and [`DYNAMODB`](Self::DYNAMODB).
13///
14/// # Examples
15///
16/// ```
17/// use toasty_core::driver::Capability;
18///
19/// let cap = &Capability::SQLITE;
20/// assert!(cap.sql);
21/// assert!(cap.returning_from_mutation);
22/// assert!(!cap.select_for_update);
23/// ```
24#[derive(Debug)]
25pub struct Capability {
26    /// When `true`, the database uses a SQL-based query language and the
27    /// planner will emit [`QuerySql`](super::operation::QuerySql) operations.
28    pub sql: bool,
29
30    /// Column storage types supported by the database.
31    pub storage_types: StorageTypes,
32
33    /// What the database is able to change about its own schema. See
34    /// [`SchemaMutations`] for the individual fields; the migration
35    /// generator branches on them to choose between an in-place
36    /// `ALTER COLUMN` and a table rebuild, and between one combined
37    /// alter statement and several single-property ones.
38    pub schema_mutations: SchemaMutations,
39
40    /// SQL: supports update statements in CTE queries.
41    pub cte_with_update: bool,
42
43    /// SQL: Supports row-level locking. If false, then the driver is expected
44    /// to serializable transaction-level isolation.
45    pub select_for_update: bool,
46
47    /// SQL: Mysql doesn't support returning clauses from insert / update queries
48    pub returning_from_mutation: bool,
49
50    /// DynamoDB does not support != predicates on the primary key.
51    pub primary_key_ne_predicate: bool,
52
53    /// Whether the database has an auto increment modifier for integer columns.
54    pub auto_increment: bool,
55
56    /// Whether the database supports `VARCHAR(n)` column types natively.
57    ///
58    /// Must be consistent with [`StorageTypes::varchar`]: when `true`,
59    /// `varchar` must be `Some`; when `false`, `varchar` must be `None`.
60    /// Use [`Capability::validate`] to check this invariant.
61    pub native_varchar: bool,
62
63    /// Whether the database has native support for Timestamp types.
64    pub native_timestamp: bool,
65
66    /// Whether the database has native support for Date types.
67    pub native_date: bool,
68
69    /// Whether the database has native support for Time types.
70    pub native_time: bool,
71
72    /// Whether the database has native support for DateTime types.
73    pub native_datetime: bool,
74
75    /// Whether the database supports native enum types.
76    ///
77    /// - PostgreSQL: `true` — `CREATE TYPE ... AS ENUM`
78    /// - MySQL: `true` — inline `ENUM('a', 'b')` column type
79    /// - SQLite: `false` — uses `TEXT` + `CHECK` constraint
80    /// - DynamoDB: `false` — plain string attribute
81    pub native_enum: bool,
82
83    /// Whether enum types are standalone named objects requiring separate DDL.
84    ///
85    /// When `true`, migrations must emit `CREATE TYPE` / `ALTER TYPE` for enum
86    /// types. When `false`, enum definitions are inline in column types.
87    ///
88    /// - PostgreSQL: `true` — `CREATE TYPE <name> AS ENUM (...)`
89    /// - MySQL: `false` — inline `ENUM('a', 'b')` on the column
90    /// - SQLite: `false`
91    /// - DynamoDB: `false`
92    pub named_enum_types: bool,
93
94    /// Whether the database has native support for Decimal types.
95    pub native_decimal: bool,
96
97    /// Whether BigDecimal driver support is implemented.
98    /// TODO: Remove this flag when PostgreSQL BigDecimal support is implemented.
99    /// Currently only MySQL has implemented BigDecimal driver support.
100    pub bigdecimal_implemented: bool,
101
102    /// Whether the database's decimal type supports arbitrary precision.
103    /// When false, the decimal type requires fixed precision and scale to be specified upfront.
104    /// - PostgreSQL: true (NUMERIC supports arbitrary precision)
105    /// - MySQL: false (DECIMAL requires fixed precision/scale)
106    /// - SQLite/DynamoDB: false (no native decimal support, stored as TEXT)
107    pub decimal_arbitrary_precision: bool,
108
109    /// Whether OR is supported in index key conditions (e.g. DynamoDB KeyConditionExpression).
110    /// DynamoDB: false. All other backends: true (SQL backends never use index key conditions).
111    pub index_or_predicate: bool,
112
113    /// Whether the database has a native prefix-match operator that does not
114    /// require LIKE-style escaping. When `true`, `starts_with` is left in the
115    /// AST and the driver renders it natively (DynamoDB's `begins_with()`,
116    /// PostgreSQL's `^@`). When `false`, the lowering rewrites it to a
117    /// `LIKE` expression — which requires `native_like` to be `true`.
118    pub native_starts_with: bool,
119
120    /// Whether the database has a native `LIKE` expression. When `false`,
121    /// `Expr::Like` cannot be sent to the driver; `starts_with` lowering
122    /// will not produce one.
123    pub native_like: bool,
124
125    /// Whether the database has a native case-insensitive `LIKE` operator
126    /// (`ILIKE`). Only PostgreSQL has one.
127    ///
128    /// Toasty does not emulate `ILIKE` on backends that lack it: `.ilike()`
129    /// is a pass-through to the database's own operator. When `native_ilike`
130    /// is `false`, the query-verify pass rejects a case-insensitive
131    /// `Expr::Like` with an
132    /// [`unsupported_feature`](crate::Error::unsupported_feature) error rather
133    /// than silently degrading to plain `LIKE`, whose case behavior differs.
134    ///
135    /// Implies `native_like`.
136    pub native_ilike: bool,
137
138    /// Whether the driver can answer queries that don't match any primary key
139    /// or index — i.e. supports unindexed full-table reads.
140    ///
141    /// SQL drivers set this to `true`: unindexed queries go through
142    /// [`QuerySql`](super::operation::QuerySql), so the SQL engine handles
143    /// them transparently. DynamoDB also sets this to `true`; the planner
144    /// emits [`Operation::Scan`](super::Operation::Scan) for the unindexed
145    /// case. A hypothetical pure key-value store with no full-scan capability
146    /// would set this to `false`.
147    pub scan: bool,
148
149    /// Whether scan operations support ordering results.
150    ///
151    /// SQL drivers do not use `Operation::Scan`, so this is `true` for them
152    /// (ordering is handled inside `QuerySql`). DynamoDB's `Scan` API returns
153    /// items in an arbitrary order with no server-side sort, so this is `false`
154    /// for DynamoDB. When `false`, the planner rejects queries that combine a
155    /// scan path with `ORDER BY`.
156    pub scan_supports_sort: bool,
157
158    /// Whether to test connection pool behavior.
159    /// TODO: We only need this for the `connection_per_clone.rs` test, come up with a better way.
160    pub test_connection_pool: bool,
161
162    /// Whether the driver honors non-`Default`
163    /// [`TransactionMode`](super::operation::TransactionMode) variants
164    /// (`Immediate`, `Exclusive`). Currently `true` only for SQLite, which
165    /// maps them to `BEGIN IMMEDIATE` / `BEGIN EXCLUSIVE`. Drivers that
166    /// leave this `false` reject non-`Default` modes with
167    /// [`Error::unsupported_feature`](crate::Error::unsupported_feature).
168    pub transaction_lock_mode: bool,
169
170    /// Whether the backend can walk a paginated query in reverse from a
171    /// cursor.
172    ///
173    /// Gates the `prev_cursor` field on a `Page` returned to user code.
174    /// When `true`, the executor extracts a previous-page cursor from the
175    /// first row of every page (see `apply_sql_pagination` in
176    /// `toasty/src/engine/exec/exec_statement.rs`). When `false`, the
177    /// executor leaves `prev_cursor` as `None`, so
178    /// `Page::has_prev()` returns `false` and `Page::prev(&db)` resolves
179    /// to `Ok(None)` without issuing a query. `Paginate::before(cursor)`
180    /// itself is not rejected — users who already hold a cursor can walk
181    /// backwards explicitly — but a driver that returns `false` is
182    /// declaring that it has no way to *produce* such a cursor.
183    ///
184    /// Drivers should set this to `true` when the backend can answer a
185    /// query equivalent to "rows ordered by K, descending from K = c,
186    /// limited to N" — i.e. the same `ORDER BY` clause reversed plus a
187    /// strict inequality on the cursor key. SQL backends meet this
188    /// trivially. DynamoDB does not: a `Query` with `ScanIndexForward =
189    /// false` returns rows in the opposite direction but cannot be
190    /// rooted at an arbitrary client-supplied cursor without an extra
191    /// `KeyConditionExpression`, and `Scan` has no order guarantee at
192    /// all.
193    pub backward_pagination: bool,
194
195    /// The driver's bind layer accepts a single parameter whose value is
196    /// `Value::List(items)` and type is `Type::List(elem)`, sending it as
197    /// one protocol-level parameter (not N separate scalars).
198    /// Property of the driver bind impl, not the SQL dialect.
199    pub bind_list_param: bool,
200
201    /// The SQL dialect parses `expr <op> ANY(<array>)` and `expr <op> ALL(<array>)`
202    /// as predicates against an array-valued operand.
203    /// Property of the dialect, not the bind layer.
204    pub predicate_match_any: bool,
205
206    /// Whether the database can store a `Vec<scalar>` model field as a native
207    /// array column (e.g. PostgreSQL `text[]`, `int8[]`).
208    ///
209    /// When `true`, schema build maps `Type::List(elem)` to `db::Type::List(elem)`
210    /// and the driver's bind layer accepts `Value::List(items)` as a single
211    /// array-valued parameter.
212    ///
213    /// When `false`, `Vec<T>` model fields use whatever fallback the backend
214    /// provides (JSON column on MySQL/SQLite, native List `L` on DynamoDB).
215    /// See [`Self::vec_scalar`] for the schema-build gate.
216    pub native_array: bool,
217
218    /// Whether the driver supports `Vec<scalar>` model fields, by whatever
219    /// representation (native typed array column, JSON column, key-value
220    /// list attribute, ...). Used by the schema builder as the gate for
221    /// accepting `stmt::Type::List(_)` fields.
222    pub vec_scalar: bool,
223
224    /// Whether the driver natively renders `IsSuperset` / `Intersects` array
225    /// predicates over an arbitrary right-hand-side expression.
226    ///
227    /// SQL drivers set this to `true`: each dialect has a single operator
228    /// (`@>` on PostgreSQL, `JSON_CONTAINS` on MySQL, a `json_each`
229    /// subquery on SQLite) that takes the rhs as a bound expression
230    /// regardless of its shape.
231    ///
232    /// DynamoDB sets this to `false`: it has no equivalent operator and
233    /// emulates the predicates by emitting one `contains(path, vN)` clause
234    /// per rhs element, which requires the rhs to be a concrete list of
235    /// values at filter-construction time. The capability check rejects
236    /// any other rhs shape before the driver is invoked.
237    pub native_array_set_predicates: bool,
238
239    /// Whether the driver supports atomic in-place removal of every element
240    /// equal to a given value from a `Vec<scalar>` field (`stmt::remove`).
241    ///
242    /// - PostgreSQL `text[]`: `true` — `array_remove(col, v)`.
243    /// - MySQL / SQLite JSON: `false` — no value-removal operator.
244    /// - DynamoDB List: `false` — no value-removal on Lists.
245    pub vec_remove: bool,
246
247    /// Whether the driver supports atomic in-place removal of the last
248    /// element of a `Vec<scalar>` field (`stmt::pop`).
249    ///
250    /// - PostgreSQL: `true` — array slicing.
251    /// - MySQL / SQLite: `false`.
252    /// - DynamoDB: `false` — `UpdateExpression` indices must be literal
253    ///   integers, so the last index cannot be expressed in one statement.
254    pub vec_pop: bool,
255
256    /// Whether the driver supports atomic in-place removal of an element at a
257    /// given index from a `Vec<scalar>` field (`stmt::remove_at`).
258    ///
259    /// - PostgreSQL: `true` — array slicing.
260    /// - MySQL / SQLite: `false`.
261    /// - DynamoDB: `false`.
262    pub vec_remove_at: bool,
263}
264
265/// Maps application-level types to the concrete database column types used for
266/// storage.
267///
268/// Each database has different native type support. For example, PostgreSQL has
269/// a native `UUID` type while SQLite stores UUIDs as `BLOB`. This struct
270/// captures those mappings so the schema layer can generate correct DDL and the
271/// driver can encode/decode values appropriately.
272///
273/// Pre-built configurations: [`SQLITE`](Self::SQLITE),
274/// [`POSTGRESQL`](Self::POSTGRESQL), [`MYSQL`](Self::MYSQL),
275/// [`DYNAMODB`](Self::DYNAMODB).
276///
277/// # Examples
278///
279/// ```
280/// use toasty_core::driver::StorageTypes;
281///
282/// let st = &StorageTypes::POSTGRESQL;
283/// // PostgreSQL stores UUIDs natively
284/// assert!(matches!(st.default_uuid_type, toasty_core::schema::db::Type::Uuid));
285/// ```
286#[derive(Debug)]
287pub struct StorageTypes {
288    /// The default storage type for a string.
289    pub default_string_type: db::Type,
290
291    /// When `Some` the database supports varchar types with the specified upper
292    /// limit.
293    pub varchar: Option<u64>,
294
295    /// The default storage type for a UUID.
296    pub default_uuid_type: db::Type,
297
298    /// The default storage type for Bytes (Vec<u8>).
299    pub default_bytes_type: db::Type,
300
301    /// The default storage type for a Decimal (fixed-precision decimal).
302    pub default_decimal_type: db::Type,
303
304    /// The default storage type for a BigDecimal (arbitrary-precision decimal).
305    pub default_bigdecimal_type: db::Type,
306
307    /// The default storage type for a Timestamp (instant in time).
308    pub default_timestamp_type: db::Type,
309
310    /// The default storage type for a Zoned (timezone-aware instant).
311    pub default_zoned_type: db::Type,
312
313    /// The default storage type for a Date (civil date).
314    pub default_date_type: db::Type,
315
316    /// The default storage type for a Time (wall clock time).
317    pub default_time_type: db::Type,
318
319    /// The default storage type for a DateTime (civil datetime).
320    pub default_datetime_type: db::Type,
321
322    /// Maximum value for unsigned integers. When `Some`, unsigned integers
323    /// are limited to this value. When `None`, full u64 range is supported.
324    pub max_unsigned_integer: Option<u64>,
325}
326
327/// The database's capabilities to mutate the schema (tables, columns, indices).
328///
329/// Used by the migration generator to decide how to express each
330/// column change. `alter_column_type` gates whether an in-place
331/// `ALTER COLUMN` is possible at all — SQLite has it set to `false`,
332/// and a type change there triggers a full table rebuild (create
333/// new table, copy rows, drop old). `alter_column_properties_atomic`
334/// decides whether several column-property changes (rename, retype,
335/// `NOT NULL`, default) collapse into one statement or emit one per
336/// property. MySQL sets both to `true`; PostgreSQL alters in place
337/// but requires one statement per property.
338///
339/// Pre-built configurations: [`SQLITE`](Self::SQLITE),
340/// [`POSTGRESQL`](Self::POSTGRESQL), [`MYSQL`](Self::MYSQL),
341/// [`DYNAMODB`](Self::DYNAMODB).
342///
343/// # Examples
344///
345/// Access through [`Capability::schema_mutations`]:
346///
347/// ```
348/// use toasty_core::driver::Capability;
349///
350/// let cap = &Capability::POSTGRESQL;
351/// assert!(cap.schema_mutations.alter_column_type);
352/// assert!(!cap.schema_mutations.alter_column_properties_atomic);
353/// ```
354#[derive(Debug)]
355pub struct SchemaMutations {
356    /// Whether the database can change the type of an existing column.
357    pub alter_column_type: bool,
358
359    /// Whether the database can change name, type and constraints of a column all
360    /// withing a single statement.
361    pub alter_column_properties_atomic: bool,
362}
363
364impl Capability {
365    /// Validates the consistency of the capability configuration.
366    ///
367    /// This performs sanity checks to ensure the capability fields are
368    /// internally consistent. For example, if `native_varchar` is true,
369    /// then `storage_types.varchar` must be Some, and vice versa.
370    ///
371    /// Returns an error if any inconsistencies are found.
372    pub fn validate(&self) -> crate::Result<()> {
373        // Validate varchar consistency
374        if self.native_varchar && self.storage_types.varchar.is_none() {
375            return Err(crate::Error::invalid_driver_configuration(
376                "native_varchar is true but storage_types.varchar is None",
377            ));
378        }
379
380        if !self.native_varchar && self.storage_types.varchar.is_some() {
381            return Err(crate::Error::invalid_driver_configuration(
382                "native_varchar is false but storage_types.varchar is Some",
383            ));
384        }
385
386        // ILIKE is a case-insensitive LIKE; a backend cannot offer it without
387        // a native LIKE.
388        if self.native_ilike && !self.native_like {
389            return Err(crate::Error::invalid_driver_configuration(
390                "native_ilike is true but native_like is false",
391            ));
392        }
393
394        Ok(())
395    }
396
397    /// Returns the default string length limit for this database.
398    ///
399    /// This is useful for tests and applications that need to respect
400    /// database-specific string length constraints.
401    pub fn default_string_max_length(&self) -> Option<u64> {
402        match &self.storage_types.default_string_type {
403            db::Type::VarChar(len) => Some(*len),
404            _ => None, // Handle other types gracefully
405        }
406    }
407
408    /// Returns the native database type for an application-level type.
409    ///
410    /// If the database supports the type natively, returns the same type.
411    /// Otherwise, returns the bridge/storage type that the application type
412    /// maps to in this database.
413    ///
414    /// This uses the existing `db::Type::bridge_type()` method to determine
415    /// the appropriate bridge type based on the database's storage capabilities.
416    pub fn native_type_for(&self, ty: &stmt::Type) -> stmt::Type {
417        match ty {
418            stmt::Type::Uuid => self.storage_types.default_uuid_type.bridge_type(ty),
419            #[cfg(feature = "jiff")]
420            stmt::Type::Timestamp => self.storage_types.default_timestamp_type.bridge_type(ty),
421            #[cfg(feature = "jiff")]
422            stmt::Type::Zoned => self.storage_types.default_zoned_type.bridge_type(ty),
423            #[cfg(feature = "jiff")]
424            stmt::Type::Date => self.storage_types.default_date_type.bridge_type(ty),
425            #[cfg(feature = "jiff")]
426            stmt::Type::Time => self.storage_types.default_time_type.bridge_type(ty),
427            #[cfg(feature = "jiff")]
428            stmt::Type::DateTime => self.storage_types.default_datetime_type.bridge_type(ty),
429            _ => ty.clone(),
430        }
431    }
432
433    /// SQLite capabilities.
434    pub const SQLITE: Self = Self {
435        sql: true,
436        storage_types: StorageTypes::SQLITE,
437        schema_mutations: SchemaMutations::SQLITE,
438        cte_with_update: false,
439        select_for_update: false,
440        returning_from_mutation: true,
441        primary_key_ne_predicate: true,
442        auto_increment: true,
443        bigdecimal_implemented: false,
444
445        native_varchar: true,
446
447        // SQLite does not have native enum types; uses TEXT + CHECK
448        native_enum: false,
449        named_enum_types: false,
450
451        // SQLite does not have native date/time types
452        native_timestamp: false,
453        native_date: false,
454        native_time: false,
455        native_datetime: false,
456
457        // SQLite does not have native decimal types
458        native_decimal: false,
459        decimal_arbitrary_precision: false,
460
461        index_or_predicate: true,
462
463        native_starts_with: false,
464        native_like: true,
465
466        // SQLite's `LIKE` is case-insensitive for ASCII only; it has no
467        // `ILIKE` operator, so `.ilike()` is rejected here.
468        native_ilike: false,
469
470        // SQL drivers handle unindexed queries via QuerySql (see field doc).
471        scan: true,
472        scan_supports_sort: true,
473
474        test_connection_pool: false,
475
476        // SQLite exposes `BEGIN DEFERRED|IMMEDIATE|EXCLUSIVE` for
477        // lock-acquisition policy.
478        transaction_lock_mode: true,
479
480        backward_pagination: true,
481
482        // `Vec<scalar>` model fields land in a `TEXT` column holding a JSON
483        // document (JSON1 extension). The driver serializes `Value::List`
484        // to a JSON string at bind time, so the extract pass keeps the list
485        // as one `Value::List` parameter; the `InList` branch in
486        // `extract_params` covers the `IN (...)` case so this flag does
487        // not regress IN-list rendering. The predicate-side `ANY` rewrite
488        // is gated on `predicate_match_any`, which stays `false`, so
489        // `Path::contains` lowers to a `json_each` subquery instead.
490        bind_list_param: true,
491        predicate_match_any: false,
492
493        // SQLite has no native typed-array column type; `Vec<scalar>`
494        // model fields are stored as a JSON document in a `TEXT` column.
495        native_array: false,
496        vec_scalar: true,
497
498        // SQLite renders `IsSuperset` / `Intersects` as `json_each`
499        // subqueries that accept any rhs expression.
500        native_array_set_predicates: true,
501
502        // SQLite JSON1 has no value-removal operator on JSON arrays; pop
503        // and remove_at need a path expression built from
504        // `json_array_length`.
505        vec_remove: false,
506        vec_pop: false,
507        vec_remove_at: false,
508    };
509
510    /// PostgreSQL capabilities
511    pub const POSTGRESQL: Self = Self {
512        cte_with_update: true,
513        storage_types: StorageTypes::POSTGRESQL,
514        schema_mutations: SchemaMutations::POSTGRESQL,
515        select_for_update: true,
516        auto_increment: true,
517        bigdecimal_implemented: false,
518
519        // PostgreSQL has the `^@` prefix-match operator.
520        native_starts_with: true,
521
522        // PostgreSQL is the only backend with a native `ILIKE` operator.
523        native_ilike: true,
524
525        // PostgreSQL has CREATE TYPE ... AS ENUM
526        native_enum: true,
527        named_enum_types: true,
528
529        // PostgreSQL has native date/time types
530        native_timestamp: true,
531        native_date: true,
532        native_time: true,
533        native_datetime: true,
534
535        // PostgreSQL has native NUMERIC type with arbitrary precision
536        native_decimal: true,
537        decimal_arbitrary_precision: true,
538
539        test_connection_pool: true,
540
541        // PostgreSQL has no SQLite-style lock-mode keyword on BEGIN.
542        transaction_lock_mode: false,
543
544        // PostgreSQL accepts a single array-valued bind param and supports
545        // `expr <op> ANY(array)` / `<op> ALL(array)` predicates.
546        bind_list_param: true,
547        predicate_match_any: true,
548
549        // PostgreSQL: native arrays (`text[]`, `int8[]`, …) are the storage
550        // representation for `Vec<scalar>` model fields.
551        native_array: true,
552        vec_scalar: true,
553
554        // PostgreSQL: all three collection removals are atomic via native
555        // array operators / slicing.
556        vec_remove: true,
557        vec_pop: true,
558        vec_remove_at: true,
559
560        ..Self::SQLITE
561    };
562
563    /// MySQL capabilities
564    pub const MYSQL: Self = Self {
565        cte_with_update: false,
566        storage_types: StorageTypes::MYSQL,
567        schema_mutations: SchemaMutations::MYSQL,
568        select_for_update: true,
569        returning_from_mutation: false,
570        auto_increment: true,
571        bigdecimal_implemented: true,
572
573        // MySQL has inline ENUM('a', 'b') column types
574        native_enum: true,
575        named_enum_types: false,
576
577        // MySQL has native date/time types
578        native_timestamp: true,
579        native_date: true,
580        native_time: true,
581        native_datetime: true,
582
583        // MySQL has DECIMAL type but requires fixed precision/scale upfront
584        native_decimal: true,
585        decimal_arbitrary_precision: false,
586
587        test_connection_pool: true,
588
589        // MySQL has no SQLite-style lock-mode keyword on START TRANSACTION.
590        transaction_lock_mode: false,
591
592        // `Vec<scalar>` model fields land in a `JSON` column. The driver
593        // serializes `Value::List` to a JSON string at bind time, so the
594        // extract pass keeps the list as one `Value::List` parameter
595        // instead of expanding it (the `InList` branch in
596        // `extract_params` covers the `IN (...)` case so this flag does
597        // not regress the IN-list rendering).
598        bind_list_param: true,
599        vec_scalar: true,
600
601        ..Self::SQLITE
602    };
603
604    /// DynamoDB capabilities
605    pub const DYNAMODB: Self = Self {
606        sql: false,
607        storage_types: StorageTypes::DYNAMODB,
608        schema_mutations: SchemaMutations::DYNAMODB,
609        cte_with_update: false,
610        select_for_update: false,
611        returning_from_mutation: false,
612        primary_key_ne_predicate: false,
613        auto_increment: false,
614        bigdecimal_implemented: false,
615        native_varchar: false,
616        native_enum: false,
617        named_enum_types: false,
618
619        // DynamoDB does not have native date/time types
620        native_timestamp: false,
621        native_date: false,
622        native_time: false,
623        native_datetime: false,
624
625        // DynamoDB does not have native decimal types
626        native_decimal: false,
627        decimal_arbitrary_precision: false,
628
629        index_or_predicate: false,
630
631        // DynamoDB has `begins_with()` but no LIKE or ILIKE.
632        native_starts_with: true,
633        native_like: false,
634        native_ilike: false,
635
636        scan: true,
637        scan_supports_sort: false,
638
639        test_connection_pool: false,
640
641        // DynamoDB rejects `Operation::Transaction` wholesale.
642        transaction_lock_mode: false,
643
644        backward_pagination: false,
645
646        // DynamoDB: not SQL-based; the array-bind/`ANY`-predicate features do
647        // not apply.
648        bind_list_param: false,
649        predicate_match_any: false,
650
651        // DynamoDB has no SQL-style typed-array column type; the
652        // `db::Type::List(elem)` storage shape doesn't apply. `Vec<scalar>`
653        // model fields land directly on a List `L` attribute via the driver's
654        // `AttributeValue` encoding.
655        native_array: false,
656        vec_scalar: true,
657
658        // DynamoDB emulates `IsSuperset` / `Intersects` by expanding the rhs
659        // into one `contains(path, vN)` clause per element. The expansion
660        // requires the rhs to be a `Value::List` at filter-construction time
661        // — the capability check rejects any other rhs shape.
662        native_array_set_predicates: false,
663
664        // DynamoDB Lists have no atomic value-removal, and pop cannot be
665        // expressed because `UpdateExpression` indices must be literal
666        // integers.
667        vec_remove: false,
668        vec_pop: false,
669        vec_remove_at: false,
670    };
671}
672
673impl StorageTypes {
674    /// SQLite storage types
675    pub const SQLITE: StorageTypes = StorageTypes {
676        default_string_type: db::Type::Text,
677
678        // SQLite doesn't really enforce the "N" in VARCHAR(N) at all – it
679        // treats any type containing "CHAR", "CLOB", or "TEXT" as having TEXT
680        // affinity, and simply ignores the length specifier. In other words,
681        // whether you declare a column as VARCHAR(10), VARCHAR(1000000), or
682        // just TEXT, SQLite won't truncate or complain based on that number.
683        //
684        // Instead, the only hard limit on how big a string (or BLOB) can be is
685        // the SQLITE_MAX_LENGTH parameter, which is set to 1 billion by default.
686        varchar: Some(1_000_000_000),
687
688        // SQLite does not have an inbuilt UUID type. The binary blob type is more
689        // difficult to read than Text but likely has better performance characteristics.
690        default_uuid_type: db::Type::Blob,
691
692        default_bytes_type: db::Type::Blob,
693
694        // SQLite does not have a native decimal type. Store as TEXT.
695        default_decimal_type: db::Type::Text,
696        default_bigdecimal_type: db::Type::Text,
697
698        // SQLite does not have native date/time types. Store as TEXT in ISO 8601 format.
699        default_timestamp_type: db::Type::Text,
700        default_zoned_type: db::Type::Text,
701        default_date_type: db::Type::Text,
702        default_time_type: db::Type::Text,
703        default_datetime_type: db::Type::Text,
704
705        // SQLite INTEGER is a signed 64-bit integer, so unsigned integers
706        // are limited to i64::MAX to prevent overflow
707        max_unsigned_integer: Some(i64::MAX as u64),
708    };
709
710    /// PostgreSQL storage types.
711    pub const POSTGRESQL: StorageTypes = StorageTypes {
712        default_string_type: db::Type::Text,
713
714        // The maximum n you can specify is 10 485 760 characters. Attempts to
715        // declare varchar with a larger typmod will be rejected at
716        // table‐creation time.
717        varchar: Some(10_485_760),
718
719        default_uuid_type: db::Type::Uuid,
720
721        default_bytes_type: db::Type::Blob,
722
723        // PostgreSQL has native NUMERIC type for fixed and arbitrary-precision decimals.
724        default_decimal_type: db::Type::Numeric(None),
725        // TODO: PostgreSQL has native NUMERIC type for arbitrary-precision decimals,
726        // but the encoding is complicated and has to be done separately in the future.
727        default_bigdecimal_type: db::Type::Text,
728
729        // PostgreSQL has native support for temporal types with microsecond precision (6 digits)
730        default_timestamp_type: db::Type::Timestamp(6),
731        default_zoned_type: db::Type::Text,
732        default_date_type: db::Type::Date,
733        default_time_type: db::Type::Time(6),
734        default_datetime_type: db::Type::DateTime(6),
735
736        // PostgreSQL BIGINT is signed 64-bit, so unsigned integers are limited
737        // to i64::MAX. While NUMERIC could theoretically support larger values,
738        // we prefer explicit limits over implicit type switching.
739        max_unsigned_integer: Some(i64::MAX as u64),
740    };
741
742    /// MySQL storage types.
743    pub const MYSQL: StorageTypes = StorageTypes {
744        default_string_type: db::Type::VarChar(191),
745
746        // Values in VARCHAR columns are variable-length strings. The length can
747        // be specified as a value from 0 to 65,535. The effective maximum
748        // length of a VARCHAR is subject to the maximum row size (65,535 bytes,
749        // which is shared among all columns) and the character set used.
750        varchar: Some(65_535),
751
752        // MySQL does not have an inbuilt UUID type. The binary blob type is
753        // more difficult to read than Text but likely has better performance
754        // characteristics. However, limitations in the engine make it easier to
755        // use VarChar for now.
756        default_uuid_type: db::Type::VarChar(36),
757
758        default_bytes_type: db::Type::Blob,
759
760        // MySQL does not have an arbitrary-precision decimal type. The DECIMAL type
761        // requires a fixed precision and scale to be specified upfront. Store as TEXT.
762        default_decimal_type: db::Type::Text,
763        default_bigdecimal_type: db::Type::Text,
764
765        // MySQL has native support for temporal types with microsecond precision (6 digits)
766        // The `TIMESTAMP` time only supports a limited range (1970-2038), so we default to
767        // DATETIME and let Toasty do the UTC conversion.
768        default_timestamp_type: db::Type::DateTime(6),
769        default_zoned_type: db::Type::Text,
770        default_date_type: db::Type::Date,
771        default_time_type: db::Type::Time(6),
772        default_datetime_type: db::Type::DateTime(6),
773
774        // MySQL supports full u64 range via BIGINT UNSIGNED
775        max_unsigned_integer: None,
776    };
777
778    /// DynamoDB storage types.
779    pub const DYNAMODB: StorageTypes = StorageTypes {
780        default_string_type: db::Type::Text,
781
782        // DynamoDB does not support varchar types
783        varchar: None,
784
785        default_uuid_type: db::Type::Text,
786
787        default_bytes_type: db::Type::Blob,
788
789        // DynamoDB does not have a native decimal type. Store as TEXT.
790        default_decimal_type: db::Type::Text,
791        default_bigdecimal_type: db::Type::Text,
792
793        // DynamoDB does not have native date/time types. Store as TEXT (strings).
794        default_timestamp_type: db::Type::Text,
795        default_zoned_type: db::Type::Text,
796        default_date_type: db::Type::Text,
797        default_time_type: db::Type::Text,
798        default_datetime_type: db::Type::Text,
799
800        // DynamoDB supports full u64 range (numbers stored as strings)
801        max_unsigned_integer: None,
802    };
803}
804
805impl SchemaMutations {
806    /// SQLite schema mutation capabilities. SQLite cannot alter column types.
807    pub const SQLITE: Self = Self {
808        alter_column_type: false,
809        alter_column_properties_atomic: false,
810    };
811
812    /// PostgreSQL schema mutation capabilities. Supports altering column types
813    /// but not atomically changing multiple column properties.
814    pub const POSTGRESQL: Self = Self {
815        alter_column_type: true,
816        alter_column_properties_atomic: false,
817    };
818
819    /// MySQL schema mutation capabilities. Supports altering column types and
820    /// atomically changing multiple column properties in a single statement.
821    pub const MYSQL: Self = Self {
822        alter_column_type: true,
823        alter_column_properties_atomic: true,
824    };
825
826    /// DynamoDB schema mutation capabilities. Migrations are not currently supported.
827    pub const DYNAMODB: Self = Self {
828        alter_column_type: false,
829        alter_column_properties_atomic: false,
830    };
831}
832
833#[cfg(test)]
834mod tests {
835    use super::*;
836
837    #[test]
838    fn test_validate_sqlite_capability() {
839        // SQLite has native_varchar=true and varchar=Some, should pass
840        assert!(Capability::SQLITE.validate().is_ok());
841    }
842
843    #[test]
844    fn test_validate_postgresql_capability() {
845        // PostgreSQL has native_varchar=true and varchar=Some, should pass
846        assert!(Capability::POSTGRESQL.validate().is_ok());
847    }
848
849    #[test]
850    fn test_validate_mysql_capability() {
851        // MySQL has native_varchar=true and varchar=Some, should pass
852        assert!(Capability::MYSQL.validate().is_ok());
853    }
854
855    #[test]
856    fn test_validate_dynamodb_capability() {
857        // DynamoDB has native_varchar=false and varchar=None, should pass
858        assert!(Capability::DYNAMODB.validate().is_ok());
859    }
860
861    #[test]
862    fn test_validate_fails_when_native_varchar_true_but_no_varchar() {
863        let invalid = Capability {
864            native_varchar: true,
865            storage_types: StorageTypes {
866                varchar: None, // Invalid: native_varchar is true but varchar is None
867                ..StorageTypes::SQLITE
868            },
869            ..Capability::SQLITE
870        };
871
872        let result = invalid.validate();
873        assert!(result.is_err());
874        assert!(
875            result
876                .unwrap_err()
877                .to_string()
878                .contains("native_varchar is true but storage_types.varchar is None")
879        );
880    }
881
882    #[test]
883    fn test_validate_fails_when_native_varchar_false_but_has_varchar() {
884        let invalid = Capability {
885            native_varchar: false,
886            storage_types: StorageTypes {
887                varchar: Some(1000), // Invalid: native_varchar is false but varchar is Some
888                ..StorageTypes::DYNAMODB
889            },
890            ..Capability::DYNAMODB
891        };
892
893        let result = invalid.validate();
894        assert!(result.is_err());
895        assert!(
896            result
897                .unwrap_err()
898                .to_string()
899                .contains("native_varchar is false but storage_types.varchar is Some")
900        );
901    }
902}