Skip to main content

wasm_dbms/
schema.rs

1// Rust guideline compliant 2026-03-01
2// X-WHERE-CLAUSE, M-CANONICAL-DOCS
3
4use wasm_dbms_api::prelude::{
5    AggregateFunction, AggregatedRow, ColumnDef, DbmsResult, DeleteBehavior, Filter, JoinColumnDef,
6    Query, TableSchemaSnapshot, Value,
7};
8use wasm_dbms_memory::prelude::{AccessControl, AccessControlList, MemoryProvider};
9
10use crate::database::WasmDbmsDatabase;
11
12/// Provides schema-driven dynamic dispatch for database operations.
13///
14/// Implementations of this trait know which concrete table types exist
15/// and forward generic operations (identified by table name) to the
16/// appropriate typed methods on [`WasmDbmsDatabase`].
17///
18/// This trait is typically implemented by generated code from the
19/// `#[derive(DatabaseSchema)]` macro.
20pub trait DatabaseSchema<M, A = AccessControlList>
21where
22    M: MemoryProvider,
23    A: AccessControl,
24{
25    /// Performs a generic select for the given table name and query.
26    fn select(
27        &self,
28        dbms: &WasmDbmsDatabase<'_, M, A>,
29        table_name: &str,
30        query: Query,
31    ) -> DbmsResult<Vec<Vec<(ColumnDef, Value)>>>;
32
33    /// Performs a join query, returning results with column definitions
34    /// that include source table names.
35    fn select_join(
36        &self,
37        dbms: &WasmDbmsDatabase<'_, M, A>,
38        from_table: &str,
39        query: Query,
40    ) -> DbmsResult<Vec<Vec<(JoinColumnDef, Value)>>> {
41        crate::join::JoinEngine::new(self).join(dbms, from_table, query)
42    }
43
44    /// Performs an aggregate query for the given table name
45    fn aggregate(
46        &self,
47        dbms: &WasmDbmsDatabase<'_, M, A>,
48        table_name: &str,
49        query: Query,
50        aggregates: &[AggregateFunction],
51    ) -> DbmsResult<Vec<AggregatedRow>>;
52
53    /// Returns tables and columns that reference the given table via foreign keys.
54    fn referenced_tables(&self, table: &'static str) -> Vec<(&'static str, Vec<&'static str>)>;
55
56    /// Performs an insert for the given table name.
57    fn insert(
58        &self,
59        dbms: &WasmDbmsDatabase<'_, M, A>,
60        table_name: &'static str,
61        record_values: &[(ColumnDef, Value)],
62    ) -> DbmsResult<()>;
63
64    /// Performs a delete for the given table name.
65    fn delete(
66        &self,
67        dbms: &WasmDbmsDatabase<'_, M, A>,
68        table_name: &'static str,
69        delete_behavior: DeleteBehavior,
70        filter: Option<Filter>,
71    ) -> DbmsResult<u64>;
72
73    /// Performs an update for the given table name.
74    fn update(
75        &self,
76        dbms: &WasmDbmsDatabase<'_, M, A>,
77        table_name: &'static str,
78        patch_values: &[(ColumnDef, Value)],
79        filter: Option<Filter>,
80    ) -> DbmsResult<u64>;
81
82    /// Validates an insert operation.
83    fn validate_insert(
84        &self,
85        dbms: &WasmDbmsDatabase<'_, M, A>,
86        table_name: &'static str,
87        record_values: &[(ColumnDef, Value)],
88    ) -> DbmsResult<()>;
89
90    /// Validates an update operation.
91    fn validate_update(
92        &self,
93        dbms: &WasmDbmsDatabase<'_, M, A>,
94        table_name: &'static str,
95        record_values: &[(ColumnDef, Value)],
96        old_pk: Value,
97    ) -> DbmsResult<()>;
98
99    /// Returns the default value of a column for a given table, if any.
100    ///
101    /// Looks up the table's per-row [`Migrate::default_value`](
102    /// wasm_dbms_api::prelude::Migrate::default_value) hook, then falls back
103    /// to the `#[default]` attribute compiled into [`ColumnDef::default`].
104    /// Used by the migration planner to satisfy `AddColumn` ops on
105    /// non-nullable columns.
106    fn migrate_default(table: &str, column: &str) -> Option<Value>
107    where
108        Self: Sized;
109
110    /// Object-safe sibling of [`Self::migrate_default`].
111    ///
112    /// The macro emits a one-line dispatch to the `Sized` variant so callers
113    /// holding a `&dyn DatabaseSchema` can resolve `AddColumn` defaults
114    /// without re-genericising on `S`.
115    fn migrate_default_dyn(&self, table: &str, column: &str) -> Option<Value>;
116
117    /// Transforms a stored value when migrating a column to an incompatible
118    /// type by dispatching to the table's
119    /// [`Migrate::transform_column`](wasm_dbms_api::prelude::Migrate::transform_column)
120    /// hook.
121    fn migrate_transform(table: &str, column: &str, old: Value) -> DbmsResult<Option<Value>>
122    where
123        Self: Sized;
124
125    /// Object-safe sibling of [`Self::migrate_transform`].
126    fn migrate_transform_dyn(
127        &self,
128        table: &str,
129        column: &str,
130        old: Value,
131    ) -> DbmsResult<Option<Value>>;
132
133    /// Returns the compile-time [`TableSchemaSnapshot`] for every table in the
134    /// schema.
135    ///
136    /// Used by drift detection (boot path) and by the migration planner to
137    /// diff against the snapshots stored on disk.
138    fn compiled_snapshots() -> Vec<TableSchemaSnapshot>
139    where
140        Self: Sized;
141
142    /// Object-safe sibling of [`Self::compiled_snapshots`].
143    ///
144    /// `WasmDbmsDatabase` holds a `Box<dyn DatabaseSchema>`, so it cannot call
145    /// `compiled_snapshots()` directly (that method requires `Self: Sized`).
146    fn compiled_snapshots_dyn(&self) -> Vec<TableSchemaSnapshot>;
147
148    /// Returns the compile-time `renamed_from` chain for `column` on `table`.
149    ///
150    /// Used by the migration diff stage to detect rename operations: when a
151    /// compiled column does not match any stored column by name, the diff
152    /// walks this list looking for a stored column under a previous name.
153    fn renamed_from_dyn(&self, table: &str, column: &str) -> Vec<&'static str>;
154}
155
156#[cfg(test)]
157mod tests {
158    use wasm_dbms_api::prelude::{
159        Database as _, DbmsResult, InsertRecord as _, Migrate, Query, TableSchema as _, Text,
160        Uint32, Value,
161    };
162    use wasm_dbms_macros::{DatabaseSchema, Table};
163    use wasm_dbms_memory::prelude::HeapMemoryProvider;
164
165    use super::DatabaseSchema as _;
166    use crate::prelude::{DbmsContext, WasmDbmsDatabase};
167
168    #[derive(Debug, Table, Clone, PartialEq, Eq)]
169    #[table = "items"]
170    pub struct Item {
171        #[primary_key]
172        pub id: Uint32,
173        pub name: Text,
174    }
175
176    #[derive(Debug, Table, Clone, PartialEq, Eq)]
177    #[table = "products"]
178    pub struct Product {
179        #[primary_key]
180        pub id: Uint32,
181        #[index]
182        pub sku: Text,
183        #[index(group = "category_brand")]
184        pub category: Text,
185        #[index(group = "category_brand")]
186        pub brand: Text,
187    }
188
189    /// Table exercising the `#[default = ...]` field attribute. The literal
190    /// `42_u32` flows through the macro, gets wrapped in
191    /// `Value::from(...)`, and surfaces on `ColumnDef::default`.
192    #[derive(Debug, Table, Clone, PartialEq, Eq)]
193    #[table = "score_defaulted"]
194    pub struct ScoreDefaulted {
195        #[primary_key]
196        pub id: Uint32,
197        #[default = 42]
198        pub score: Uint32,
199    }
200
201    /// Table exercising the `#[renamed_from(...)]` field attribute. The
202    /// previous-name slice flows through to `ColumnDef::renamed_from` for
203    /// the migration planner to consume on rename detection.
204    #[derive(Debug, Table, Clone, PartialEq, Eq)]
205    #[table = "renamed_table"]
206    pub struct RenamedTable {
207        #[primary_key]
208        pub id: Uint32,
209        #[renamed_from("old_name", "older_name")]
210        pub name: Text,
211    }
212
213    /// Table opting into a manual `impl Migrate` via the `#[migrate]` struct
214    /// attribute. The macro must NOT emit its own empty impl, otherwise the
215    /// user impl below would be a duplicate.
216    #[derive(Debug, Table, Clone, PartialEq, Eq)]
217    #[table = "custom_migrate"]
218    #[migrate]
219    pub struct CustomMigrate {
220        #[primary_key]
221        pub id: Uint32,
222        pub label: Text,
223    }
224
225    impl Migrate for CustomMigrate {
226        fn default_value(column: &str) -> Option<Value> {
227            if column == "label" {
228                Some(Value::Text(Text("user-default".to_string())))
229            } else {
230                None
231            }
232        }
233
234        fn transform_column(column: &str, _old: Value) -> DbmsResult<Option<Value>> {
235            if column == "label" {
236                Ok(Some(Value::Text(Text("transformed".to_string()))))
237            } else {
238                Ok(None)
239            }
240        }
241    }
242
243    #[derive(DatabaseSchema)]
244    #[tables(Item = "items")]
245    pub struct TestSchema;
246
247    /// Multi-table schema covering every macro variant introduced for
248    /// migrations, exercised through the new `DatabaseSchema` dispatch
249    /// methods (`migrate_default`, `migrate_transform`, `compiled_snapshots`).
250    #[derive(DatabaseSchema)]
251    #[tables(
252        Item = "items",
253        ScoreDefaulted = "score_defaulted",
254        RenamedTable = "renamed_table",
255        CustomMigrate = "custom_migrate"
256    )]
257    pub struct MigrationSchema;
258
259    fn setup() -> DbmsContext<HeapMemoryProvider> {
260        let ctx = DbmsContext::new(HeapMemoryProvider::default());
261        TestSchema::register_tables(&ctx).unwrap();
262        ctx
263    }
264
265    #[test]
266    fn test_should_register_tables_via_macro() {
267        let ctx = DbmsContext::new(HeapMemoryProvider::default());
268        TestSchema::register_tables(&ctx).unwrap();
269    }
270
271    #[test]
272    fn test_should_insert_and_select_via_schema() {
273        let ctx = setup();
274        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
275
276        let insert = ItemInsertRequest::from_values(&[
277            (Item::columns()[0], Value::Uint32(Uint32(1))),
278            (Item::columns()[1], Value::Text(Text("foo".to_string()))),
279        ])
280        .unwrap();
281        db.insert::<Item>(insert).unwrap();
282
283        let rows = TestSchema
284            .select(&db, "items", Query::builder().build())
285            .unwrap();
286        assert_eq!(rows.len(), 1);
287        assert_eq!(rows[0][1].1, Value::Text(Text("foo".to_string())));
288    }
289
290    #[test]
291    fn test_should_delete_via_schema() {
292        let ctx = setup();
293        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
294
295        let insert = ItemInsertRequest::from_values(&[
296            (Item::columns()[0], Value::Uint32(Uint32(1))),
297            (Item::columns()[1], Value::Text(Text("foo".to_string()))),
298        ])
299        .unwrap();
300        db.insert::<Item>(insert).unwrap();
301
302        let deleted = TestSchema
303            .delete(
304                &db,
305                "items",
306                wasm_dbms_api::prelude::DeleteBehavior::Restrict,
307                None,
308            )
309            .unwrap();
310        assert_eq!(deleted, 1);
311    }
312
313    #[test]
314    fn test_should_return_error_for_unknown_table() {
315        let ctx = setup();
316        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
317
318        let result = TestSchema.select(&db, "nonexistent", Query::builder().build());
319        assert!(result.is_err());
320    }
321
322    #[test]
323    fn test_should_return_referenced_tables() {
324        let refs = <TestSchema as super::DatabaseSchema<HeapMemoryProvider>>::referenced_tables(
325            &TestSchema,
326            "items",
327        );
328        assert!(refs.is_empty());
329    }
330
331    #[test]
332    fn test_commit_rolls_back_all_operations_on_failure() {
333        let ctx = setup();
334        let owner = vec![1, 2, 3];
335
336        // Begin a transaction and queue two inserts.
337        let tx_id = ctx.begin_transaction(owner);
338        let mut db = WasmDbmsDatabase::from_transaction(&ctx, TestSchema, tx_id);
339
340        let first = ItemInsertRequest::from_values(&[
341            (Item::columns()[0], Value::Uint32(Uint32(1))),
342            (Item::columns()[1], Value::Text(Text("first".to_string()))),
343        ])
344        .unwrap();
345        db.insert::<Item>(first).unwrap();
346
347        let second = ItemInsertRequest::from_values(&[
348            (Item::columns()[0], Value::Uint32(Uint32(2))),
349            (Item::columns()[1], Value::Text(Text("second".to_string()))),
350        ])
351        .unwrap();
352        db.insert::<Item>(second).unwrap();
353
354        // Before committing, insert PK=2 outside the transaction so the
355        // second operation will conflict at commit time.
356        let oneshot = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
357        let conflicting = ItemInsertRequest::from_values(&[
358            (Item::columns()[0], Value::Uint32(Uint32(2))),
359            (
360                Item::columns()[1],
361                Value::Text(Text("conflict".to_string())),
362            ),
363        ])
364        .unwrap();
365        oneshot.insert::<Item>(conflicting).unwrap();
366
367        // Commit should fail: the first insert (PK=1) succeeds, but the
368        // second (PK=2) hits a primary key conflict.
369        let result = db.commit();
370        assert!(result.is_err());
371
372        // Verify that the first insert was also rolled back: only the
373        // conflicting row should remain.
374        let db = WasmDbmsDatabase::oneshot(&ctx, TestSchema);
375        let rows = db.select::<Item>(Query::builder().build()).unwrap();
376        assert_eq!(rows.len(), 1, "expected only the conflicting row");
377        assert_eq!(rows[0].id, Some(Uint32(2)));
378        assert_eq!(rows[0].name, Some(Text("conflict".to_string())));
379    }
380
381    #[test]
382    fn test_indexes_contains_pk_by_default() {
383        let indexes = Item::indexes();
384        assert_eq!(indexes.len(), 1);
385        assert_eq!(indexes[0].columns(), &["id"]);
386    }
387
388    #[test]
389    fn test_indexes_single_and_composite() {
390        let indexes = Product::indexes();
391        // [pk("id"), standalone("sku"), composite("category", "brand")]
392        assert_eq!(indexes.len(), 3);
393        assert_eq!(indexes[0].columns(), &["id"]);
394        assert_eq!(indexes[1].columns(), &["sku"]);
395        assert_eq!(indexes[2].columns(), &["category", "brand"]);
396    }
397
398    // ---------- Macro tests for migration metadata --------------------------
399
400    /// `#[default = LIT]` on a field must surface as a `fn() -> Value` on the
401    /// column's `default` slot, returning a `Value` of the same variant as
402    /// the column's data type.
403    #[test]
404    fn test_default_attribute_emits_constructor_on_column_def() {
405        let columns = ScoreDefaulted::columns();
406        let id = columns.iter().find(|c| c.name == "id").unwrap();
407        let score = columns.iter().find(|c| c.name == "score").unwrap();
408
409        assert!(id.default.is_none(), "id has no #[default]");
410        let ctor = score.default.expect("score must have a default");
411        assert_eq!(ctor(), Value::Uint32(Uint32(42)));
412    }
413
414    /// `#[renamed_from(...)]` populates the `renamed_from` slice in
415    /// declaration order; absent on fields that did not use the attribute.
416    #[test]
417    fn test_renamed_from_attribute_populates_slice() {
418        let columns = RenamedTable::columns();
419        let id = columns.iter().find(|c| c.name == "id").unwrap();
420        let name = columns.iter().find(|c| c.name == "name").unwrap();
421
422        assert!(id.renamed_from.is_empty());
423        assert_eq!(name.renamed_from, &["old_name", "older_name"]);
424    }
425
426    /// Default-impl path: the macro emits `impl Migrate for T {}`, so both
427    /// `default_value` and `transform_column` produce trait defaults.
428    #[test]
429    fn test_table_macro_emits_default_migrate_impl() {
430        assert_eq!(
431            <Item as Migrate>::default_value("id"),
432            None,
433            "default Migrate returns None for default_value"
434        );
435        assert!(matches!(
436            <Item as Migrate>::transform_column("id", Value::Uint32(Uint32(7))),
437            Ok(None)
438        ));
439    }
440
441    /// `#[migrate]` opts out of macro-emitted impl; the user-supplied impl is
442    /// the only one in scope and its overrides take effect.
443    #[test]
444    fn test_migrate_struct_attribute_uses_user_impl() {
445        assert_eq!(
446            <CustomMigrate as Migrate>::default_value("label"),
447            Some(Value::Text(Text("user-default".to_string())))
448        );
449        assert_eq!(<CustomMigrate as Migrate>::default_value("id"), None);
450
451        let transformed =
452            <CustomMigrate as Migrate>::transform_column("label", Value::Text(Text("x".into())))
453                .expect("transform_column must succeed");
454        assert_eq!(transformed, Some(Value::Text(Text("transformed".into()))));
455    }
456
457    /// `migrate_default` dispatch: the schema falls back to the column's
458    /// static `#[default]` constructor when `Migrate::default_value` returns
459    /// `None`.
460    #[test]
461    fn test_migrate_default_dispatch_falls_back_to_column_default() {
462        let value = <MigrationSchema as super::DatabaseSchema<HeapMemoryProvider>>::migrate_default(
463            "score_defaulted",
464            "score",
465        );
466        assert_eq!(value, Some(Value::Uint32(Uint32(42))));
467    }
468
469    /// `migrate_default` dispatch: the user-provided `Migrate::default_value`
470    /// wins over any static column default (and there is none here anyway).
471    #[test]
472    fn test_migrate_default_dispatch_uses_user_override() {
473        let value = <MigrationSchema as super::DatabaseSchema<HeapMemoryProvider>>::migrate_default(
474            "custom_migrate",
475            "label",
476        );
477        assert_eq!(value, Some(Value::Text(Text("user-default".to_string()))));
478    }
479
480    /// `migrate_default` dispatch: unknown table → `None`, mirroring the
481    /// `referenced_tables` no-match contract.
482    #[test]
483    fn test_migrate_default_dispatch_unknown_table_returns_none() {
484        let value = <MigrationSchema as super::DatabaseSchema<HeapMemoryProvider>>::migrate_default(
485            "nonexistent",
486            "anything",
487        );
488        assert!(value.is_none());
489    }
490
491    /// `migrate_default` dispatch: known table but column without a default →
492    /// `None`.
493    #[test]
494    fn test_migrate_default_dispatch_known_table_unknown_column_returns_none() {
495        let value = <MigrationSchema as super::DatabaseSchema<HeapMemoryProvider>>::migrate_default(
496            "items", "name",
497        );
498        assert!(value.is_none());
499    }
500
501    /// `migrate_transform` dispatch routes to the user-supplied impl on
502    /// `CustomMigrate` and produces the override value.
503    #[test]
504    fn test_migrate_transform_dispatch_uses_user_override() {
505        let value =
506            <MigrationSchema as super::DatabaseSchema<HeapMemoryProvider>>::migrate_transform(
507                "custom_migrate",
508                "label",
509                Value::Text(Text("x".into())),
510            )
511            .expect("transform must succeed");
512        assert_eq!(value, Some(Value::Text(Text("transformed".into()))));
513    }
514
515    /// `migrate_transform` dispatch: tables with the macro-emitted default
516    /// `Migrate` produce `Ok(None)` (no transform).
517    #[test]
518    fn test_migrate_transform_dispatch_default_impl_returns_none() {
519        let value =
520            <MigrationSchema as super::DatabaseSchema<HeapMemoryProvider>>::migrate_transform(
521                "items",
522                "id",
523                Value::Uint32(Uint32(1)),
524            )
525            .expect("transform must succeed");
526        assert!(value.is_none());
527    }
528
529    /// `migrate_transform` dispatch: unknown table → table-not-found error,
530    /// matching the existing behaviour of CRUD dispatch methods.
531    #[test]
532    fn test_migrate_transform_dispatch_unknown_table_errors() {
533        let result =
534            <MigrationSchema as super::DatabaseSchema<HeapMemoryProvider>>::migrate_transform(
535                "nonexistent",
536                "anything",
537                Value::Null,
538            );
539        assert!(result.is_err());
540    }
541
542    /// `compiled_snapshots` returns one snapshot per registered table, in
543    /// declaration order, and each snapshot reflects the table's compile-time
544    /// columns, primary key, and metadata such as `#[default]`/
545    /// `#[renamed_from]`.
546    #[test]
547    fn test_compiled_snapshots_one_per_table_in_order() {
548        let snapshots =
549            <MigrationSchema as super::DatabaseSchema<HeapMemoryProvider>>::compiled_snapshots();
550        assert_eq!(snapshots.len(), 4);
551        assert_eq!(
552            snapshots
553                .iter()
554                .map(|s| s.name.as_str())
555                .collect::<Vec<_>>(),
556            vec![
557                "items",
558                "score_defaulted",
559                "renamed_table",
560                "custom_migrate"
561            ],
562        );
563
564        let score_snapshot = snapshots
565            .iter()
566            .find(|s| s.name == "score_defaulted")
567            .unwrap();
568        let score_col = score_snapshot
569            .columns
570            .iter()
571            .find(|c| c.name == "score")
572            .unwrap();
573        assert_eq!(score_col.default, Some(Value::Uint32(Uint32(42))));
574        assert_eq!(score_snapshot.primary_key, "id");
575    }
576}