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    /// Schema mutation capabilities supported by the datbase.
34    pub schema_mutations: SchemaMutations,
35
36    /// SQL: supports update statements in CTE queries.
37    pub cte_with_update: bool,
38
39    /// SQL: Supports row-level locking. If false, then the driver is expected
40    /// to serializable transaction-level isolation.
41    pub select_for_update: bool,
42
43    /// SQL: Mysql doesn't support returning clauses from insert / update queries
44    pub returning_from_mutation: bool,
45
46    /// DynamoDB does not support != predicates on the primary key.
47    pub primary_key_ne_predicate: bool,
48
49    /// Whether the database has an auto increment modifier for integer columns.
50    pub auto_increment: bool,
51
52    /// Whether the database supports `VARCHAR(n)` column types natively.
53    ///
54    /// Must be consistent with [`StorageTypes::varchar`]: when `true`,
55    /// `varchar` must be `Some`; when `false`, `varchar` must be `None`.
56    /// Use [`Capability::validate`] to check this invariant.
57    pub native_varchar: bool,
58
59    /// Whether the database has native support for Timestamp types.
60    pub native_timestamp: bool,
61
62    /// Whether the database has native support for Date types.
63    pub native_date: bool,
64
65    /// Whether the database has native support for Time types.
66    pub native_time: bool,
67
68    /// Whether the database has native support for DateTime types.
69    pub native_datetime: bool,
70
71    /// Whether the database supports native enum types.
72    ///
73    /// - PostgreSQL: `true` — `CREATE TYPE ... AS ENUM`
74    /// - MySQL: `true` — inline `ENUM('a', 'b')` column type
75    /// - SQLite: `false` — uses `TEXT` + `CHECK` constraint
76    /// - DynamoDB: `false` — plain string attribute
77    pub native_enum: bool,
78
79    /// Whether enum types are standalone named objects requiring separate DDL.
80    ///
81    /// When `true`, migrations must emit `CREATE TYPE` / `ALTER TYPE` for enum
82    /// types. When `false`, enum definitions are inline in column types.
83    ///
84    /// - PostgreSQL: `true` — `CREATE TYPE <name> AS ENUM (...)`
85    /// - MySQL: `false` — inline `ENUM('a', 'b')` on the column
86    /// - SQLite: `false`
87    /// - DynamoDB: `false`
88    pub named_enum_types: bool,
89
90    /// Whether the database has native support for Decimal types.
91    pub native_decimal: bool,
92
93    /// Whether BigDecimal driver support is implemented.
94    /// TODO: Remove this flag when PostgreSQL BigDecimal support is implemented.
95    /// Currently only MySQL has implemented BigDecimal driver support.
96    pub bigdecimal_implemented: bool,
97
98    /// Whether the database's decimal type supports arbitrary precision.
99    /// When false, the decimal type requires fixed precision and scale to be specified upfront.
100    /// - PostgreSQL: true (NUMERIC supports arbitrary precision)
101    /// - MySQL: false (DECIMAL requires fixed precision/scale)
102    /// - SQLite/DynamoDB: false (no native decimal support, stored as TEXT)
103    pub decimal_arbitrary_precision: bool,
104
105    /// Whether OR is supported in index key conditions (e.g. DynamoDB KeyConditionExpression).
106    /// DynamoDB: false. All other backends: true (SQL backends never use index key conditions).
107    pub index_or_predicate: bool,
108
109    /// Whether the database has a native prefix-match operator that does not
110    /// require LIKE-style escaping. When `true`, `starts_with` is left in the
111    /// AST and the driver renders it natively (DynamoDB's `begins_with()`,
112    /// PostgreSQL's `^@`). When `false`, the lowering rewrites it to a
113    /// `LIKE` expression — which requires `native_like` to be `true`.
114    pub native_starts_with: bool,
115
116    /// Whether the database has a native `LIKE` expression. When `false`,
117    /// `Expr::Like` cannot be sent to the driver; `starts_with` lowering
118    /// will not produce one.
119    pub native_like: bool,
120
121    /// Whether to test connection pool behavior.
122    /// TODO: We only need this for the `connection_per_clone.rs` test, come up with a better way.
123    pub test_connection_pool: bool,
124}
125
126/// Maps application-level types to the concrete database column types used for
127/// storage.
128///
129/// Each database has different native type support. For example, PostgreSQL has
130/// a native `UUID` type while SQLite stores UUIDs as `BLOB`. This struct
131/// captures those mappings so the schema layer can generate correct DDL and the
132/// driver can encode/decode values appropriately.
133///
134/// Pre-built configurations: [`SQLITE`](Self::SQLITE),
135/// [`POSTGRESQL`](Self::POSTGRESQL), [`MYSQL`](Self::MYSQL),
136/// [`DYNAMODB`](Self::DYNAMODB).
137///
138/// # Examples
139///
140/// ```
141/// use toasty_core::driver::StorageTypes;
142///
143/// let st = &StorageTypes::POSTGRESQL;
144/// // PostgreSQL stores UUIDs natively
145/// assert!(matches!(st.default_uuid_type, toasty_core::schema::db::Type::Uuid));
146/// ```
147#[derive(Debug)]
148pub struct StorageTypes {
149    /// The default storage type for a string.
150    pub default_string_type: db::Type,
151
152    /// When `Some` the database supports varchar types with the specified upper
153    /// limit.
154    pub varchar: Option<u64>,
155
156    /// The default storage type for a UUID.
157    pub default_uuid_type: db::Type,
158
159    /// The default storage type for Bytes (Vec<u8>).
160    pub default_bytes_type: db::Type,
161
162    /// The default storage type for a Decimal (fixed-precision decimal).
163    pub default_decimal_type: db::Type,
164
165    /// The default storage type for a BigDecimal (arbitrary-precision decimal).
166    pub default_bigdecimal_type: db::Type,
167
168    /// The default storage type for a Timestamp (instant in time).
169    pub default_timestamp_type: db::Type,
170
171    /// The default storage type for a Zoned (timezone-aware instant).
172    pub default_zoned_type: db::Type,
173
174    /// The default storage type for a Date (civil date).
175    pub default_date_type: db::Type,
176
177    /// The default storage type for a Time (wall clock time).
178    pub default_time_type: db::Type,
179
180    /// The default storage type for a DateTime (civil datetime).
181    pub default_datetime_type: db::Type,
182
183    /// Maximum value for unsigned integers. When `Some`, unsigned integers
184    /// are limited to this value. When `None`, full u64 range is supported.
185    pub max_unsigned_integer: Option<u64>,
186}
187
188/// The database's capabilities to mutate the schema (tables, columns, indices).
189///
190/// Used by the migration generator to decide how to express schema changes.
191/// For example, SQLite cannot alter column types so migrations must recreate
192/// the table instead.
193///
194/// Pre-built configurations: [`SQLITE`](Self::SQLITE),
195/// [`POSTGRESQL`](Self::POSTGRESQL), [`MYSQL`](Self::MYSQL),
196/// [`DYNAMODB`](Self::DYNAMODB).
197///
198/// # Examples
199///
200/// Access through [`Capability::schema_mutations`]:
201///
202/// ```
203/// use toasty_core::driver::Capability;
204///
205/// let cap = &Capability::POSTGRESQL;
206/// assert!(cap.schema_mutations.alter_column_type);
207/// assert!(!cap.schema_mutations.alter_column_properties_atomic);
208/// ```
209#[derive(Debug)]
210pub struct SchemaMutations {
211    /// Whether the database can change the type of an existing column.
212    pub alter_column_type: bool,
213
214    /// Whether the database can change name, type and constraints of a column all
215    /// withing a single statement.
216    pub alter_column_properties_atomic: bool,
217}
218
219impl Capability {
220    /// Validates the consistency of the capability configuration.
221    ///
222    /// This performs sanity checks to ensure the capability fields are
223    /// internally consistent. For example, if `native_varchar` is true,
224    /// then `storage_types.varchar` must be Some, and vice versa.
225    ///
226    /// Returns an error if any inconsistencies are found.
227    pub fn validate(&self) -> crate::Result<()> {
228        // Validate varchar consistency
229        if self.native_varchar && self.storage_types.varchar.is_none() {
230            return Err(crate::Error::invalid_driver_configuration(
231                "native_varchar is true but storage_types.varchar is None",
232            ));
233        }
234
235        if !self.native_varchar && self.storage_types.varchar.is_some() {
236            return Err(crate::Error::invalid_driver_configuration(
237                "native_varchar is false but storage_types.varchar is Some",
238            ));
239        }
240
241        Ok(())
242    }
243
244    /// Returns the default string length limit for this database.
245    ///
246    /// This is useful for tests and applications that need to respect
247    /// database-specific string length constraints.
248    pub fn default_string_max_length(&self) -> Option<u64> {
249        match &self.storage_types.default_string_type {
250            db::Type::VarChar(len) => Some(*len),
251            _ => None, // Handle other types gracefully
252        }
253    }
254
255    /// Returns the native database type for an application-level type.
256    ///
257    /// If the database supports the type natively, returns the same type.
258    /// Otherwise, returns the bridge/storage type that the application type
259    /// maps to in this database.
260    ///
261    /// This uses the existing `db::Type::bridge_type()` method to determine
262    /// the appropriate bridge type based on the database's storage capabilities.
263    pub fn native_type_for(&self, ty: &stmt::Type) -> stmt::Type {
264        match ty {
265            stmt::Type::Uuid => self.storage_types.default_uuid_type.bridge_type(ty),
266            #[cfg(feature = "jiff")]
267            stmt::Type::Timestamp => self.storage_types.default_timestamp_type.bridge_type(ty),
268            #[cfg(feature = "jiff")]
269            stmt::Type::Zoned => self.storage_types.default_zoned_type.bridge_type(ty),
270            #[cfg(feature = "jiff")]
271            stmt::Type::Date => self.storage_types.default_date_type.bridge_type(ty),
272            #[cfg(feature = "jiff")]
273            stmt::Type::Time => self.storage_types.default_time_type.bridge_type(ty),
274            #[cfg(feature = "jiff")]
275            stmt::Type::DateTime => self.storage_types.default_datetime_type.bridge_type(ty),
276            _ => ty.clone(),
277        }
278    }
279
280    /// SQLite capabilities.
281    pub const SQLITE: Self = Self {
282        sql: true,
283        storage_types: StorageTypes::SQLITE,
284        schema_mutations: SchemaMutations::SQLITE,
285        cte_with_update: false,
286        select_for_update: false,
287        returning_from_mutation: true,
288        primary_key_ne_predicate: true,
289        auto_increment: true,
290        bigdecimal_implemented: false,
291
292        native_varchar: true,
293
294        // SQLite does not have native enum types; uses TEXT + CHECK
295        native_enum: false,
296        named_enum_types: false,
297
298        // SQLite does not have native date/time types
299        native_timestamp: false,
300        native_date: false,
301        native_time: false,
302        native_datetime: false,
303
304        // SQLite does not have native decimal types
305        native_decimal: false,
306        decimal_arbitrary_precision: false,
307
308        index_or_predicate: true,
309
310        native_starts_with: false,
311        native_like: true,
312
313        test_connection_pool: false,
314    };
315
316    /// PostgreSQL capabilities
317    pub const POSTGRESQL: Self = Self {
318        cte_with_update: true,
319        storage_types: StorageTypes::POSTGRESQL,
320        schema_mutations: SchemaMutations::POSTGRESQL,
321        select_for_update: true,
322        auto_increment: true,
323        bigdecimal_implemented: false,
324
325        // PostgreSQL has the `^@` prefix-match operator.
326        native_starts_with: true,
327
328        // PostgreSQL has CREATE TYPE ... AS ENUM
329        native_enum: true,
330        named_enum_types: true,
331
332        // PostgreSQL has native date/time types
333        native_timestamp: true,
334        native_date: true,
335        native_time: true,
336        native_datetime: true,
337
338        // PostgreSQL has native NUMERIC type with arbitrary precision
339        native_decimal: true,
340        decimal_arbitrary_precision: true,
341
342        test_connection_pool: true,
343
344        ..Self::SQLITE
345    };
346
347    /// MySQL capabilities
348    pub const MYSQL: Self = Self {
349        cte_with_update: false,
350        storage_types: StorageTypes::MYSQL,
351        schema_mutations: SchemaMutations::MYSQL,
352        select_for_update: true,
353        returning_from_mutation: false,
354        auto_increment: true,
355        bigdecimal_implemented: true,
356
357        // MySQL has inline ENUM('a', 'b') column types
358        native_enum: true,
359        named_enum_types: false,
360
361        // MySQL has native date/time types
362        native_timestamp: true,
363        native_date: true,
364        native_time: true,
365        native_datetime: true,
366
367        // MySQL has DECIMAL type but requires fixed precision/scale upfront
368        native_decimal: true,
369        decimal_arbitrary_precision: false,
370
371        test_connection_pool: true,
372
373        ..Self::SQLITE
374    };
375
376    /// DynamoDB capabilities
377    pub const DYNAMODB: Self = Self {
378        sql: false,
379        storage_types: StorageTypes::DYNAMODB,
380        schema_mutations: SchemaMutations::DYNAMODB,
381        cte_with_update: false,
382        select_for_update: false,
383        returning_from_mutation: false,
384        primary_key_ne_predicate: false,
385        auto_increment: false,
386        bigdecimal_implemented: false,
387        native_varchar: false,
388        native_enum: false,
389        named_enum_types: false,
390
391        // DynamoDB does not have native date/time types
392        native_timestamp: false,
393        native_date: false,
394        native_time: false,
395        native_datetime: false,
396
397        // DynamoDB does not have native decimal types
398        native_decimal: false,
399        decimal_arbitrary_precision: false,
400
401        index_or_predicate: false,
402
403        // DynamoDB has `begins_with()` but no LIKE.
404        native_starts_with: true,
405        native_like: false,
406
407        test_connection_pool: false,
408    };
409}
410
411impl StorageTypes {
412    /// SQLite storage types
413    pub const SQLITE: StorageTypes = StorageTypes {
414        default_string_type: db::Type::Text,
415
416        // SQLite doesn't really enforce the "N" in VARCHAR(N) at all – it
417        // treats any type containing "CHAR", "CLOB", or "TEXT" as having TEXT
418        // affinity, and simply ignores the length specifier. In other words,
419        // whether you declare a column as VARCHAR(10), VARCHAR(1000000), or
420        // just TEXT, SQLite won't truncate or complain based on that number.
421        //
422        // Instead, the only hard limit on how big a string (or BLOB) can be is
423        // the SQLITE_MAX_LENGTH parameter, which is set to 1 billion by default.
424        varchar: Some(1_000_000_000),
425
426        // SQLite does not have an inbuilt UUID type. The binary blob type is more
427        // difficult to read than Text but likely has better performance characteristics.
428        default_uuid_type: db::Type::Blob,
429
430        default_bytes_type: db::Type::Blob,
431
432        // SQLite does not have a native decimal type. Store as TEXT.
433        default_decimal_type: db::Type::Text,
434        default_bigdecimal_type: db::Type::Text,
435
436        // SQLite does not have native date/time types. Store as TEXT in ISO 8601 format.
437        default_timestamp_type: db::Type::Text,
438        default_zoned_type: db::Type::Text,
439        default_date_type: db::Type::Text,
440        default_time_type: db::Type::Text,
441        default_datetime_type: db::Type::Text,
442
443        // SQLite INTEGER is a signed 64-bit integer, so unsigned integers
444        // are limited to i64::MAX to prevent overflow
445        max_unsigned_integer: Some(i64::MAX as u64),
446    };
447
448    /// PostgreSQL storage types.
449    pub const POSTGRESQL: StorageTypes = StorageTypes {
450        default_string_type: db::Type::Text,
451
452        // The maximum n you can specify is 10 485 760 characters. Attempts to
453        // declare varchar with a larger typmod will be rejected at
454        // table‐creation time.
455        varchar: Some(10_485_760),
456
457        default_uuid_type: db::Type::Uuid,
458
459        default_bytes_type: db::Type::Blob,
460
461        // PostgreSQL has native NUMERIC type for fixed and arbitrary-precision decimals.
462        default_decimal_type: db::Type::Numeric(None),
463        // TODO: PostgreSQL has native NUMERIC type for arbitrary-precision decimals,
464        // but the encoding is complicated and has to be done separately in the future.
465        default_bigdecimal_type: db::Type::Text,
466
467        // PostgreSQL has native support for temporal types with microsecond precision (6 digits)
468        default_timestamp_type: db::Type::Timestamp(6),
469        default_zoned_type: db::Type::Text,
470        default_date_type: db::Type::Date,
471        default_time_type: db::Type::Time(6),
472        default_datetime_type: db::Type::DateTime(6),
473
474        // PostgreSQL BIGINT is signed 64-bit, so unsigned integers are limited
475        // to i64::MAX. While NUMERIC could theoretically support larger values,
476        // we prefer explicit limits over implicit type switching.
477        max_unsigned_integer: Some(i64::MAX as u64),
478    };
479
480    /// MySQL storage types.
481    pub const MYSQL: StorageTypes = StorageTypes {
482        default_string_type: db::Type::VarChar(191),
483
484        // Values in VARCHAR columns are variable-length strings. The length can
485        // be specified as a value from 0 to 65,535. The effective maximum
486        // length of a VARCHAR is subject to the maximum row size (65,535 bytes,
487        // which is shared among all columns) and the character set used.
488        varchar: Some(65_535),
489
490        // MySQL does not have an inbuilt UUID type. The binary blob type is
491        // more difficult to read than Text but likely has better performance
492        // characteristics. However, limitations in the engine make it easier to
493        // use VarChar for now.
494        default_uuid_type: db::Type::VarChar(36),
495
496        default_bytes_type: db::Type::Blob,
497
498        // MySQL does not have an arbitrary-precision decimal type. The DECIMAL type
499        // requires a fixed precision and scale to be specified upfront. Store as TEXT.
500        default_decimal_type: db::Type::Text,
501        default_bigdecimal_type: db::Type::Text,
502
503        // MySQL has native support for temporal types with microsecond precision (6 digits)
504        // The `TIMESTAMP` time only supports a limited range (1970-2038), so we default to
505        // DATETIME and let Toasty do the UTC conversion.
506        default_timestamp_type: db::Type::DateTime(6),
507        default_zoned_type: db::Type::Text,
508        default_date_type: db::Type::Date,
509        default_time_type: db::Type::Time(6),
510        default_datetime_type: db::Type::DateTime(6),
511
512        // MySQL supports full u64 range via BIGINT UNSIGNED
513        max_unsigned_integer: None,
514    };
515
516    /// DynamoDB storage types.
517    pub const DYNAMODB: StorageTypes = StorageTypes {
518        default_string_type: db::Type::Text,
519
520        // DynamoDB does not support varchar types
521        varchar: None,
522
523        default_uuid_type: db::Type::Text,
524
525        default_bytes_type: db::Type::Blob,
526
527        // DynamoDB does not have a native decimal type. Store as TEXT.
528        default_decimal_type: db::Type::Text,
529        default_bigdecimal_type: db::Type::Text,
530
531        // DynamoDB does not have native date/time types. Store as TEXT (strings).
532        default_timestamp_type: db::Type::Text,
533        default_zoned_type: db::Type::Text,
534        default_date_type: db::Type::Text,
535        default_time_type: db::Type::Text,
536        default_datetime_type: db::Type::Text,
537
538        // DynamoDB supports full u64 range (numbers stored as strings)
539        max_unsigned_integer: None,
540    };
541}
542
543impl SchemaMutations {
544    /// SQLite schema mutation capabilities. SQLite cannot alter column types.
545    pub const SQLITE: Self = Self {
546        alter_column_type: false,
547        alter_column_properties_atomic: false,
548    };
549
550    /// PostgreSQL schema mutation capabilities. Supports altering column types
551    /// but not atomically changing multiple column properties.
552    pub const POSTGRESQL: Self = Self {
553        alter_column_type: true,
554        alter_column_properties_atomic: false,
555    };
556
557    /// MySQL schema mutation capabilities. Supports altering column types and
558    /// atomically changing multiple column properties in a single statement.
559    pub const MYSQL: Self = Self {
560        alter_column_type: true,
561        alter_column_properties_atomic: true,
562    };
563
564    /// DynamoDB schema mutation capabilities. Migrations are not currently supported.
565    pub const DYNAMODB: Self = Self {
566        alter_column_type: false,
567        alter_column_properties_atomic: false,
568    };
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574
575    #[test]
576    fn test_validate_sqlite_capability() {
577        // SQLite has native_varchar=true and varchar=Some, should pass
578        assert!(Capability::SQLITE.validate().is_ok());
579    }
580
581    #[test]
582    fn test_validate_postgresql_capability() {
583        // PostgreSQL has native_varchar=true and varchar=Some, should pass
584        assert!(Capability::POSTGRESQL.validate().is_ok());
585    }
586
587    #[test]
588    fn test_validate_mysql_capability() {
589        // MySQL has native_varchar=true and varchar=Some, should pass
590        assert!(Capability::MYSQL.validate().is_ok());
591    }
592
593    #[test]
594    fn test_validate_dynamodb_capability() {
595        // DynamoDB has native_varchar=false and varchar=None, should pass
596        assert!(Capability::DYNAMODB.validate().is_ok());
597    }
598
599    #[test]
600    fn test_validate_fails_when_native_varchar_true_but_no_varchar() {
601        let invalid = Capability {
602            native_varchar: true,
603            storage_types: StorageTypes {
604                varchar: None, // Invalid: native_varchar is true but varchar is None
605                ..StorageTypes::SQLITE
606            },
607            ..Capability::SQLITE
608        };
609
610        let result = invalid.validate();
611        assert!(result.is_err());
612        assert!(
613            result
614                .unwrap_err()
615                .to_string()
616                .contains("native_varchar is true but storage_types.varchar is None")
617        );
618    }
619
620    #[test]
621    fn test_validate_fails_when_native_varchar_false_but_has_varchar() {
622        let invalid = Capability {
623            native_varchar: false,
624            storage_types: StorageTypes {
625                varchar: Some(1000), // Invalid: native_varchar is false but varchar is Some
626                ..StorageTypes::DYNAMODB
627            },
628            ..Capability::DYNAMODB
629        };
630
631        let result = invalid.validate();
632        assert!(result.is_err());
633        assert!(
634            result
635                .unwrap_err()
636                .to_string()
637                .contains("native_varchar is false but storage_types.varchar is Some")
638        );
639    }
640}