Skip to main content

cufflink_types/
lib.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4pub mod openapi;
5
6/// A service manifest describes the complete definition of a cufflink service.
7/// This is generated by the SDK macros from the user's Rust struct definitions.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ServiceManifest {
10    pub name: String,
11    pub version: Option<String>,
12    pub tables: Vec<TableDefinition>,
13    #[serde(default)]
14    pub cells: Vec<CellDefinition>,
15    #[serde(default)]
16    pub events: Vec<EventDefinition>,
17    #[serde(default)]
18    pub subscriptions: Vec<SubscriptionDefinition>,
19    #[serde(default)]
20    pub custom_routes: Vec<CustomRouteDefinition>,
21    #[serde(default)]
22    pub mode: ServiceMode,
23    /// Authorization configuration (permission areas and default roles).
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub authorization: Option<AuthorizationConfig>,
26    /// Optional WASM handler invoked between additive and destructive schema
27    /// phases of a deploy. Used to backfill or restructure data when columns
28    /// are split, renamed, or have their semantics changed. The handler must
29    /// be idempotent — it may be re-invoked on retried deploys.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub on_migrate: Option<String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
35#[serde(rename_all = "lowercase")]
36pub enum ServiceMode {
37    #[default]
38    Crud,
39    Wasm,
40    Container,
41    Web,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct TableDefinition {
46    pub name: String,
47    pub columns: Vec<ColumnDefinition>,
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub indexes: Vec<IndexDefinition>,
50    #[serde(default)]
51    pub soft_delete: bool,
52    /// Column name that stores the owner's user ID for row-level security.
53    /// When set, CRUD operations are scoped to the authenticated user's records.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub owner_field: Option<String>,
56    /// When true, all CRUD operations on this table require authentication.
57    /// Unauthenticated requests receive a 401 Unauthorized response.
58    #[serde(default)]
59    pub auth_required: bool,
60    /// Permission area that controls access to this table's CRUD operations.
61    /// When set, platform checks "{area}:view" for GET, "{area}:create" for POST,
62    /// "{area}:edit" for PUT, "{area}:delete" for DELETE.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub permission_area: Option<String>,
65    /// CRUD lifecycle hooks — WASM handler names called before/after operations.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub hooks: Option<TableHooks>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ColumnDefinition {
72    pub name: String,
73    pub column_type: ColumnType,
74    #[serde(default)]
75    pub primary_key: bool,
76    #[serde(default)]
77    pub nullable: bool,
78    #[serde(default)]
79    pub auto_generate: bool,
80    pub default_value: Option<String>,
81    /// Foreign key reference in "table.column" format
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub references: Option<String>,
84    /// Whether to cascade deletes on the FK
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub on_delete: Option<ForeignKeyAction>,
87    /// Whether this column has a unique constraint
88    #[serde(default)]
89    pub unique: bool,
90    /// Validation rules for this column
91    #[serde(default, skip_serializing_if = "Vec::is_empty")]
92    pub validations: Vec<ValidationRule>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96#[serde(tag = "rule", rename_all = "snake_case")]
97// Note: ValidationRule contains f64 fields (Min/Max), so Eq cannot be derived
98pub enum ValidationRule {
99    /// Regex pattern match
100    Regex { pattern: String },
101    /// Minimum numeric value
102    Min { value: f64 },
103    /// Maximum numeric value
104    Max { value: f64 },
105    /// Minimum string length
106    MinLength { value: usize },
107    /// Maximum string length
108    MaxLength { value: usize },
109    /// Must be one of the listed values
110    OneOf { values: Vec<String> },
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
114pub struct IndexDefinition {
115    pub name: String,
116    pub columns: Vec<String>,
117    #[serde(default)]
118    pub unique: bool,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
122#[serde(rename_all = "snake_case")]
123pub enum ForeignKeyAction {
124    Cascade,
125    SetNull,
126    Restrict,
127    NoAction,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
131#[serde(rename_all = "lowercase")]
132pub enum ColumnType {
133    Uuid,
134    Text,
135    Integer,
136    BigInteger,
137    Float,
138    Double,
139    Boolean,
140    Timestamp,
141    Date,
142    Jsonb,
143}
144
145impl ColumnType {
146    pub fn to_sql(&self) -> &str {
147        match self {
148            ColumnType::Uuid => "UUID",
149            ColumnType::Text => "TEXT",
150            ColumnType::Integer => "INTEGER",
151            ColumnType::BigInteger => "BIGINT",
152            ColumnType::Float => "REAL",
153            ColumnType::Double => "DOUBLE PRECISION",
154            ColumnType::Boolean => "BOOLEAN",
155            ColumnType::Timestamp => "TIMESTAMPTZ",
156            ColumnType::Date => "DATE",
157            ColumnType::Jsonb => "JSONB",
158        }
159    }
160
161    /// Map to the filter column type for query param parsing
162    pub fn filter_type(&self) -> &str {
163        match self {
164            ColumnType::Uuid => "uuid",
165            ColumnType::Text => "text",
166            ColumnType::Integer | ColumnType::BigInteger => "integer",
167            ColumnType::Float | ColumnType::Double => "double",
168            ColumnType::Boolean => "boolean",
169            ColumnType::Timestamp => "timestamp",
170            ColumnType::Date => "date",
171            ColumnType::Jsonb => "jsonb",
172        }
173    }
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct CellDefinition {
178    pub name: String,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct EventDefinition {
183    pub name: String,
184    pub table: String,
185    pub action: EventAction,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
189#[serde(rename_all = "lowercase")]
190pub enum EventAction {
191    Create,
192    Update,
193    Delete,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct SubscriptionDefinition {
198    pub subject: String,
199    pub handler: String,
200    pub handler_type: HandlerType,
201    #[serde(default)]
202    pub config: HashMap<String, String>,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
206#[serde(rename_all = "snake_case")]
207pub enum HandlerType {
208    DeleteCascade,
209    UpdateField,
210    Webhook,
211    Wasm,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct CustomRouteDefinition {
216    pub method: String,
217    pub path: String,
218    pub handler: String,
219}
220
221/// CRUD lifecycle hooks for a table. Each field names a WASM handler function.
222#[derive(Debug, Clone, Serialize, Deserialize, Default)]
223pub struct TableHooks {
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub before_create: Option<String>,
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub after_create: Option<String>,
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub before_update: Option<String>,
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub after_update: Option<String>,
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub before_delete: Option<String>,
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub after_delete: Option<String>,
236}
237
238/// Authorization configuration declared in the service manifest.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct AuthorizationConfig {
241    /// Permission areas declared by this service.
242    #[serde(default)]
243    pub areas: Vec<PermissionAreaDef>,
244    /// Default roles to seed on first deployment.
245    #[serde(default)]
246    pub default_roles: Vec<DefaultRoleDef>,
247}
248
249/// A permission area with its supported operations.
250#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct PermissionAreaDef {
252    pub name: String,
253    #[serde(default)]
254    pub operations: Vec<String>,
255}
256
257/// A default role seeded on deployment.
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct DefaultRoleDef {
260    pub name: String,
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub description: Option<String>,
263    #[serde(default)]
264    pub permissions: Vec<String>,
265}
266
267/// Description of the changes a deploy is about to apply, passed as the
268/// payload to the `on_migrate` hook so user code can decide which backfill
269/// blocks should fire.
270#[derive(Debug, Clone, Default, Serialize, Deserialize)]
271pub struct SchemaDiff {
272    #[serde(default)]
273    pub added_tables: Vec<String>,
274    #[serde(default)]
275    pub dropped_tables: Vec<String>,
276    /// (table, column) pairs for newly added columns. In phase 2 these
277    /// already exist in the database (added in phase 1) but may be NULL on
278    /// existing rows; the hook is the place to backfill them.
279    #[serde(default)]
280    pub added_columns: Vec<(String, String)>,
281    /// (table, column) pairs for columns that will be dropped in phase 3.
282    /// During phase 2 these are still present in the database, so the hook
283    /// can read them to populate new columns before they disappear.
284    #[serde(default)]
285    pub dropped_columns: Vec<(String, String)>,
286    /// (table, column, old_type, new_type)
287    #[serde(default)]
288    pub type_changes: Vec<(String, String, String, String)>,
289    /// (table, column, new_nullable)
290    #[serde(default)]
291    pub nullability_changes: Vec<(String, String, bool)>,
292}
293
294impl SchemaDiff {
295    pub fn added_column(&self, table: &str, col: &str) -> bool {
296        self.added_columns
297            .iter()
298            .any(|(t, c)| t == table && c == col)
299    }
300
301    pub fn dropped_column(&self, table: &str, col: &str) -> bool {
302        self.dropped_columns
303            .iter()
304            .any(|(t, c)| t == table && c == col)
305    }
306
307    pub fn added_table(&self, table: &str) -> bool {
308        self.added_tables.iter().any(|t| t == table)
309    }
310
311    pub fn dropped_table(&self, table: &str) -> bool {
312        self.dropped_tables.iter().any(|t| t == table)
313    }
314
315    pub fn is_empty(&self) -> bool {
316        self.added_tables.is_empty()
317            && self.dropped_tables.is_empty()
318            && self.added_columns.is_empty()
319            && self.dropped_columns.is_empty()
320            && self.type_changes.is_empty()
321            && self.nullability_changes.is_empty()
322    }
323}
324
325impl ServiceManifest {
326    /// Get table definition by name
327    pub fn get_table(&self, name: &str) -> Option<&TableDefinition> {
328        self.tables.iter().find(|t| t.name == name)
329    }
330
331    /// Compute a SHA-256 hash of the manifest for change detection
332    pub fn schema_hash(&self) -> String {
333        use sha2::{Digest, Sha256};
334        let mut hasher = Sha256::new();
335        hasher.update(
336            serde_json::to_string(&self.tables)
337                .unwrap_or_default()
338                .as_bytes(),
339        );
340        hasher.update(
341            serde_json::to_string(&self.custom_routes)
342                .unwrap_or_default()
343                .as_bytes(),
344        );
345        hasher.update(
346            serde_json::to_string(&self.subscriptions)
347                .unwrap_or_default()
348                .as_bytes(),
349        );
350        hasher.update(
351            serde_json::to_string(&self.authorization)
352                .unwrap_or_default()
353                .as_bytes(),
354        );
355        hasher.update(
356            serde_json::to_string(&self.on_migrate)
357                .unwrap_or_default()
358                .as_bytes(),
359        );
360        hex::encode(hasher.finalize())
361    }
362}
363
364impl TableDefinition {
365    /// Get the primary key column
366    pub fn primary_key(&self) -> Option<&ColumnDefinition> {
367        self.columns.iter().find(|c| c.primary_key)
368    }
369
370    /// Get all non-primary-key, non-auto columns (for INSERT/UPDATE)
371    pub fn writable_columns(&self) -> Vec<&ColumnDefinition> {
372        self.columns
373            .iter()
374            .filter(|c| !c.auto_generate || !c.primary_key)
375            .collect()
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    fn sample_manifest() -> ServiceManifest {
384        ServiceManifest {
385            name: "test-service".into(),
386            version: Some("1.0.0".into()),
387            tables: vec![TableDefinition {
388                name: "todos".into(),
389                columns: vec![
390                    ColumnDefinition {
391                        name: "id".into(),
392                        column_type: ColumnType::Uuid,
393                        primary_key: true,
394                        nullable: false,
395                        auto_generate: true,
396                        default_value: None,
397                        references: None,
398                        on_delete: None,
399                        unique: false,
400                        validations: vec![],
401                    },
402                    ColumnDefinition {
403                        name: "title".into(),
404                        column_type: ColumnType::Text,
405                        primary_key: false,
406                        nullable: false,
407                        auto_generate: false,
408                        default_value: None,
409                        references: None,
410                        on_delete: None,
411                        unique: false,
412                        validations: vec![],
413                    },
414                    ColumnDefinition {
415                        name: "user_id".into(),
416                        column_type: ColumnType::Uuid,
417                        primary_key: false,
418                        nullable: false,
419                        auto_generate: false,
420                        default_value: None,
421                        references: Some("users.id".into()),
422                        on_delete: Some(ForeignKeyAction::Cascade),
423                        unique: false,
424                        validations: vec![],
425                    },
426                ],
427                indexes: vec![],
428                soft_delete: false,
429                owner_field: None,
430                auth_required: false,
431                permission_area: None,
432                hooks: None,
433            }],
434            cells: vec![],
435            events: vec![],
436            subscriptions: vec![],
437            custom_routes: vec![],
438            mode: ServiceMode::Crud,
439            authorization: None,
440            on_migrate: None,
441        }
442    }
443
444    #[test]
445    fn test_manifest_serialization_roundtrip() {
446        let manifest = sample_manifest();
447        let json = serde_json::to_string(&manifest).unwrap();
448        let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
449        assert_eq!(parsed.name, "test-service");
450        assert_eq!(parsed.tables.len(), 1);
451        assert_eq!(parsed.tables[0].columns.len(), 3);
452    }
453
454    #[test]
455    fn test_get_table() {
456        let manifest = sample_manifest();
457        assert!(manifest.get_table("todos").is_some());
458        assert!(manifest.get_table("nonexistent").is_none());
459    }
460
461    #[test]
462    fn test_primary_key() {
463        let manifest = sample_manifest();
464        let table = manifest.get_table("todos").unwrap();
465        let pk = table.primary_key().unwrap();
466        assert_eq!(pk.name, "id");
467        assert!(pk.auto_generate);
468    }
469
470    #[test]
471    fn test_schema_hash_deterministic() {
472        let m1 = sample_manifest();
473        let m2 = sample_manifest();
474        assert_eq!(m1.schema_hash(), m2.schema_hash());
475    }
476
477    #[test]
478    fn test_schema_hash_changes() {
479        let mut m1 = sample_manifest();
480        let m2 = sample_manifest();
481        m1.tables[0].columns.push(ColumnDefinition {
482            name: "extra".into(),
483            column_type: ColumnType::Text,
484            primary_key: false,
485            nullable: true,
486            auto_generate: false,
487            default_value: None,
488            references: None,
489            on_delete: None,
490            unique: false,
491            validations: vec![],
492        });
493        assert_ne!(m1.schema_hash(), m2.schema_hash());
494    }
495
496    #[test]
497    fn test_column_type_to_sql() {
498        assert_eq!(ColumnType::Uuid.to_sql(), "UUID");
499        assert_eq!(ColumnType::Text.to_sql(), "TEXT");
500        assert_eq!(ColumnType::Integer.to_sql(), "INTEGER");
501        assert_eq!(ColumnType::BigInteger.to_sql(), "BIGINT");
502        assert_eq!(ColumnType::Boolean.to_sql(), "BOOLEAN");
503        assert_eq!(ColumnType::Timestamp.to_sql(), "TIMESTAMPTZ");
504        assert_eq!(ColumnType::Date.to_sql(), "DATE");
505        assert_eq!(ColumnType::Jsonb.to_sql(), "JSONB");
506    }
507
508    #[test]
509    fn test_fk_serialization() {
510        let manifest = sample_manifest();
511        let json = serde_json::to_string(&manifest).unwrap();
512        assert!(json.contains("\"references\":\"users.id\""));
513        assert!(json.contains("\"on_delete\":\"cascade\""));
514
515        let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
516        let user_id_col = &parsed.tables[0].columns[2];
517        assert_eq!(user_id_col.references.as_deref(), Some("users.id"));
518        assert_eq!(user_id_col.on_delete, Some(ForeignKeyAction::Cascade));
519    }
520
521    #[test]
522    fn test_fk_not_serialized_when_none() {
523        let col = ColumnDefinition {
524            name: "title".into(),
525            column_type: ColumnType::Text,
526            primary_key: false,
527            nullable: false,
528            auto_generate: false,
529            default_value: None,
530            references: None,
531            on_delete: None,
532            unique: false,
533            validations: vec![],
534        };
535        let json = serde_json::to_string(&col).unwrap();
536        assert!(!json.contains("references"));
537        assert!(!json.contains("on_delete"));
538    }
539
540    #[test]
541    fn test_unique_column_serialization() {
542        let col = ColumnDefinition {
543            name: "email".into(),
544            column_type: ColumnType::Text,
545            primary_key: false,
546            nullable: false,
547            auto_generate: false,
548            default_value: None,
549            references: None,
550            on_delete: None,
551            unique: true,
552            validations: vec![],
553        };
554        let json = serde_json::to_string(&col).unwrap();
555        assert!(json.contains("\"unique\":true"));
556        let parsed: ColumnDefinition = serde_json::from_str(&json).unwrap();
557        assert!(parsed.unique);
558    }
559
560    #[test]
561    fn test_index_serialization() {
562        let table = TableDefinition {
563            name: "users".into(),
564            columns: vec![],
565            indexes: vec![IndexDefinition {
566                name: "idx_users_email".into(),
567                columns: vec!["email".into()],
568                unique: true,
569            }],
570            soft_delete: false,
571            owner_field: None,
572            auth_required: false,
573            permission_area: None,
574            hooks: None,
575        };
576        let json = serde_json::to_string(&table).unwrap();
577        assert!(json.contains("idx_users_email"));
578        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
579        assert_eq!(parsed.indexes.len(), 1);
580        assert!(parsed.indexes[0].unique);
581    }
582
583    #[test]
584    fn test_indexes_not_serialized_when_empty() {
585        let table = TableDefinition {
586            name: "users".into(),
587            columns: vec![],
588            indexes: vec![],
589            soft_delete: false,
590            owner_field: None,
591            auth_required: false,
592            permission_area: None,
593            hooks: None,
594        };
595        let json = serde_json::to_string(&table).unwrap();
596        assert!(!json.contains("indexes"));
597    }
598
599    #[test]
600    fn test_service_mode_default() {
601        let json = r#"{"name":"svc","tables":[]}"#;
602        let m: ServiceManifest = serde_json::from_str(json).unwrap();
603        assert_eq!(m.mode, ServiceMode::Crud);
604    }
605
606    #[test]
607    fn test_owner_field_serialization() {
608        let table = TableDefinition {
609            name: "notes".into(),
610            columns: vec![],
611            indexes: vec![],
612            soft_delete: false,
613            owner_field: Some("user_id".into()),
614            auth_required: false,
615            permission_area: None,
616            hooks: None,
617        };
618        let json = serde_json::to_string(&table).unwrap();
619        assert!(json.contains("\"owner_field\":\"user_id\""));
620        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
621        assert_eq!(parsed.owner_field.as_deref(), Some("user_id"));
622    }
623
624    #[test]
625    fn test_owner_field_not_serialized_when_none() {
626        let table = TableDefinition {
627            name: "notes".into(),
628            columns: vec![],
629            indexes: vec![],
630            soft_delete: false,
631            owner_field: None,
632            auth_required: false,
633            permission_area: None,
634            hooks: None,
635        };
636        let json = serde_json::to_string(&table).unwrap();
637        assert!(!json.contains("owner_field"));
638    }
639
640    #[test]
641    fn test_owner_field_defaults_to_none() {
642        let json = r#"{"name":"notes","columns":[]}"#;
643        let table: TableDefinition = serde_json::from_str(json).unwrap();
644        assert!(table.owner_field.is_none());
645    }
646
647    #[test]
648    fn test_auth_required_serialization() {
649        let table = TableDefinition {
650            name: "orders".into(),
651            columns: vec![],
652            indexes: vec![],
653            soft_delete: false,
654            owner_field: None,
655            auth_required: true,
656            permission_area: None,
657            hooks: None,
658        };
659        let json = serde_json::to_string(&table).unwrap();
660        assert!(json.contains("\"auth_required\":true"));
661        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
662        assert!(parsed.auth_required);
663    }
664
665    #[test]
666    fn test_auth_required_defaults_to_false() {
667        let json = r#"{"name":"orders","columns":[]}"#;
668        let table: TableDefinition = serde_json::from_str(json).unwrap();
669        assert!(!table.auth_required);
670    }
671
672    #[test]
673    fn test_on_migrate_defaults_to_none() {
674        let json = r#"{"name":"svc","tables":[]}"#;
675        let m: ServiceManifest = serde_json::from_str(json).unwrap();
676        assert!(m.on_migrate.is_none());
677    }
678
679    #[test]
680    fn test_on_migrate_serialization_roundtrip() {
681        let mut m = sample_manifest();
682        m.on_migrate = Some("handle_on_migrate".into());
683        let json = serde_json::to_string(&m).unwrap();
684        assert!(json.contains("\"on_migrate\":\"handle_on_migrate\""));
685        let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
686        assert_eq!(parsed.on_migrate.as_deref(), Some("handle_on_migrate"));
687    }
688
689    #[test]
690    fn test_on_migrate_skipped_when_none() {
691        let m = sample_manifest();
692        let json = serde_json::to_string(&m).unwrap();
693        assert!(!json.contains("on_migrate"));
694    }
695
696    #[test]
697    fn test_on_migrate_changes_schema_hash() {
698        let m1 = sample_manifest();
699        let mut m2 = sample_manifest();
700        m2.on_migrate = Some("handle_on_migrate".into());
701        assert_ne!(m1.schema_hash(), m2.schema_hash());
702    }
703
704    #[test]
705    fn test_schema_diff_helpers() {
706        let diff = SchemaDiff {
707            added_tables: vec!["batches".into()],
708            dropped_tables: vec!["legacy_batches".into()],
709            added_columns: vec![("pickups".into(), "min".into())],
710            dropped_columns: vec![("pickups".into(), "midpoint".into())],
711            type_changes: vec![],
712            nullability_changes: vec![],
713        };
714        assert!(!diff.is_empty());
715        assert!(diff.added_table("batches"));
716        assert!(!diff.added_table("nope"));
717        assert!(diff.dropped_table("legacy_batches"));
718        assert!(diff.added_column("pickups", "min"));
719        assert!(!diff.added_column("pickups", "max"));
720        assert!(diff.dropped_column("pickups", "midpoint"));
721    }
722
723    #[test]
724    fn test_schema_diff_is_empty_default() {
725        assert!(SchemaDiff::default().is_empty());
726    }
727
728    #[test]
729    fn test_schema_diff_serialization_roundtrip() {
730        let diff = SchemaDiff {
731            added_tables: vec![],
732            dropped_tables: vec![],
733            added_columns: vec![("t".into(), "c".into())],
734            dropped_columns: vec![],
735            type_changes: vec![("t".into(), "c".into(), "TEXT".into(), "INTEGER".into())],
736            nullability_changes: vec![("t".into(), "c".into(), false)],
737        };
738        let json = serde_json::to_string(&diff).unwrap();
739        let parsed: SchemaDiff = serde_json::from_str(&json).unwrap();
740        assert!(parsed.added_column("t", "c"));
741        assert_eq!(parsed.type_changes.len(), 1);
742        assert_eq!(parsed.nullability_changes.len(), 1);
743    }
744}