Skip to main content

toasty_core/driver/
capability.rs

1use super::Dialect;
2use crate::{schema::db, stmt};
3
4/// Describes what a database driver supports.
5///
6/// The query planner reads these flags to decide which [`Operation`](super::Operation)
7/// variants to generate. For example, a SQL driver names its dialect in `sql`
8/// and receives `QuerySql` operations, while DynamoDB leaves `sql` as `None`
9/// and receives key-value operations like `GetByKey` and `QueryPk`.
10///
11/// Pre-built configurations are available as associated constants:
12/// [`SQLITE`](Self::SQLITE), [`POSTGRESQL`](Self::POSTGRESQL),
13/// [`MYSQL`](Self::MYSQL), and [`DYNAMODB`](Self::DYNAMODB).
14///
15/// # Examples
16///
17/// ```
18/// use toasty_core::driver::Capability;
19///
20/// let cap = &Capability::SQLITE;
21/// assert!(cap.sql());
22/// assert!(cap.returning_from_mutation);
23/// assert!(!cap.select_for_update);
24/// ```
25#[derive(Debug)]
26pub struct Capability {
27    /// Human-readable driver name used in diagnostics.
28    pub driver_name: &'static str,
29
30    /// The SQL dialect this driver speaks, selecting how statements are
31    /// rendered.
32    ///
33    /// `Some` means the database uses a SQL-based query language, so the
34    /// planner emits [`QuerySql`](super::operation::QuerySql) operations.
35    /// Non-SQL drivers set this to `None` and receive key-value operations
36    /// instead. [`sql()`](Self::sql) is the boolean view of this field.
37    pub sql: Option<Dialect>,
38
39    /// Placeholder syntax accepted by the driver's SQL bind layer.
40    ///
41    /// SQL drivers set this to `Some`. Non-SQL drivers set this to `None`.
42    pub sql_placeholder: Option<SqlPlaceholder>,
43
44    /// Column storage types supported by the database.
45    pub storage_types: StorageTypes,
46
47    /// What the database is able to change about its own schema. See
48    /// [`SchemaMutations`] for the individual fields; the migration
49    /// generator branches on them to choose between an in-place
50    /// `ALTER COLUMN` and a table rebuild, and between one combined
51    /// alter statement and several single-property ones.
52    pub schema_mutations: SchemaMutations,
53
54    /// SQL: supports update statements in CTE queries.
55    pub cte_with_update: bool,
56
57    /// SQL: Supports row-level locking. If false, then the driver is expected
58    /// to serializable transaction-level isolation.
59    pub select_for_update: bool,
60
61    /// SQL: Mysql doesn't support returning clauses from insert / update queries
62    pub returning_from_mutation: bool,
63
64    /// Whether an upsert may target the table's primary key.
65    ///
66    /// When `false`, the verifier returns `unsupported_feature` before
67    /// dispatching a primary-key upsert to the driver.
68    pub upsert_primary_key: bool,
69
70    /// Whether an upsert may target a secondary unique constraint.
71    ///
72    /// The driver must match the exact lowered target columns rather than
73    /// reacting to an arbitrary unique conflict.
74    pub upsert_unique: bool,
75
76    /// Whether an upsert can apply arbitrary separate `on_create` and
77    /// `on_update` assignments.
78    ///
79    /// A driver with this capability must select the branch atomically within
80    /// the database operation; it cannot read first and choose a second write.
81    /// Drivers without this capability may still accept branch patterns that
82    /// map to native conditional assignments.
83    pub upsert_branch_assignments: bool,
84
85    /// Whether an insert-or-ignore upsert suppresses only the selected target's
86    /// conflict.
87    ///
88    /// Other uniqueness conflicts and validation errors must remain errors.
89    pub upsert_targeted_ignore: bool,
90
91    /// DynamoDB does not support != predicates on the primary key.
92    pub primary_key_ne_predicate: bool,
93
94    /// Whether the database has an auto increment modifier for integer columns.
95    pub auto_increment: bool,
96
97    /// Maximum storage width, in bytes, for auto-increment integer columns.
98    ///
99    /// Backends that require a particular declared type for auto-increment
100    /// columns use this to cap the storage type selected from the Rust field
101    /// type. SQLite requires the declared type to be `INTEGER` when using
102    /// `AUTOINCREMENT`; Toasty's SQLite serializer emits that spelling for
103    /// `Integer(4)`.
104    pub max_auto_increment_integer_width: Option<u8>,
105
106    /// Maximum byte length for a database identifier (table name, index name,
107    /// column name, etc.).
108    ///
109    /// When `Some(n)`, auto-generated index names that exceed `n` bytes are
110    /// truncated and a short stable hash suffix is appended so names remain
111    /// unique and deterministic across builds. User-supplied `#[index(name =
112    /// "...")]` names are left untouched.
113    ///
114    /// - MySQL: `Some(64)` — hard error on longer names
115    /// - PostgreSQL: `Some(63)` — silently truncates, risking collisions
116    /// - SQLite / DynamoDB: `None` — no enforced limit
117    pub max_identifier_length: Option<usize>,
118
119    /// Whether the database supports `VARCHAR(n)` column types natively.
120    ///
121    /// Must be consistent with [`StorageTypes::varchar`]: when `true`,
122    /// `varchar` must be `Some`; when `false`, `varchar` must be `None`.
123    /// Use [`Capability::validate`] to check this invariant.
124    pub native_varchar: bool,
125
126    /// Whether the database supports native `JSON` columns.
127    pub native_json: bool,
128
129    /// Whether the database supports native `JSONB` columns.
130    pub native_jsonb: bool,
131
132    /// Whether the database has native support for Timestamp types.
133    pub native_timestamp: bool,
134
135    /// Whether the database has native support for Date types.
136    pub native_date: bool,
137
138    /// Whether the database has native support for Time types.
139    pub native_time: bool,
140
141    /// Whether the database has native support for DateTime types.
142    pub native_datetime: bool,
143
144    /// Whether the database has a native CIDR network type.
145    pub native_cidr: bool,
146
147    /// Whether the database has a native INET address type.
148    pub native_inet: bool,
149
150    /// Whether the database has a native six-byte MACADDR type.
151    pub native_macaddr: bool,
152
153    /// Whether the database has a native eight-byte MACADDR8 type.
154    pub native_macaddr8: bool,
155
156    /// Whether the database supports native enum types.
157    ///
158    /// - PostgreSQL: `true` — `CREATE TYPE ... AS ENUM`
159    /// - MySQL: `true` — inline `ENUM('a', 'b')` column type
160    /// - SQLite: `false` — uses `TEXT` + `CHECK` constraint
161    /// - DynamoDB: `false` — plain string attribute
162    pub native_enum: bool,
163
164    /// Whether enum types are standalone named objects requiring separate DDL.
165    ///
166    /// When `true`, migrations must emit `CREATE TYPE` / `ALTER TYPE` for enum
167    /// types. When `false`, enum definitions are inline in column types.
168    ///
169    /// - PostgreSQL: `true` — `CREATE TYPE <name> AS ENUM (...)`
170    /// - MySQL: `false` — inline `ENUM('a', 'b')` on the column
171    /// - SQLite: `false`
172    /// - DynamoDB: `false`
173    pub named_enum_types: bool,
174
175    /// Whether the database has native support for Decimal types.
176    pub native_decimal: bool,
177
178    /// Whether BigDecimal driver support is implemented.
179    /// TODO: Remove this flag when PostgreSQL BigDecimal support is implemented.
180    /// Currently only MySQL has implemented BigDecimal driver support.
181    pub bigdecimal_implemented: bool,
182
183    /// Whether the database's decimal type supports arbitrary precision.
184    /// When false, the decimal type requires fixed precision and scale to be specified upfront.
185    /// - PostgreSQL: true (NUMERIC supports arbitrary precision)
186    /// - MySQL: false (DECIMAL requires fixed precision/scale)
187    /// - SQLite/DynamoDB: false (no native decimal support, stored as TEXT)
188    pub decimal_arbitrary_precision: bool,
189
190    /// Whether OR is supported in index key conditions (e.g. DynamoDB KeyConditionExpression).
191    /// DynamoDB: false. All other backends: true (SQL backends never use index key conditions).
192    pub index_or_predicate: bool,
193
194    /// Whether the database has a native prefix-match operator that does not
195    /// require LIKE-style escaping. When `true`, `starts_with` is left in the
196    /// AST and the driver renders it natively (DynamoDB's `begins_with()`,
197    /// PostgreSQL's `^@`, SQLite's `GLOB`, MySQL's `LIKE BINARY`). When
198    /// `false`, the lowering rewrites it to a `LIKE` expression — which
199    /// requires `native_like` to be `true`.
200    pub native_starts_with: bool,
201
202    /// Whether `starts_with` should be rendered as a SQLite `GLOB 'prefix*'`
203    /// expression. When `true`, `extract_params` escapes GLOB metacharacters
204    /// (`*`, `?`, `[`) in the prefix and appends `*`; the serializer emits
205    /// `col GLOB ?`. Implies `native_starts_with`.
206    pub glob_starts_with: bool,
207
208    /// Whether `starts_with` should be rendered as MySQL `BINARY col LIKE ?
209    /// ESCAPE '!'`. When `true`, `extract_params` escapes LIKE metacharacters
210    /// using `!` as the escape char and appends `%`; the serializer emits
211    /// `BINARY col LIKE ? ESCAPE '!'`. Implies `native_starts_with`.
212    pub binary_like_starts_with: bool,
213
214    /// Whether the database has a native `LIKE` expression. When `false`,
215    /// `Expr::Like` cannot be sent to the driver; `starts_with` lowering
216    /// will not produce one.
217    pub native_like: bool,
218
219    /// Whether the database has a native case-insensitive `LIKE` operator
220    /// (`ILIKE`). Only PostgreSQL has one.
221    ///
222    /// Toasty does not emulate `ILIKE` on backends that lack it: `.ilike()`
223    /// is a pass-through to the database's own operator. When `native_ilike`
224    /// is `false`, the query-verify pass rejects a case-insensitive
225    /// `Expr::Like` with an
226    /// [`unsupported_feature`](crate::Error::unsupported_feature) error rather
227    /// than silently degrading to plain `LIKE`, whose case behavior differs.
228    ///
229    /// Implies `native_like`.
230    pub native_ilike: bool,
231
232    /// Whether the driver can answer queries that don't match any primary key
233    /// or index — i.e. supports unindexed full-table reads.
234    ///
235    /// SQL drivers set this to `true`: unindexed queries go through
236    /// [`QuerySql`](super::operation::QuerySql), so the SQL engine handles
237    /// them transparently. DynamoDB also sets this to `true`; the planner
238    /// emits [`Operation::Scan`](super::Operation::Scan) for the unindexed
239    /// case. A hypothetical pure key-value store with no full-scan capability
240    /// would set this to `false`.
241    pub scan: bool,
242
243    /// Whether scan operations support ordering results.
244    ///
245    /// SQL drivers do not use `Operation::Scan`, so this is `true` for them
246    /// (ordering is handled inside `QuerySql`). DynamoDB's `Scan` API returns
247    /// items in an arbitrary order with no server-side sort, so this is `false`
248    /// for DynamoDB. When `false`, the planner rejects queries that combine a
249    /// scan path with `ORDER BY`.
250    pub scan_supports_sort: bool,
251
252    /// Whether to test connection pool behavior.
253    /// TODO: We only need this for the `connection_per_clone.rs` test, come up with a better way.
254    pub test_connection_pool: bool,
255
256    /// Whether the driver honors non-`Default`
257    /// [`TransactionMode`](super::operation::TransactionMode) variants
258    /// (`Immediate`, `Exclusive`). Currently `true` only for SQLite, which
259    /// maps them to `BEGIN IMMEDIATE` / `BEGIN EXCLUSIVE`. Drivers that
260    /// leave this `false` reject non-`Default` modes with
261    /// [`Error::unsupported_feature`](crate::Error::unsupported_feature).
262    pub transaction_lock_mode: bool,
263
264    /// Whether the backend can walk a paginated query in reverse from a
265    /// cursor.
266    ///
267    /// Gates the `prev_cursor` field on a `Page` returned to user code.
268    /// When `true`, the executor extracts a previous-page cursor from the
269    /// first row of every page (see `apply_sql_pagination` in
270    /// `toasty/src/engine/exec/exec_statement.rs`). When `false`, the
271    /// executor leaves `prev_cursor` as `None`, so
272    /// `Page::has_prev()` returns `false` and `Page::prev(&db)` resolves
273    /// to `Ok(None)` without issuing a query. `Paginate::before(cursor)`
274    /// itself is not rejected — users who already hold a cursor can walk
275    /// backwards explicitly — but a driver that returns `false` is
276    /// declaring that it has no way to *produce* such a cursor.
277    ///
278    /// Drivers should set this to `true` when the backend can answer a
279    /// query equivalent to "rows ordered by K, descending from K = c,
280    /// limited to N" — i.e. the same `ORDER BY` clause reversed plus a
281    /// strict inequality on the cursor key. SQL backends meet this
282    /// trivially. DynamoDB does not: a `Query` with `ScanIndexForward =
283    /// false` returns rows in the opposite direction but cannot be
284    /// rooted at an arbitrary client-supplied cursor without an extra
285    /// `KeyConditionExpression`, and `Scan` has no order guarantee at
286    /// all.
287    pub backward_pagination: bool,
288
289    /// Whether ascending SQL ordering places `NULL` before non-null values.
290    ///
291    /// Cursor pagination uses this to generate predicates that match the
292    /// backend's native `ORDER BY` behavior. Descending ordering uses the
293    /// opposite placement.
294    pub sql_nulls_first_on_asc: bool,
295
296    /// Whether the backend supports `BOOL` as a key attribute type.
297    ///
298    /// DynamoDB only allows `S`, `N`, or `B` for primary-key and GSI key
299    /// attribute types; `BOOL` is rejected at the API level. SQL backends
300    /// have no such restriction. When `false`, the schema builder overrides
301    /// `storage_ty` for any `Bool` key/index field to `db::Type::Integer(1)`,
302    /// letting the engine cast `Bool ↔ I8` and the driver handle it as a
303    /// plain number — no driver-level bool-to-number special-casing needed.
304    pub bool_key_type: bool,
305
306    /// The driver's bind layer accepts a single parameter whose value is
307    /// `Value::List(items)` and type is `Type::List(elem)`, sending it as
308    /// one protocol-level parameter (not N separate scalars).
309    /// Property of the driver bind impl, not the SQL dialect.
310    pub bind_list_param: bool,
311
312    /// The SQL dialect parses `expr <op> ANY(<array>)` and `expr <op> ALL(<array>)`
313    /// as predicates against an array-valued operand.
314    /// Property of the dialect, not the bind layer.
315    pub predicate_match_any: bool,
316
317    /// Whether the database can store a `Vec<scalar>` model field as a native
318    /// array column (e.g. PostgreSQL `text[]`, `int8[]`).
319    ///
320    /// When `true`, schema build maps `Type::List(elem)` to `db::Type::List(elem)`
321    /// and the driver's bind layer accepts `Value::List(items)` as a single
322    /// array-valued parameter.
323    ///
324    /// When `false`, `Vec<T>` model fields use whatever fallback the backend
325    /// provides (JSON column on MySQL/SQLite, native List `L` on DynamoDB).
326    /// See [`Self::vec_scalar`] for the schema-build gate.
327    pub native_array: bool,
328
329    /// Whether the driver supports `Vec<scalar>` model fields, by whatever
330    /// representation (native typed array column, JSON column, key-value
331    /// list attribute, ...). Used by the schema builder as the gate for
332    /// accepting `stmt::Type::List(_)` fields.
333    pub vec_scalar: bool,
334
335    /// Whether the database can enforce a unique constraint on the complete
336    /// ordered value of a native list column.
337    ///
338    /// This is narrower than [`Self::native_array`]: storing a list as an
339    /// array does not by itself guarantee that the backend has an index type
340    /// whose equality semantics preserve element order and multiplicity.
341    pub unique_list_index: bool,
342
343    /// Whether the driver can store a `#[document]` collection field — a
344    /// `Vec<T>` of an embedded struct — as a single document column
345    /// (`jsonb` / `JSON` on the SQL backends). Used by the schema builder as
346    /// the gate for accepting `stmt::Type::List(Document(_))` fields.
347    pub document_collections: bool,
348
349    /// Whether the driver natively renders `IsSuperset` / `Intersects` array
350    /// predicates over an arbitrary right-hand-side expression.
351    ///
352    /// SQL drivers set this to `true`: each dialect has a single operator
353    /// (`@>` on PostgreSQL, `JSON_CONTAINS` on MySQL, a `json_each`
354    /// subquery on SQLite) that takes the rhs as a bound expression
355    /// regardless of its shape.
356    ///
357    /// DynamoDB sets this to `false`: it has no equivalent operator and
358    /// emulates the predicates by emitting one `contains(path, vN)` clause
359    /// per rhs element, which requires the rhs to be a concrete list of
360    /// values at filter-construction time. The capability check rejects
361    /// any other rhs shape before the driver is invoked.
362    pub native_array_set_predicates: bool,
363
364    /// Whether the driver supports atomic in-place removal of every element
365    /// equal to a given value from a `Vec<scalar>` field (`stmt::remove`).
366    ///
367    /// - PostgreSQL `text[]`: `true` — `array_remove(col, v)`.
368    /// - MySQL / SQLite JSON: `false` — no value-removal operator.
369    /// - DynamoDB List: `false` — no value-removal on Lists.
370    pub vec_remove: bool,
371
372    /// Whether the driver supports atomic in-place removal of the last
373    /// element of a `Vec<scalar>` field (`stmt::pop`).
374    ///
375    /// - PostgreSQL: `true` — array slicing.
376    /// - MySQL / SQLite: `false`.
377    /// - DynamoDB: `false` — `UpdateExpression` indices must be literal
378    ///   integers, so the last index cannot be expressed in one statement.
379    pub vec_pop: bool,
380
381    /// Whether the driver supports atomic in-place removal of an element at a
382    /// given index from a `Vec<scalar>` field (`stmt::remove_at`).
383    ///
384    /// - PostgreSQL: `true` — array slicing.
385    /// - MySQL / SQLite: `false`.
386    /// - DynamoDB: `false`.
387    pub vec_remove_at: bool,
388}
389
390/// Maps application-level types to the concrete database column types used for
391/// storage.
392///
393/// Each database has different native type support. For example, PostgreSQL has
394/// a native `UUID` type while SQLite stores UUIDs as `BLOB`. This struct
395/// captures those mappings so the schema layer can generate correct DDL and the
396/// driver can encode/decode values appropriately.
397///
398/// Pre-built configurations: [`SQLITE`](Self::SQLITE),
399/// [`POSTGRESQL`](Self::POSTGRESQL), [`MYSQL`](Self::MYSQL),
400/// [`DYNAMODB`](Self::DYNAMODB).
401///
402/// # Examples
403///
404/// ```
405/// use toasty_core::driver::StorageTypes;
406///
407/// let st = &StorageTypes::POSTGRESQL;
408/// // PostgreSQL stores UUIDs natively
409/// assert!(matches!(st.default_uuid_type, toasty_core::schema::db::Type::Uuid));
410/// ```
411#[derive(Debug)]
412pub struct StorageTypes {
413    /// The default storage type for a string.
414    pub default_string_type: db::Type,
415
416    /// When `Some` the database supports varchar types with the specified upper
417    /// limit.
418    pub varchar: Option<u64>,
419
420    /// The default storage type for a UUID.
421    pub default_uuid_type: db::Type,
422
423    /// The default storage type for Bytes (Vec<u8>).
424    pub default_bytes_type: db::Type,
425
426    /// The default storage type for a Decimal (fixed-precision decimal).
427    pub default_decimal_type: db::Type,
428
429    /// The default storage type for a BigDecimal (arbitrary-precision decimal).
430    pub default_bigdecimal_type: db::Type,
431
432    /// The default storage type for a Timestamp (instant in time).
433    pub default_timestamp_type: db::Type,
434
435    /// The default storage type for a Zoned (timezone-aware instant).
436    pub default_zoned_type: db::Type,
437
438    /// The default storage type for a Date (civil date).
439    pub default_date_type: db::Type,
440
441    /// The default storage type for a Time (wall clock time).
442    pub default_time_type: db::Type,
443
444    /// The default storage type for a DateTime (civil datetime).
445    pub default_datetime_type: db::Type,
446
447    /// The default storage type for an IP network prefix.
448    pub default_cidr_type: db::Type,
449
450    /// The default storage type for an IP host address and prefix.
451    pub default_inet_type: db::Type,
452
453    /// The default storage type for a six-byte MAC address.
454    pub default_macaddr_type: db::Type,
455
456    /// The default storage type for an eight-byte MAC address.
457    pub default_macaddr8_type: db::Type,
458
459    /// Maximum value for unsigned integers. When `Some`, unsigned integers
460    /// are limited to this value. When `None`, full u64 range is supported.
461    pub max_unsigned_integer: Option<u64>,
462}
463
464/// The database's capabilities to mutate the schema (tables, columns, indices).
465///
466/// Used by the migration generator to decide how to express each
467/// column change. `alter_column_type` gates whether an in-place
468/// `ALTER COLUMN` is possible at all — SQLite has it set to `false`,
469/// and a type change there triggers a full table rebuild (create
470/// new table, copy rows, drop old). `alter_column_properties_atomic`
471/// decides whether several column-property changes (rename, retype,
472/// `NOT NULL`, default) collapse into one statement or emit one per
473/// property. MySQL sets both to `true`; PostgreSQL alters in place
474/// but requires one statement per property.
475///
476/// Pre-built configurations: [`SQLITE`](Self::SQLITE),
477/// [`POSTGRESQL`](Self::POSTGRESQL), [`MYSQL`](Self::MYSQL),
478/// [`DYNAMODB`](Self::DYNAMODB).
479///
480/// # Examples
481///
482/// Access through [`Capability::schema_mutations`]:
483///
484/// ```
485/// use toasty_core::driver::Capability;
486///
487/// let cap = &Capability::POSTGRESQL;
488/// assert!(cap.schema_mutations.alter_column_type);
489/// assert!(!cap.schema_mutations.alter_column_properties_atomic);
490/// ```
491#[derive(Debug)]
492pub struct SchemaMutations {
493    /// Whether the database can change the type of an existing column.
494    pub alter_column_type: bool,
495
496    /// Whether the database can change name, type and constraints of a column all
497    /// withing a single statement.
498    pub alter_column_properties_atomic: bool,
499}
500
501/// SQL bind-parameter placeholder syntax accepted by a driver.
502///
503/// This describes the SQL text users must write when sending raw SQL through
504/// [`RawSql`](super::operation::RawSql). The SQL serializer uses the same
505/// value when rendering Toasty-generated SQL.
506#[derive(Debug, Clone, Copy, PartialEq, Eq)]
507pub enum SqlPlaceholder {
508    /// Positional `?` placeholders, where parameter order is the occurrence
509    /// order in the SQL string.
510    QuestionMark,
511
512    /// Numbered `?1`, `?2`, ... placeholders.
513    NumberedQuestionMark,
514
515    /// Numbered `$1`, `$2`, ... placeholders.
516    DollarNumber,
517}
518
519impl Capability {
520    /// Whether the database uses a SQL-based query language.
521    ///
522    /// The boolean view of [`sql`](Self::sql), for the callers that only need
523    /// to know whether SQL is spoken at all and not which dialect.
524    ///
525    /// # Examples
526    ///
527    /// ```
528    /// use toasty_core::driver::Capability;
529    ///
530    /// assert!(Capability::SQLITE.sql());
531    /// assert!(!Capability::DYNAMODB.sql());
532    /// ```
533    pub const fn sql(&self) -> bool {
534        self.sql.is_some()
535    }
536
537    /// Validates the consistency of the capability configuration.
538    ///
539    /// This performs sanity checks to ensure the capability fields are
540    /// internally consistent. For example, if `native_varchar` is true,
541    /// then `storage_types.varchar` must be Some, and vice versa.
542    ///
543    /// Returns an error if any inconsistencies are found.
544    pub fn validate(&self) -> crate::Result<()> {
545        // Validate varchar consistency
546        if self.native_varchar && self.storage_types.varchar.is_none() {
547            return Err(crate::Error::invalid_driver_configuration(
548                "native_varchar is true but storage_types.varchar is None",
549            ));
550        }
551
552        if !self.native_varchar && self.storage_types.varchar.is_some() {
553            return Err(crate::Error::invalid_driver_configuration(
554                "native_varchar is false but storage_types.varchar is Some",
555            ));
556        }
557
558        // ILIKE is a case-insensitive LIKE; a backend cannot offer it without
559        // a native LIKE.
560        if self.native_ilike && !self.native_like {
561            return Err(crate::Error::invalid_driver_configuration(
562                "native_ilike is true but native_like is false",
563            ));
564        }
565
566        if self.glob_starts_with && !self.native_starts_with {
567            return Err(crate::Error::invalid_driver_configuration(
568                "glob_starts_with is true but native_starts_with is false",
569            ));
570        }
571
572        if self.binary_like_starts_with && !self.native_starts_with {
573            return Err(crate::Error::invalid_driver_configuration(
574                "binary_like_starts_with is true but native_starts_with is false",
575            ));
576        }
577
578        if self.glob_starts_with && self.binary_like_starts_with {
579            return Err(crate::Error::invalid_driver_configuration(
580                "glob_starts_with and binary_like_starts_with cannot both be true",
581            ));
582        }
583
584        if self.sql() && self.sql_placeholder.is_none() {
585            return Err(crate::Error::invalid_driver_configuration(
586                "sql is Some but sql_placeholder is None",
587            ));
588        }
589
590        if !self.sql() && self.sql_placeholder.is_some() {
591            return Err(crate::Error::invalid_driver_configuration(
592                "sql is None but sql_placeholder is Some",
593            ));
594        }
595
596        if self.unique_list_index && !self.native_array {
597            return Err(crate::Error::invalid_driver_configuration(
598                "unique_list_index is true but native_array is false",
599            ));
600        }
601
602        Ok(())
603    }
604
605    /// Returns the default string length limit for this database.
606    ///
607    /// This is useful for tests and applications that need to respect
608    /// database-specific string length constraints.
609    pub fn default_string_max_length(&self) -> Option<u64> {
610        match &self.storage_types.default_string_type {
611            db::Type::VarChar(len) => Some(*len),
612            _ => None, // Handle other types gracefully
613        }
614    }
615
616    /// Returns the native database type for an application-level type.
617    ///
618    /// If the database supports the type natively, returns the same type.
619    /// Otherwise, returns the bridge/storage type that the application type
620    /// maps to in this database.
621    ///
622    /// This uses the existing `db::Type::bridge_type()` method to determine
623    /// the appropriate bridge type based on the database's storage capabilities.
624    pub fn native_type_for(&self, ty: &stmt::Type) -> stmt::Type {
625        match ty {
626            stmt::Type::Uuid => self.storage_types.default_uuid_type.bridge_type(ty),
627            #[cfg(feature = "jiff")]
628            stmt::Type::Timestamp => self.storage_types.default_timestamp_type.bridge_type(ty),
629            #[cfg(feature = "jiff")]
630            stmt::Type::Zoned => self.storage_types.default_zoned_type.bridge_type(ty),
631            #[cfg(feature = "jiff")]
632            stmt::Type::Date => self.storage_types.default_date_type.bridge_type(ty),
633            #[cfg(feature = "jiff")]
634            stmt::Type::Time => self.storage_types.default_time_type.bridge_type(ty),
635            #[cfg(feature = "jiff")]
636            stmt::Type::DateTime => self.storage_types.default_datetime_type.bridge_type(ty),
637            #[cfg(feature = "net")]
638            stmt::Type::Cidr => self.storage_types.default_cidr_type.bridge_type(ty),
639            #[cfg(feature = "net")]
640            stmt::Type::Inet => self.storage_types.default_inet_type.bridge_type(ty),
641            #[cfg(feature = "net")]
642            stmt::Type::MacAddr => self.storage_types.default_macaddr_type.bridge_type(ty),
643            #[cfg(feature = "net")]
644            stmt::Type::MacAddr8 => self.storage_types.default_macaddr8_type.bridge_type(ty),
645            _ => ty.clone(),
646        }
647    }
648
649    /// SQLite capabilities.
650    pub const SQLITE: Self = Self {
651        driver_name: "SQLite",
652        sql: Some(Dialect::Sqlite),
653        sql_placeholder: Some(SqlPlaceholder::NumberedQuestionMark),
654        storage_types: StorageTypes::SQLITE,
655        schema_mutations: SchemaMutations::SQLITE,
656        cte_with_update: false,
657        select_for_update: false,
658        returning_from_mutation: true,
659        upsert_primary_key: true,
660        upsert_unique: true,
661        upsert_branch_assignments: true,
662        upsert_targeted_ignore: true,
663        primary_key_ne_predicate: true,
664        auto_increment: true,
665        max_auto_increment_integer_width: Some(4),
666        bigdecimal_implemented: false,
667        bool_key_type: true,
668        max_identifier_length: None,
669
670        native_varchar: true,
671        native_json: false,
672        native_jsonb: false,
673
674        // SQLite does not have native enum types; uses TEXT + CHECK
675        native_enum: false,
676        named_enum_types: false,
677
678        // SQLite does not have native date/time types
679        native_timestamp: false,
680        native_date: false,
681        native_time: false,
682        native_datetime: false,
683
684        native_cidr: false,
685        native_inet: false,
686        native_macaddr: false,
687        native_macaddr8: false,
688
689        // SQLite does not have native decimal types
690        native_decimal: false,
691        decimal_arbitrary_precision: false,
692
693        index_or_predicate: true,
694
695        // SQLite's GLOB operator is case-sensitive and is used for starts_with.
696        // LIKE is preserved for user-supplied `.like()` calls.
697        native_starts_with: true,
698        glob_starts_with: true,
699        binary_like_starts_with: false,
700        native_like: true,
701
702        // SQLite's `LIKE` is case-insensitive for ASCII only; it has no
703        // `ILIKE` operator, so `.ilike()` is rejected here.
704        native_ilike: false,
705
706        // SQL drivers handle unindexed queries via QuerySql (see field doc).
707        scan: true,
708        scan_supports_sort: true,
709
710        test_connection_pool: false,
711
712        // SQLite exposes `BEGIN DEFERRED|IMMEDIATE|EXCLUSIVE` for
713        // lock-acquisition policy.
714        transaction_lock_mode: true,
715
716        backward_pagination: true,
717        sql_nulls_first_on_asc: true,
718
719        // `Vec<scalar>` model fields land in a `TEXT` column holding a JSON
720        // document (JSON1 extension). The driver serializes `Value::List`
721        // to a JSON string at bind time, so the extract pass keeps the list
722        // as one `Value::List` parameter; the `InList` branch in
723        // `extract_params` covers the `IN (...)` case so this flag does
724        // not regress IN-list rendering. The predicate-side `ANY` rewrite
725        // is gated on `predicate_match_any`, which stays `false`, so
726        // `Path::contains` lowers to a `json_each` subquery instead.
727        bind_list_param: true,
728        predicate_match_any: false,
729
730        // SQLite has no native typed-array column type; `Vec<scalar>`
731        // model fields are stored as a JSON document in a `TEXT` column.
732        native_array: false,
733        vec_scalar: true,
734        unique_list_index: false,
735        document_collections: true,
736
737        // SQLite renders `IsSuperset` / `Intersects` as `json_each`
738        // subqueries that accept any rhs expression.
739        native_array_set_predicates: true,
740
741        // SQLite JSON1 has no value-removal operator on JSON arrays; pop
742        // and remove_at need a path expression built from
743        // `json_array_length`.
744        vec_remove: false,
745        vec_pop: false,
746        vec_remove_at: false,
747    };
748
749    /// PostgreSQL capabilities
750    pub const POSTGRESQL: Self = Self {
751        driver_name: "PostgreSQL",
752        cte_with_update: true,
753        sql: Some(Dialect::Postgresql),
754        sql_placeholder: Some(SqlPlaceholder::DollarNumber),
755        storage_types: StorageTypes::POSTGRESQL,
756        schema_mutations: SchemaMutations::POSTGRESQL,
757        select_for_update: true,
758        auto_increment: true,
759        max_auto_increment_integer_width: None,
760        bigdecimal_implemented: false,
761        max_identifier_length: Some(63),
762
763        // PostgreSQL has the `^@` prefix-match operator.
764        native_starts_with: true,
765        glob_starts_with: false,
766        binary_like_starts_with: false,
767
768        // PostgreSQL is the only backend with a native `ILIKE` operator.
769        native_ilike: true,
770
771        // PostgreSQL has CREATE TYPE ... AS ENUM
772        native_enum: true,
773        named_enum_types: true,
774        native_json: true,
775        native_jsonb: true,
776
777        // PostgreSQL has native date/time types
778        native_timestamp: true,
779        native_date: true,
780        native_time: true,
781        native_datetime: true,
782
783        // PostgreSQL has native network address types.
784        native_cidr: true,
785        native_inet: true,
786        native_macaddr: true,
787        native_macaddr8: true,
788
789        // PostgreSQL has native NUMERIC type with arbitrary precision
790        native_decimal: true,
791        decimal_arbitrary_precision: true,
792
793        test_connection_pool: true,
794
795        // PostgreSQL has no SQLite-style lock-mode keyword on BEGIN.
796        transaction_lock_mode: false,
797        sql_nulls_first_on_asc: false,
798
799        // PostgreSQL accepts a single array-valued bind param and supports
800        // `expr <op> ANY(array)` / `<op> ALL(array)` predicates.
801        bind_list_param: true,
802        predicate_match_any: true,
803
804        // PostgreSQL: native arrays (`text[]`, `int8[]`, …) are the storage
805        // representation for `Vec<scalar>` model fields.
806        native_array: true,
807        vec_scalar: true,
808        unique_list_index: true,
809        document_collections: true,
810
811        // PostgreSQL: all three collection removals are atomic via native
812        // array operators / slicing.
813        vec_remove: true,
814        vec_pop: true,
815        vec_remove_at: true,
816
817        ..Self::SQLITE
818    };
819
820    /// MySQL capabilities
821    pub const MYSQL: Self = Self {
822        driver_name: "MySQL",
823        cte_with_update: false,
824        sql: Some(Dialect::Mysql),
825        sql_placeholder: Some(SqlPlaceholder::QuestionMark),
826        storage_types: StorageTypes::MYSQL,
827        schema_mutations: SchemaMutations::MYSQL,
828        select_for_update: true,
829        returning_from_mutation: false,
830        upsert_primary_key: false,
831        upsert_unique: false,
832        upsert_branch_assignments: false,
833        upsert_targeted_ignore: false,
834        auto_increment: true,
835        max_auto_increment_integer_width: None,
836        bigdecimal_implemented: true,
837        max_identifier_length: Some(64),
838
839        // MySQL has inline ENUM('a', 'b') column types
840        native_enum: true,
841        named_enum_types: false,
842        native_json: true,
843
844        // MySQL has native date/time types
845        native_timestamp: true,
846        native_date: true,
847        native_time: true,
848        native_datetime: true,
849
850        // MySQL has DECIMAL type but requires fixed precision/scale upfront
851        native_decimal: true,
852        decimal_arbitrary_precision: false,
853
854        test_connection_pool: true,
855
856        // MySQL has no SQLite-style lock-mode keyword on START TRANSACTION.
857        transaction_lock_mode: false,
858
859        // `Vec<scalar>` model fields land in a `JSON` column. The driver
860        // serializes `Value::List` to a JSON string at bind time, so the
861        // extract pass keeps the list as one `Value::List` parameter
862        // instead of expanding it (the `InList` branch in
863        // `extract_params` covers the `IN (...)` case so this flag does
864        // not regress the IN-list rendering).
865        bind_list_param: true,
866        vec_scalar: true,
867        document_collections: true,
868
869        // MySQL uses BINARY col LIKE ? ESCAPE '!' for case-sensitive starts_with.
870        glob_starts_with: false,
871        binary_like_starts_with: true,
872
873        ..Self::SQLITE
874    };
875
876    /// Turso capabilities.
877    ///
878    /// Identical to [`SQLITE`](Self::SQLITE) at the flag level. The driver
879    /// extends SQLite's behavior in two ways that don't fit a capability
880    /// bit:
881    ///
882    /// * It opens a real async connection per pool slot (sharing a cached
883    ///   `Database` across `connect()` calls), so the connection-pool test
884    ///   suite applies.
885    /// * When `Turso::concurrent_writes()` is enabled, the driver issues
886    ///   `BEGIN CONCURRENT` for `TransactionMode::Default`, opting the
887    ///   transaction into Turso's MVCC concurrency. The other
888    ///   `TransactionMode` variants pass through to the SQLite serializer
889    ///   unchanged, so callers can still request the classic locking
890    ///   strategies per transaction.
891    pub const TURSO: Self = Self {
892        driver_name: "Turso",
893        test_connection_pool: true,
894        ..Self::SQLITE
895    };
896
897    /// DynamoDB capabilities
898    pub const DYNAMODB: Self = Self {
899        driver_name: "DynamoDB",
900        sql: None,
901        sql_placeholder: None,
902        storage_types: StorageTypes::DYNAMODB,
903        schema_mutations: SchemaMutations::DYNAMODB,
904        cte_with_update: false,
905        select_for_update: false,
906        returning_from_mutation: false,
907        upsert_primary_key: true,
908        upsert_unique: false,
909        upsert_branch_assignments: false,
910        upsert_targeted_ignore: true,
911        primary_key_ne_predicate: false,
912        auto_increment: false,
913        max_auto_increment_integer_width: None,
914        bigdecimal_implemented: false,
915        max_identifier_length: None,
916        // DynamoDB key attributes (primary key and GSI keys) only support
917        // S, N, or B — BOOL is not a valid key attribute type.
918        bool_key_type: false,
919        native_varchar: false,
920        native_json: false,
921        native_jsonb: false,
922        native_enum: false,
923        named_enum_types: false,
924
925        // DynamoDB does not have native date/time types
926        native_timestamp: false,
927        native_date: false,
928        native_time: false,
929        native_datetime: false,
930
931        native_cidr: false,
932        native_inet: false,
933        native_macaddr: false,
934        native_macaddr8: false,
935
936        // DynamoDB does not have native decimal types
937        native_decimal: false,
938        decimal_arbitrary_precision: false,
939
940        index_or_predicate: false,
941
942        // DynamoDB has `begins_with()` but no LIKE or ILIKE.
943        native_starts_with: true,
944        glob_starts_with: false,
945        binary_like_starts_with: false,
946        native_like: false,
947        native_ilike: false,
948
949        scan: true,
950        scan_supports_sort: false,
951
952        test_connection_pool: false,
953
954        // DynamoDB rejects `Operation::Transaction` wholesale.
955        transaction_lock_mode: false,
956
957        backward_pagination: false,
958        sql_nulls_first_on_asc: false,
959
960        // DynamoDB: not SQL-based; the array-bind/`ANY`-predicate features do
961        // not apply.
962        bind_list_param: false,
963        predicate_match_any: false,
964
965        // DynamoDB has no SQL-style typed-array column type; the
966        // `db::Type::List(elem)` storage shape doesn't apply. `Vec<scalar>`
967        // model fields land directly on a List `L` attribute via the driver's
968        // `AttributeValue` encoding.
969        native_array: false,
970        vec_scalar: true,
971        unique_list_index: false,
972        // `#[document]` embeds store as a native Map `M` attribute (a
973        // `Vec<embed>` collection as a List `L` of Maps). DynamoDB caps
974        // attribute nesting at 32 levels; documents deeper than that are not
975        // rejected up front — the write surfaces DynamoDB's own error.
976        document_collections: true,
977
978        // DynamoDB emulates `IsSuperset` / `Intersects` by expanding the rhs
979        // into one `contains(path, vN)` clause per element. The expansion
980        // requires the rhs to be a `Value::List` at filter-construction time
981        // — the capability check rejects any other rhs shape.
982        native_array_set_predicates: false,
983
984        // DynamoDB Lists have no atomic value-removal, and pop cannot be
985        // expressed because `UpdateExpression` indices must be literal
986        // integers.
987        vec_remove: false,
988        vec_pop: false,
989        vec_remove_at: false,
990    };
991}
992
993impl StorageTypes {
994    /// SQLite storage types
995    pub const SQLITE: StorageTypes = StorageTypes {
996        default_string_type: db::Type::Text,
997
998        // SQLite doesn't really enforce the "N" in VARCHAR(N) at all – it
999        // treats any type containing "CHAR", "CLOB", or "TEXT" as having TEXT
1000        // affinity, and simply ignores the length specifier. In other words,
1001        // whether you declare a column as VARCHAR(10), VARCHAR(1000000), or
1002        // just TEXT, SQLite won't truncate or complain based on that number.
1003        //
1004        // Instead, the only hard limit on how big a string (or BLOB) can be is
1005        // the SQLITE_MAX_LENGTH parameter, which is set to 1 billion by default.
1006        varchar: Some(1_000_000_000),
1007
1008        // SQLite does not have an inbuilt UUID type. The binary blob type is more
1009        // difficult to read than Text but likely has better performance characteristics.
1010        default_uuid_type: db::Type::Blob,
1011
1012        default_bytes_type: db::Type::Blob,
1013
1014        // SQLite does not have a native decimal type. Store as TEXT.
1015        default_decimal_type: db::Type::Text,
1016        default_bigdecimal_type: db::Type::Text,
1017
1018        // SQLite does not have native date/time types. Store as TEXT in ISO 8601 format.
1019        default_timestamp_type: db::Type::Text,
1020        default_zoned_type: db::Type::Text,
1021        default_date_type: db::Type::Text,
1022        default_time_type: db::Type::Text,
1023        default_datetime_type: db::Type::Text,
1024
1025        // SQLite stores network address values as canonical text.
1026        default_cidr_type: db::Type::Text,
1027        default_inet_type: db::Type::Text,
1028        default_macaddr_type: db::Type::Text,
1029        default_macaddr8_type: db::Type::Text,
1030
1031        // SQLite INTEGER is a signed 64-bit integer, so unsigned integers
1032        // are limited to i64::MAX to prevent overflow
1033        max_unsigned_integer: Some(i64::MAX as u64),
1034    };
1035
1036    /// PostgreSQL storage types.
1037    pub const POSTGRESQL: StorageTypes = StorageTypes {
1038        default_string_type: db::Type::Text,
1039
1040        // The maximum n you can specify is 10 485 760 characters. Attempts to
1041        // declare varchar with a larger typmod will be rejected at
1042        // table‐creation time.
1043        varchar: Some(10_485_760),
1044
1045        default_uuid_type: db::Type::Uuid,
1046
1047        default_bytes_type: db::Type::Blob,
1048
1049        // PostgreSQL has native NUMERIC type for fixed and arbitrary-precision decimals.
1050        default_decimal_type: db::Type::Numeric(None),
1051        // TODO: PostgreSQL has native NUMERIC type for arbitrary-precision decimals,
1052        // but the encoding is complicated and has to be done separately in the future.
1053        default_bigdecimal_type: db::Type::Text,
1054
1055        // PostgreSQL has native support for temporal types with microsecond precision (6 digits)
1056        default_timestamp_type: db::Type::Timestamp(6),
1057        default_zoned_type: db::Type::Text,
1058        default_date_type: db::Type::Date,
1059        default_time_type: db::Type::Time(6),
1060        default_datetime_type: db::Type::DateTime(6),
1061
1062        default_cidr_type: db::Type::Cidr,
1063        default_inet_type: db::Type::Inet,
1064        default_macaddr_type: db::Type::MacAddr,
1065        default_macaddr8_type: db::Type::MacAddr8,
1066
1067        // PostgreSQL BIGINT is signed 64-bit, so unsigned integers are limited
1068        // to i64::MAX. While NUMERIC could theoretically support larger values,
1069        // we prefer explicit limits over implicit type switching.
1070        max_unsigned_integer: Some(i64::MAX as u64),
1071    };
1072
1073    /// MySQL storage types.
1074    pub const MYSQL: StorageTypes = StorageTypes {
1075        default_string_type: db::Type::VarChar(191),
1076
1077        // Values in VARCHAR columns are variable-length strings. The length can
1078        // be specified as a value from 0 to 65,535. The effective maximum
1079        // length of a VARCHAR is subject to the maximum row size (65,535 bytes,
1080        // which is shared among all columns) and the character set used.
1081        varchar: Some(65_535),
1082
1083        // MySQL does not have an inbuilt UUID type. The binary blob type is
1084        // more difficult to read than Text but likely has better performance
1085        // characteristics. However, limitations in the engine make it easier to
1086        // use VarChar for now.
1087        default_uuid_type: db::Type::VarChar(36),
1088
1089        default_bytes_type: db::Type::Blob,
1090
1091        // MySQL does not have an arbitrary-precision decimal type. The DECIMAL type
1092        // requires a fixed precision and scale to be specified upfront. Store as TEXT.
1093        default_decimal_type: db::Type::Text,
1094        default_bigdecimal_type: db::Type::Text,
1095
1096        // MySQL has native support for temporal types with microsecond precision (6 digits)
1097        // The `TIMESTAMP` time only supports a limited range (1970-2038), so we default to
1098        // DATETIME and let Toasty do the UTC conversion.
1099        default_timestamp_type: db::Type::DateTime(6),
1100        default_zoned_type: db::Type::Text,
1101        default_date_type: db::Type::Date,
1102        default_time_type: db::Type::Time(6),
1103        default_datetime_type: db::Type::DateTime(6),
1104
1105        // MySQL has no native network address types. Bounded text keeps
1106        // indexes compact while accommodating IPv6 prefixes and EUI-64.
1107        default_cidr_type: db::Type::VarChar(43),
1108        default_inet_type: db::Type::VarChar(43),
1109        default_macaddr_type: db::Type::VarChar(17),
1110        default_macaddr8_type: db::Type::VarChar(23),
1111
1112        // MySQL supports full u64 range via BIGINT UNSIGNED
1113        max_unsigned_integer: None,
1114    };
1115
1116    /// DynamoDB storage types.
1117    pub const DYNAMODB: StorageTypes = StorageTypes {
1118        default_string_type: db::Type::Text,
1119
1120        // DynamoDB does not support varchar types
1121        varchar: None,
1122
1123        default_uuid_type: db::Type::Text,
1124
1125        default_bytes_type: db::Type::Blob,
1126
1127        // DynamoDB does not have a native decimal type. Store as TEXT.
1128        default_decimal_type: db::Type::Text,
1129        default_bigdecimal_type: db::Type::Text,
1130
1131        // DynamoDB does not have native date/time types. Store as TEXT (strings).
1132        default_timestamp_type: db::Type::Text,
1133        default_zoned_type: db::Type::Text,
1134        default_date_type: db::Type::Text,
1135        default_time_type: db::Type::Text,
1136        default_datetime_type: db::Type::Text,
1137
1138        // DynamoDB stores network address values as canonical strings.
1139        default_cidr_type: db::Type::Text,
1140        default_inet_type: db::Type::Text,
1141        default_macaddr_type: db::Type::Text,
1142        default_macaddr8_type: db::Type::Text,
1143
1144        // DynamoDB supports full u64 range (numbers stored as strings)
1145        max_unsigned_integer: None,
1146    };
1147}
1148
1149impl SchemaMutations {
1150    /// SQLite schema mutation capabilities. SQLite cannot alter column types.
1151    pub const SQLITE: Self = Self {
1152        alter_column_type: false,
1153        alter_column_properties_atomic: false,
1154    };
1155
1156    /// PostgreSQL schema mutation capabilities. Supports altering column types
1157    /// but not atomically changing multiple column properties.
1158    pub const POSTGRESQL: Self = Self {
1159        alter_column_type: true,
1160        alter_column_properties_atomic: false,
1161    };
1162
1163    /// MySQL schema mutation capabilities. Supports altering column types and
1164    /// atomically changing multiple column properties in a single statement.
1165    pub const MYSQL: Self = Self {
1166        alter_column_type: true,
1167        alter_column_properties_atomic: true,
1168    };
1169
1170    /// DynamoDB schema mutation capabilities. Migrations are not currently supported.
1171    pub const DYNAMODB: Self = Self {
1172        alter_column_type: false,
1173        alter_column_properties_atomic: false,
1174    };
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179    use super::*;
1180
1181    #[test]
1182    fn test_validate_sqlite_capability() {
1183        // SQLite has native_varchar=true and varchar=Some, should pass
1184        assert!(Capability::SQLITE.validate().is_ok());
1185    }
1186
1187    #[test]
1188    fn test_validate_postgresql_capability() {
1189        // PostgreSQL has native_varchar=true and varchar=Some, should pass
1190        assert!(Capability::POSTGRESQL.validate().is_ok());
1191    }
1192
1193    #[test]
1194    fn test_validate_mysql_capability() {
1195        // MySQL has native_varchar=true and varchar=Some, should pass
1196        assert!(Capability::MYSQL.validate().is_ok());
1197    }
1198
1199    #[test]
1200    fn test_validate_dynamodb_capability() {
1201        // DynamoDB has native_varchar=false and varchar=None, should pass
1202        assert!(Capability::DYNAMODB.validate().is_ok());
1203    }
1204
1205    #[test]
1206    fn test_validate_fails_when_sql_has_no_placeholder() {
1207        let invalid = Capability {
1208            sql_placeholder: None,
1209            ..Capability::SQLITE
1210        };
1211
1212        let result = invalid.validate();
1213        assert!(result.is_err());
1214        assert!(
1215            result
1216                .unwrap_err()
1217                .to_string()
1218                .contains("sql is Some but sql_placeholder is None")
1219        );
1220    }
1221
1222    #[test]
1223    fn test_validate_fails_when_non_sql_has_placeholder() {
1224        let invalid = Capability {
1225            sql_placeholder: Some(SqlPlaceholder::QuestionMark),
1226            ..Capability::DYNAMODB
1227        };
1228
1229        let result = invalid.validate();
1230        assert!(result.is_err());
1231        assert!(
1232            result
1233                .unwrap_err()
1234                .to_string()
1235                .contains("sql is None but sql_placeholder is Some")
1236        );
1237    }
1238
1239    #[test]
1240    fn test_validate_fails_when_unique_list_index_has_no_native_array() {
1241        let invalid = Capability {
1242            unique_list_index: true,
1243            ..Capability::SQLITE
1244        };
1245
1246        let result = invalid.validate();
1247        assert!(result.is_err());
1248        assert!(
1249            result
1250                .unwrap_err()
1251                .to_string()
1252                .contains("unique_list_index is true but native_array is false")
1253        );
1254    }
1255
1256    #[test]
1257    fn test_validate_fails_when_native_varchar_true_but_no_varchar() {
1258        let invalid = Capability {
1259            native_varchar: true,
1260            storage_types: StorageTypes {
1261                varchar: None, // Invalid: native_varchar is true but varchar is None
1262                ..StorageTypes::SQLITE
1263            },
1264            ..Capability::SQLITE
1265        };
1266
1267        let result = invalid.validate();
1268        assert!(result.is_err());
1269        assert!(
1270            result
1271                .unwrap_err()
1272                .to_string()
1273                .contains("native_varchar is true but storage_types.varchar is None")
1274        );
1275    }
1276
1277    #[test]
1278    fn test_validate_fails_when_native_varchar_false_but_has_varchar() {
1279        let invalid = Capability {
1280            native_varchar: false,
1281            storage_types: StorageTypes {
1282                varchar: Some(1000), // Invalid: native_varchar is false but varchar is Some
1283                ..StorageTypes::DYNAMODB
1284            },
1285            ..Capability::DYNAMODB
1286        };
1287
1288        let result = invalid.validate();
1289        assert!(result.is_err());
1290        assert!(
1291            result
1292                .unwrap_err()
1293                .to_string()
1294                .contains("native_varchar is false but storage_types.varchar is Some")
1295        );
1296    }
1297}