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}