1pub mod files;
2#[cfg(feature = "postgres-live")]
3pub mod pg_datastore;
4pub mod pool;
5pub mod postgres;
6pub mod sqlite;
7
8use std::fmt;
9
10use pylon_kernel::AppManifest;
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct StorageError {
19 pub code: String,
20 pub message: String,
21}
22
23impl fmt::Display for StorageError {
24 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25 write!(f, "[{}] {}", self.code, self.message)
26 }
27}
28
29impl std::error::Error for StorageError {}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(tag = "op", rename_all = "snake_case")]
37pub enum SchemaOperation {
38 CreateEntity {
39 name: String,
40 fields: Vec<FieldSpec>,
41 },
42 AddField {
43 entity: String,
44 field: FieldSpec,
45 },
46 RemoveField {
47 entity: String,
48 field_name: String,
49 },
50 RemoveEntity {
51 name: String,
52 },
53 AddIndex {
54 entity: String,
55 name: String,
56 fields: Vec<String>,
57 unique: bool,
58 },
59 RemoveIndex {
60 entity: String,
61 name: String,
62 },
63 Noop,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct FieldSpec {
68 pub name: String,
69 pub field_type: String,
70 pub optional: bool,
71 pub unique: bool,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct SchemaPlan {
80 pub operations: Vec<SchemaOperation>,
81}
82
83impl SchemaPlan {
84 pub fn is_empty(&self) -> bool {
85 self.operations.is_empty()
86 || self
87 .operations
88 .iter()
89 .all(|op| matches!(op, SchemaOperation::Noop))
90 }
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
98pub struct SchemaSnapshot {
99 pub tables: Vec<TableSnapshot>,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
103pub struct TableSnapshot {
104 pub name: String,
105 pub columns: Vec<ColumnSnapshot>,
106 pub indexes: Vec<IndexSnapshot>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
110pub struct ColumnSnapshot {
111 pub name: String,
112 pub column_type: String,
113 pub notnull: bool,
114 pub primary_key: bool,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
118pub struct IndexSnapshot {
119 pub name: String,
120 pub columns: Vec<String>,
121 pub unique: bool,
122}
123
124pub fn plan_from_snapshot(snapshot: &SchemaSnapshot, target: &AppManifest) -> SchemaPlan {
128 use std::collections::{HashMap, HashSet};
129
130 let existing_tables: HashMap<&str, &TableSnapshot> = snapshot
131 .tables
132 .iter()
133 .map(|t| (t.name.as_str(), t))
134 .collect();
135
136 let mut operations = Vec::new();
137
138 for entity in &target.entities {
139 match existing_tables.get(entity.name.as_str()) {
140 None => {
141 let fields: Vec<FieldSpec> = entity
142 .fields
143 .iter()
144 .map(|f| FieldSpec {
145 name: f.name.clone(),
146 field_type: f.field_type.clone(),
147 optional: f.optional,
148 unique: f.unique,
149 })
150 .collect();
151 operations.push(SchemaOperation::CreateEntity {
152 name: entity.name.clone(),
153 fields,
154 });
155 for index in &entity.indexes {
156 operations.push(SchemaOperation::AddIndex {
157 entity: entity.name.clone(),
158 name: index.name.clone(),
159 fields: index.fields.clone(),
160 unique: index.unique,
161 });
162 }
163 }
164 Some(table) => {
165 let existing_cols: HashSet<&str> =
166 table.columns.iter().map(|c| c.name.as_str()).collect();
167 for field in &entity.fields {
168 if !existing_cols.contains(field.name.as_str()) {
169 operations.push(SchemaOperation::AddField {
170 entity: entity.name.clone(),
171 field: FieldSpec {
172 name: field.name.clone(),
173 field_type: field.field_type.clone(),
174 optional: field.optional,
175 unique: field.unique,
176 },
177 });
178 }
179 }
180 let existing_indexes: HashSet<&str> =
182 table.indexes.iter().map(|i| i.name.as_str()).collect();
183 for index in &entity.indexes {
184 let full_name = format!("{}_{}", entity.name, index.name);
185 if !existing_indexes.contains(full_name.as_str()) {
186 operations.push(SchemaOperation::AddIndex {
187 entity: entity.name.clone(),
188 name: index.name.clone(),
189 fields: index.fields.clone(),
190 unique: index.unique,
191 });
192 }
193 }
194 }
195 }
196 }
197
198 if operations.is_empty() {
199 operations.push(SchemaOperation::Noop);
200 }
201
202 SchemaPlan { operations }
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
210pub struct PlanWarning {
211 pub code: String,
212 pub message: String,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
216pub struct PlanAnalysis {
217 pub destructive: bool,
218 pub has_unsupported: bool,
219 pub warnings: Vec<PlanWarning>,
220}
221
222pub fn analyze_plan(plan: &SchemaPlan) -> PlanAnalysis {
224 let mut destructive = false;
225 let mut has_unsupported = false;
226 let mut warnings = Vec::new();
227
228 for op in &plan.operations {
229 match op {
230 SchemaOperation::RemoveEntity { name } => {
231 destructive = true;
232 has_unsupported = true;
233 warnings.push(PlanWarning {
234 code: "DESTRUCTIVE_REMOVE_ENTITY".into(),
235 message: format!(
236 "Removing entity \"{}\" will drop the table and all its data",
237 name
238 ),
239 });
240 }
241 SchemaOperation::RemoveField { entity, field_name } => {
242 destructive = true;
243 has_unsupported = true;
244 warnings.push(PlanWarning {
245 code: "DESTRUCTIVE_REMOVE_FIELD".into(),
246 message: format!(
247 "Removing field \"{}.{}\" will drop the column and its data",
248 entity, field_name
249 ),
250 });
251 }
252 SchemaOperation::RemoveIndex { entity, name } => {
253 has_unsupported = true;
254 warnings.push(PlanWarning {
255 code: "UNSUPPORTED_REMOVE_INDEX".into(),
256 message: format!(
257 "Removing index \"{}.{}\" is not supported by the SQLite adapter",
258 entity, name
259 ),
260 });
261 }
262 _ => {}
263 }
264 }
265
266 PlanAnalysis {
267 destructive,
268 has_unsupported,
269 warnings,
270 }
271}
272
273pub trait StorageAdapter {
278 fn plan_schema(&self, target: &AppManifest) -> Result<SchemaPlan, StorageError>;
280
281 fn apply_schema(&self, _plan: &SchemaPlan) -> Result<(), StorageError> {
283 Err(StorageError {
284 code: "APPLY_NOT_IMPLEMENTED".into(),
285 message: "This adapter does not support applying schemas".into(),
286 })
287 }
288}
289
290pub struct DryRunAdapter;
297
298impl StorageAdapter for DryRunAdapter {
299 fn plan_schema(&self, target: &AppManifest) -> Result<SchemaPlan, StorageError> {
300 let mut operations = Vec::new();
301
302 for entity in &target.entities {
303 let fields: Vec<FieldSpec> = entity
304 .fields
305 .iter()
306 .map(|f| FieldSpec {
307 name: f.name.clone(),
308 field_type: f.field_type.clone(),
309 optional: f.optional,
310 unique: f.unique,
311 })
312 .collect();
313
314 operations.push(SchemaOperation::CreateEntity {
315 name: entity.name.clone(),
316 fields,
317 });
318
319 for index in &entity.indexes {
320 operations.push(SchemaOperation::AddIndex {
321 entity: entity.name.clone(),
322 name: index.name.clone(),
323 fields: index.fields.clone(),
324 unique: index.unique,
325 });
326 }
327 }
328
329 if operations.is_empty() {
330 operations.push(SchemaOperation::Noop);
331 }
332
333 Ok(SchemaPlan { operations })
334 }
335}
336
337pub struct DiffAdapter {
343 pub from: AppManifest,
344}
345
346impl StorageAdapter for DiffAdapter {
347 fn plan_schema(&self, target: &AppManifest) -> Result<SchemaPlan, StorageError> {
348 let mut operations = Vec::new();
349
350 let old_entities: std::collections::HashMap<&str, &pylon_kernel::ManifestEntity> = self
351 .from
352 .entities
353 .iter()
354 .map(|e| (e.name.as_str(), e))
355 .collect();
356 let new_entities: std::collections::HashMap<&str, &pylon_kernel::ManifestEntity> = target
357 .entities
358 .iter()
359 .map(|e| (e.name.as_str(), e))
360 .collect();
361
362 for name in old_entities.keys() {
364 if !new_entities.contains_key(name) {
365 operations.push(SchemaOperation::RemoveEntity {
366 name: name.to_string(),
367 });
368 }
369 }
370
371 for (name, entity) in &new_entities {
373 if !old_entities.contains_key(name) {
374 let fields: Vec<FieldSpec> = entity
375 .fields
376 .iter()
377 .map(|f| FieldSpec {
378 name: f.name.clone(),
379 field_type: f.field_type.clone(),
380 optional: f.optional,
381 unique: f.unique,
382 })
383 .collect();
384 operations.push(SchemaOperation::CreateEntity {
385 name: name.to_string(),
386 fields,
387 });
388 for index in &entity.indexes {
389 operations.push(SchemaOperation::AddIndex {
390 entity: name.to_string(),
391 name: index.name.clone(),
392 fields: index.fields.clone(),
393 unique: index.unique,
394 });
395 }
396 }
397 }
398
399 for (name, new_entity) in &new_entities {
401 if let Some(old_entity) = old_entities.get(name) {
402 let old_fields: std::collections::HashSet<&str> =
403 old_entity.fields.iter().map(|f| f.name.as_str()).collect();
404 let new_fields: std::collections::HashSet<&str> =
405 new_entity.fields.iter().map(|f| f.name.as_str()).collect();
406
407 for field in &new_entity.fields {
408 if !old_fields.contains(field.name.as_str()) {
409 operations.push(SchemaOperation::AddField {
410 entity: name.to_string(),
411 field: FieldSpec {
412 name: field.name.clone(),
413 field_type: field.field_type.clone(),
414 optional: field.optional,
415 unique: field.unique,
416 },
417 });
418 }
419 }
420
421 for field in &old_entity.fields {
422 if !new_fields.contains(field.name.as_str()) {
423 operations.push(SchemaOperation::RemoveField {
424 entity: name.to_string(),
425 field_name: field.name.clone(),
426 });
427 }
428 }
429
430 let old_indexes: std::collections::HashSet<&str> =
432 old_entity.indexes.iter().map(|i| i.name.as_str()).collect();
433 let new_indexes: std::collections::HashSet<&str> =
434 new_entity.indexes.iter().map(|i| i.name.as_str()).collect();
435
436 for index in &new_entity.indexes {
437 if !old_indexes.contains(index.name.as_str()) {
438 operations.push(SchemaOperation::AddIndex {
439 entity: name.to_string(),
440 name: index.name.clone(),
441 fields: index.fields.clone(),
442 unique: index.unique,
443 });
444 }
445 }
446
447 for index in &old_entity.indexes {
448 if !new_indexes.contains(index.name.as_str()) {
449 operations.push(SchemaOperation::RemoveIndex {
450 entity: name.to_string(),
451 name: index.name.clone(),
452 });
453 }
454 }
455 }
456 }
457
458 if operations.is_empty() {
459 operations.push(SchemaOperation::Noop);
460 }
461
462 Ok(SchemaPlan { operations })
463 }
464}
465
466#[cfg(test)]
471mod tests {
472 use super::*;
473 use pylon_kernel::*;
474
475 fn minimal_manifest() -> AppManifest {
476 AppManifest {
477 manifest_version: MANIFEST_VERSION,
478 name: "test".into(),
479 version: "0.1.0".into(),
480 entities: vec![ManifestEntity {
481 name: "User".into(),
482 fields: vec![ManifestField {
483 name: "email".into(),
484 field_type: "string".into(),
485 optional: false,
486 unique: true,
487 }],
488 indexes: vec![],
489 relations: vec![],
490 }],
491 routes: vec![],
492 queries: vec![],
493 actions: vec![],
494 policies: vec![],
495 }
496 }
497
498 #[test]
499 fn dry_run_creates_all_entities() {
500 let adapter = DryRunAdapter;
501 let manifest = minimal_manifest();
502 let plan = adapter.plan_schema(&manifest).unwrap();
503
504 assert_eq!(plan.operations.len(), 1);
505 match &plan.operations[0] {
506 SchemaOperation::CreateEntity { name, fields } => {
507 assert_eq!(name, "User");
508 assert_eq!(fields.len(), 1);
509 assert_eq!(fields[0].name, "email");
510 }
511 other => panic!("expected CreateEntity, got: {other:?}"),
512 }
513 }
514
515 #[test]
516 fn dry_run_includes_indexes() {
517 let adapter = DryRunAdapter;
518 let mut manifest = minimal_manifest();
519 manifest.entities[0].indexes.push(ManifestIndex {
520 name: "by_email".into(),
521 fields: vec!["email".into()],
522 unique: true,
523 });
524 let plan = adapter.plan_schema(&manifest).unwrap();
525
526 assert_eq!(plan.operations.len(), 2);
527 match &plan.operations[1] {
528 SchemaOperation::AddIndex {
529 entity,
530 name,
531 fields,
532 unique,
533 } => {
534 assert_eq!(entity, "User");
535 assert_eq!(name, "by_email");
536 assert_eq!(fields, &vec!["email".to_string()]);
537 assert!(unique);
538 }
539 other => panic!("expected AddIndex, got: {other:?}"),
540 }
541 }
542
543 #[test]
544 fn dry_run_empty_manifest_produces_noop() {
545 let adapter = DryRunAdapter;
546 let manifest = AppManifest {
547 manifest_version: MANIFEST_VERSION,
548 name: "empty".into(),
549 version: "0.1.0".into(),
550 entities: vec![],
551 routes: vec![],
552 queries: vec![],
553 actions: vec![],
554 policies: vec![],
555 };
556 let plan = adapter.plan_schema(&manifest).unwrap();
557 assert!(plan.is_empty());
558 }
559
560 #[test]
561 fn diff_adapter_detects_new_entity() {
562 let old = minimal_manifest();
563 let mut new = minimal_manifest();
564 new.entities.push(ManifestEntity {
565 name: "Post".into(),
566 fields: vec![ManifestField {
567 name: "title".into(),
568 field_type: "string".into(),
569 optional: false,
570 unique: false,
571 }],
572 indexes: vec![],
573 relations: vec![],
574 });
575
576 let adapter = DiffAdapter { from: old };
577 let plan = adapter.plan_schema(&new).unwrap();
578
579 assert!(plan.operations.iter().any(|op| matches!(
580 op,
581 SchemaOperation::CreateEntity { name, .. } if name == "Post"
582 )));
583 }
584
585 #[test]
586 fn diff_adapter_detects_removed_entity() {
587 let old = minimal_manifest();
588 let mut new = minimal_manifest();
589 new.entities.clear();
590
591 let adapter = DiffAdapter { from: old };
592 let plan = adapter.plan_schema(&new).unwrap();
593
594 assert!(plan.operations.iter().any(|op| matches!(
595 op,
596 SchemaOperation::RemoveEntity { name } if name == "User"
597 )));
598 }
599
600 #[test]
601 fn diff_adapter_detects_added_field() {
602 let old = minimal_manifest();
603 let mut new = minimal_manifest();
604 new.entities[0].fields.push(ManifestField {
605 name: "name".into(),
606 field_type: "string".into(),
607 optional: false,
608 unique: false,
609 });
610
611 let adapter = DiffAdapter { from: old };
612 let plan = adapter.plan_schema(&new).unwrap();
613
614 assert!(plan.operations.iter().any(|op| matches!(
615 op,
616 SchemaOperation::AddField { entity, field } if entity == "User" && field.name == "name"
617 )));
618 }
619
620 #[test]
621 fn diff_adapter_detects_removed_field() {
622 let old = minimal_manifest();
623 let mut new = minimal_manifest();
624 new.entities[0].fields.clear();
625
626 let adapter = DiffAdapter { from: old };
627 let plan = adapter.plan_schema(&new).unwrap();
628
629 assert!(plan.operations.iter().any(|op| matches!(
630 op,
631 SchemaOperation::RemoveField { entity, field_name } if entity == "User" && field_name == "email"
632 )));
633 }
634
635 #[test]
636 fn diff_adapter_no_changes_produces_noop() {
637 let m = minimal_manifest();
638 let adapter = DiffAdapter { from: m.clone() };
639 let plan = adapter.plan_schema(&m).unwrap();
640 assert!(plan.is_empty());
641 }
642
643 #[test]
644 fn apply_schema_not_implemented() {
645 let adapter = DryRunAdapter;
646 let plan = SchemaPlan { operations: vec![] };
647 let result = adapter.apply_schema(&plan);
648 assert!(result.is_err());
649 assert_eq!(result.unwrap_err().code, "APPLY_NOT_IMPLEMENTED");
650 }
651
652 #[test]
655 fn safe_plan_has_no_warnings() {
656 let plan = SchemaPlan {
657 operations: vec![
658 SchemaOperation::CreateEntity {
659 name: "User".into(),
660 fields: vec![],
661 },
662 SchemaOperation::AddIndex {
663 entity: "User".into(),
664 name: "idx".into(),
665 fields: vec!["email".into()],
666 unique: true,
667 },
668 SchemaOperation::Noop,
669 ],
670 };
671 let analysis = analyze_plan(&plan);
672 assert!(!analysis.destructive);
673 assert!(!analysis.has_unsupported);
674 assert!(analysis.warnings.is_empty());
675 }
676
677 #[test]
678 fn remove_entity_is_destructive() {
679 let plan = SchemaPlan {
680 operations: vec![SchemaOperation::RemoveEntity {
681 name: "User".into(),
682 }],
683 };
684 let analysis = analyze_plan(&plan);
685 assert!(analysis.destructive);
686 assert!(analysis.has_unsupported);
687 assert_eq!(analysis.warnings.len(), 1);
688 assert_eq!(analysis.warnings[0].code, "DESTRUCTIVE_REMOVE_ENTITY");
689 }
690
691 #[test]
692 fn remove_field_is_destructive() {
693 let plan = SchemaPlan {
694 operations: vec![SchemaOperation::RemoveField {
695 entity: "User".into(),
696 field_name: "email".into(),
697 }],
698 };
699 let analysis = analyze_plan(&plan);
700 assert!(analysis.destructive);
701 assert!(analysis.has_unsupported);
702 assert_eq!(analysis.warnings[0].code, "DESTRUCTIVE_REMOVE_FIELD");
703 }
704
705 #[test]
706 fn remove_index_is_unsupported_not_destructive() {
707 let plan = SchemaPlan {
708 operations: vec![SchemaOperation::RemoveIndex {
709 entity: "User".into(),
710 name: "idx".into(),
711 }],
712 };
713 let analysis = analyze_plan(&plan);
714 assert!(!analysis.destructive);
715 assert!(analysis.has_unsupported);
716 assert_eq!(analysis.warnings[0].code, "UNSUPPORTED_REMOVE_INDEX");
717 }
718
719 #[test]
720 fn mixed_plan_flags_both() {
721 let plan = SchemaPlan {
722 operations: vec![
723 SchemaOperation::CreateEntity {
724 name: "Post".into(),
725 fields: vec![],
726 },
727 SchemaOperation::RemoveEntity {
728 name: "User".into(),
729 },
730 SchemaOperation::RemoveIndex {
731 entity: "Post".into(),
732 name: "idx".into(),
733 },
734 ],
735 };
736 let analysis = analyze_plan(&plan);
737 assert!(analysis.destructive);
738 assert!(analysis.has_unsupported);
739 assert_eq!(analysis.warnings.len(), 2);
740 }
741
742 #[test]
743 fn noop_plan_is_safe() {
744 let plan = SchemaPlan {
745 operations: vec![SchemaOperation::Noop],
746 };
747 let analysis = analyze_plan(&plan);
748 assert!(!analysis.destructive);
749 assert!(!analysis.has_unsupported);
750 assert!(analysis.warnings.is_empty());
751 }
752
753 #[test]
756 fn storage_error_display() {
757 let err = StorageError {
758 code: "TEST".into(),
759 message: "msg".into(),
760 };
761 assert_eq!(format!("{err}"), "[TEST] msg");
762 }
763
764 #[test]
767 fn plan_from_snapshot_empty_both() {
768 let snapshot = SchemaSnapshot { tables: vec![] };
769 let manifest = AppManifest {
770 manifest_version: MANIFEST_VERSION,
771 name: "test".into(),
772 version: "0.1.0".into(),
773 entities: vec![],
774 routes: vec![],
775 queries: vec![],
776 actions: vec![],
777 policies: vec![],
778 };
779 let plan = plan_from_snapshot(&snapshot, &manifest);
780 assert!(plan.is_empty());
781 }
782
783 #[test]
784 fn plan_from_snapshot_add_field_to_existing() {
785 let snapshot = SchemaSnapshot {
786 tables: vec![TableSnapshot {
787 name: "User".into(),
788 columns: vec![
789 ColumnSnapshot {
790 name: "id".into(),
791 column_type: "TEXT".into(),
792 notnull: true,
793 primary_key: true,
794 },
795 ColumnSnapshot {
796 name: "email".into(),
797 column_type: "TEXT".into(),
798 notnull: true,
799 primary_key: false,
800 },
801 ],
802 indexes: vec![],
803 }],
804 };
805 let manifest = AppManifest {
806 manifest_version: MANIFEST_VERSION,
807 name: "test".into(),
808 version: "0.1.0".into(),
809 entities: vec![ManifestEntity {
810 name: "User".into(),
811 fields: vec![
812 ManifestField {
813 name: "email".into(),
814 field_type: "string".into(),
815 optional: false,
816 unique: true,
817 },
818 ManifestField {
819 name: "name".into(),
820 field_type: "string".into(),
821 optional: false,
822 unique: false,
823 },
824 ],
825 indexes: vec![],
826 relations: vec![],
827 }],
828 routes: vec![],
829 queries: vec![],
830 actions: vec![],
831 policies: vec![],
832 };
833 let plan = plan_from_snapshot(&snapshot, &manifest);
834 assert!(plan.operations.iter().any(|op| matches!(op, SchemaOperation::AddField { entity, field } if entity == "User" && field.name == "name")));
835 }
836
837 #[test]
838 fn plan_from_snapshot_add_index() {
839 let snapshot = SchemaSnapshot {
840 tables: vec![TableSnapshot {
841 name: "User".into(),
842 columns: vec![
843 ColumnSnapshot {
844 name: "id".into(),
845 column_type: "TEXT".into(),
846 notnull: true,
847 primary_key: true,
848 },
849 ColumnSnapshot {
850 name: "email".into(),
851 column_type: "TEXT".into(),
852 notnull: true,
853 primary_key: false,
854 },
855 ],
856 indexes: vec![], }],
858 };
859 let manifest = AppManifest {
860 manifest_version: MANIFEST_VERSION,
861 name: "test".into(),
862 version: "0.1.0".into(),
863 entities: vec![ManifestEntity {
864 name: "User".into(),
865 fields: vec![ManifestField {
866 name: "email".into(),
867 field_type: "string".into(),
868 optional: false,
869 unique: true,
870 }],
871 indexes: vec![ManifestIndex {
872 name: "by_email".into(),
873 fields: vec!["email".into()],
874 unique: true,
875 }],
876 relations: vec![],
877 }],
878 routes: vec![],
879 queries: vec![],
880 actions: vec![],
881 policies: vec![],
882 };
883 let plan = plan_from_snapshot(&snapshot, &manifest);
884 assert!(plan
885 .operations
886 .iter()
887 .any(|op| matches!(op, SchemaOperation::AddIndex { name, .. } if name == "by_email")));
888 }
889
890 #[test]
893 fn plan_empty_vec_is_empty() {
894 let plan = SchemaPlan { operations: vec![] };
895 assert!(plan.is_empty());
896 }
897
898 #[test]
899 fn plan_with_real_ops_not_empty() {
900 let plan = SchemaPlan {
901 operations: vec![SchemaOperation::CreateEntity {
902 name: "X".into(),
903 fields: vec![],
904 }],
905 };
906 assert!(!plan.is_empty());
907 }
908
909 #[test]
912 fn plan_analysis_serializable() {
913 let analysis = analyze_plan(&SchemaPlan {
914 operations: vec![SchemaOperation::RemoveEntity { name: "X".into() }],
915 });
916 let json = serde_json::to_string(&analysis).unwrap();
917 assert!(json.contains("DESTRUCTIVE_REMOVE_ENTITY"));
918 }
919}