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