1use std::collections::BTreeSet;
24
25use serde::{Deserialize, Serialize};
26
27use crate::schema::{
28 Schema, SchemaField, SchemaModel, SchemaRelation, SCHEMA_VERSION, VALID_TYPE_NAMES,
29};
30
31pub mod executor;
32pub mod industry;
33pub mod intake;
34pub mod planner;
35pub mod review;
36
37#[cfg(test)]
38mod context_tests;
39#[cfg(test)]
40mod executor_tests;
41#[cfg(test)]
42mod executor_tests_advanced;
43#[cfg(test)]
44mod planner_tests;
45#[cfg(test)]
46mod review_tests;
47
48pub use executor::{
49 execute_plan_document, plan_execution, plan_retrofit_foreign_keys, render_preview_human,
50 ExecuteOptions, ExecutionError, ExecutionPreview, ExecutionResult, FileChangeKind,
51 ParsedModelsFile, PlannedFileChange, ProjectView, RetrofitReport,
52};
53pub use industry::{industry_schema_for, IndustrySchema};
54pub use intake::{sketch, FieldSketch, ModelSketch, ProjectSketch};
55pub use planner::{generate_plan, ContextConfig, PlanError, PlanRequest, PlanResult};
56pub use review::{
57 build_plan_document, build_plan_document_with_timestamp, classify_risk, compute_impact,
58 load_plan, render_plan_document_json, render_review_human, review_plan, warnings_for,
59 LoadedPlan, PlanDocument, PlanImpact, PlanReview, ReviewError, RiskLevel, ValidationOutcome,
60 PLAN_DOCUMENT_VERSION,
61};
62
63#[non_exhaustive]
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
73pub enum Primitive {
74 AddModel(AddModel),
75 RemoveModel(RemoveModel),
76 RenameModel(RenameModel),
77 AddField(AddField),
78 RemoveField(RemoveField),
79 RenameField(RenameField),
80 ChangeFieldType(ChangeFieldType),
81 ChangeFieldNullability(ChangeFieldNullability),
82 AddRelation(AddRelation),
83 RemoveRelation(RemoveRelation),
84 UpdateAdmin(UpdateAdmin),
85 CreateMigration(CreateMigration),
91}
92
93impl Primitive {
94 pub fn is_developer_only(&self) -> bool {
106 matches!(self, Primitive::CreateMigration(_))
107 }
108
109 pub fn op_name(&self) -> &'static str {
113 match self {
114 Primitive::AddModel(_) => "add_model",
115 Primitive::RemoveModel(_) => "remove_model",
116 Primitive::RenameModel(_) => "rename_model",
117 Primitive::AddField(_) => "add_field",
118 Primitive::RemoveField(_) => "remove_field",
119 Primitive::RenameField(_) => "rename_field",
120 Primitive::ChangeFieldType(_) => "change_field_type",
121 Primitive::ChangeFieldNullability(_) => "change_field_nullability",
122 Primitive::AddRelation(_) => "add_relation",
123 Primitive::RemoveRelation(_) => "remove_relation",
124 Primitive::UpdateAdmin(_) => "update_admin",
125 Primitive::CreateMigration(_) => "create_migration",
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[serde(deny_unknown_fields)]
133pub struct FieldSpec {
134 pub name: String,
135 #[serde(rename = "type")]
139 pub ty: String,
140 #[serde(default)]
141 pub nullable: bool,
142 #[serde(default = "default_editable")]
143 pub editable: bool,
144}
145
146fn default_editable() -> bool {
147 true
148}
149
150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
151#[serde(deny_unknown_fields)]
152pub struct AddModel {
153 pub name: String,
155 pub table: String,
157 pub fields: Vec<FieldSpec>,
158}
159
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161#[serde(deny_unknown_fields)]
162pub struct RemoveModel {
163 pub name: String,
164}
165
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct AddField {
169 pub model: String,
170 #[serde(flatten)]
171 pub field: FieldSpec,
172}
173
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175#[serde(deny_unknown_fields)]
176pub struct RemoveField {
177 pub model: String,
178 pub field: String,
179}
180
181#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184#[serde(deny_unknown_fields)]
185pub struct RenameModel {
186 pub from: String,
187 pub to: String,
188}
189
190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192#[serde(deny_unknown_fields)]
193pub struct RenameField {
194 pub model: String,
195 pub from: String,
196 pub to: String,
197}
198
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
204#[serde(deny_unknown_fields)]
205pub struct ChangeFieldType {
206 pub model: String,
207 pub field: String,
208 pub new_type: String,
211}
212
213#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
215#[serde(deny_unknown_fields)]
216pub struct ChangeFieldNullability {
217 pub model: String,
218 pub field: String,
219 pub nullable: bool,
220}
221
222pub use crate::schema::RelationKind;
229
230#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
231#[serde(deny_unknown_fields)]
232pub struct AddRelation {
233 pub from: String,
234 pub kind: RelationKind,
235 pub to: String,
236 pub via: String,
240 #[serde(default)]
247 pub required: bool,
248 #[serde(default)]
255 pub on_delete: OnDelete,
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
261#[serde(rename_all = "snake_case")]
262pub enum OnDelete {
263 #[default]
265 Restrict,
266 Cascade,
269 SetNull,
272}
273
274impl OnDelete {
275 pub fn sql(self) -> &'static str {
278 match self {
279 OnDelete::Restrict => "ON DELETE RESTRICT",
280 OnDelete::Cascade => "ON DELETE CASCADE",
281 OnDelete::SetNull => "ON DELETE SET NULL",
282 }
283 }
284
285 pub fn as_str(self) -> &'static str {
287 match self {
288 OnDelete::Restrict => "restrict",
289 OnDelete::Cascade => "cascade",
290 OnDelete::SetNull => "set_null",
291 }
292 }
293}
294
295#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
296#[serde(deny_unknown_fields)]
297pub struct RemoveRelation {
298 pub from: String,
299 pub via: String,
300}
301
302#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
308#[serde(deny_unknown_fields)]
309pub struct UpdateAdmin {
310 pub model: String,
311 pub field: String,
312 pub attr: String,
313 pub value: serde_json::Value,
314}
315
316#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
323#[serde(deny_unknown_fields)]
324pub struct CreateMigration {
325 pub name: String,
326 pub sql: String,
327}
328
329#[non_exhaustive]
333#[derive(Debug, Clone, PartialEq)]
334pub enum PrimitiveError {
335 EmptyIdentifier(&'static str),
337 UnknownType {
339 model: String,
340 field: String,
341 ty: String,
342 },
343 DuplicateFieldInAddModel { model: String, field: String },
345 AlreadyExists { what: &'static str, name: String },
347 NotFound { what: &'static str, name: String },
349 UnknownRelationTarget { from: String, to: String },
351 UnknownAdminAttribute { attr: String },
353 NoOpRename { what: &'static str, name: String },
356 DeveloperOnlyNotAllowedInPlan { op: &'static str },
361 InStep {
364 step: usize,
365 inner: Box<PrimitiveError>,
366 },
367}
368
369impl std::fmt::Display for PrimitiveError {
370 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
371 match self {
372 Self::EmptyIdentifier(which) => write!(f, "empty {which}"),
373 Self::UnknownType { model, field, ty } => write!(
374 f,
375 "field `{model}.{field}` has unknown type `{ty}` (valid: {valid})",
376 valid = VALID_TYPE_NAMES.join(", "),
377 ),
378 Self::DuplicateFieldInAddModel { model, field } => write!(
379 f,
380 "add_model `{model}` lists field `{field}` more than once",
381 ),
382 Self::AlreadyExists { what, name } => write!(f, "{what} `{name}` already exists"),
383 Self::NotFound { what, name } => write!(f, "{what} `{name}` does not exist"),
384 Self::UnknownRelationTarget { from, to } => {
385 write!(f, "relation from `{from}` targets unknown model `{to}`")
386 }
387 Self::UnknownAdminAttribute { attr } => {
388 write!(f, "unknown admin attribute `{attr}`")
389 }
390 Self::NoOpRename { what, name } => {
391 write!(f, "rename of {what} `{name}` is a no-op (from == to)")
392 }
393 Self::DeveloperOnlyNotAllowedInPlan { op } => write!(
394 f,
395 "`{op}` is developer-only and cannot appear in an AI plan"
396 ),
397 Self::InStep { step, inner } => write!(f, "step {step}: {inner}"),
398 }
399 }
400}
401
402impl std::error::Error for PrimitiveError {}
403
404const ALLOWED_ADMIN_ATTRS: &[&str] = &["searchable", "editable", "nullable"];
408
409pub fn validate_primitive(p: &Primitive) -> Result<(), PrimitiveError> {
413 match p {
414 Primitive::AddModel(m) => {
415 require_nonempty(&m.name, "model name")?;
416 require_nonempty(&m.table, "table name")?;
417 let mut seen: BTreeSet<&str> = BTreeSet::new();
418 for field in &m.fields {
419 validate_field_spec(&m.name, field)?;
420 if !seen.insert(field.name.as_str()) {
421 return Err(PrimitiveError::DuplicateFieldInAddModel {
422 model: m.name.clone(),
423 field: field.name.clone(),
424 });
425 }
426 }
427 Ok(())
428 }
429 Primitive::RemoveModel(m) => {
430 require_nonempty(&m.name, "model name")?;
431 Ok(())
432 }
433 Primitive::AddField(af) => {
434 require_nonempty(&af.model, "model name")?;
435 validate_field_spec(&af.model, &af.field)
436 }
437 Primitive::RemoveField(rf) => {
438 require_nonempty(&rf.model, "model name")?;
439 require_nonempty(&rf.field, "field name")?;
440 Ok(())
441 }
442 Primitive::RenameModel(rm) => {
443 require_nonempty(&rm.from, "from")?;
444 require_nonempty(&rm.to, "to")?;
445 if rm.from == rm.to {
446 return Err(PrimitiveError::NoOpRename {
447 what: "model",
448 name: rm.from.clone(),
449 });
450 }
451 Ok(())
452 }
453 Primitive::RenameField(rf) => {
454 require_nonempty(&rf.model, "model name")?;
455 require_nonempty(&rf.from, "from")?;
456 require_nonempty(&rf.to, "to")?;
457 if rf.from == rf.to {
458 return Err(PrimitiveError::NoOpRename {
459 what: "field",
460 name: format!("{}.{}", rf.model, rf.from),
461 });
462 }
463 Ok(())
464 }
465 Primitive::ChangeFieldType(c) => {
466 require_nonempty(&c.model, "model name")?;
467 require_nonempty(&c.field, "field name")?;
468 if !VALID_TYPE_NAMES.contains(&c.new_type.as_str()) {
469 return Err(PrimitiveError::UnknownType {
470 model: c.model.clone(),
471 field: c.field.clone(),
472 ty: c.new_type.clone(),
473 });
474 }
475 Ok(())
476 }
477 Primitive::ChangeFieldNullability(c) => {
478 require_nonempty(&c.model, "model name")?;
479 require_nonempty(&c.field, "field name")?;
480 Ok(())
481 }
482 Primitive::AddRelation(r) => {
483 require_nonempty(&r.from, "from")?;
484 require_nonempty(&r.to, "to")?;
485 require_nonempty(&r.via, "via")?;
486 Ok(())
488 }
489 Primitive::RemoveRelation(r) => {
490 require_nonempty(&r.from, "from")?;
491 require_nonempty(&r.via, "via")?;
492 Ok(())
493 }
494 Primitive::UpdateAdmin(u) => {
495 require_nonempty(&u.model, "model name")?;
496 require_nonempty(&u.field, "field name")?;
497 require_nonempty(&u.attr, "attr")?;
498 if !ALLOWED_ADMIN_ATTRS.contains(&u.attr.as_str()) {
499 return Err(PrimitiveError::UnknownAdminAttribute {
500 attr: u.attr.clone(),
501 });
502 }
503 Ok(())
504 }
505 Primitive::CreateMigration(m) => {
506 require_nonempty(&m.name, "migration name")?;
507 require_nonempty(&m.sql, "migration sql")?;
508 Ok(())
509 }
510 }
511}
512
513fn require_nonempty(s: &str, which: &'static str) -> Result<(), PrimitiveError> {
514 if s.trim().is_empty() {
515 Err(PrimitiveError::EmptyIdentifier(which))
516 } else {
517 Ok(())
518 }
519}
520
521fn validate_field_spec(model: &str, f: &FieldSpec) -> Result<(), PrimitiveError> {
522 require_nonempty(&f.name, "field name")?;
523 if !VALID_TYPE_NAMES.contains(&f.ty.as_str()) {
524 return Err(PrimitiveError::UnknownType {
525 model: model.to_string(),
526 field: f.name.clone(),
527 ty: f.ty.clone(),
528 });
529 }
530 Ok(())
531}
532
533#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
544#[serde(deny_unknown_fields)]
545pub struct Plan {
546 pub steps: Vec<Primitive>,
547}
548
549impl Plan {
550 pub fn new(steps: Vec<Primitive>) -> Self {
551 Self { steps }
552 }
553
554 pub fn is_empty(&self) -> bool {
555 self.steps.is_empty()
556 }
557
558 pub fn len(&self) -> usize {
559 self.steps.len()
560 }
561
562 pub fn validate(&self, initial: &Schema) -> Result<(), PrimitiveError> {
576 let mut state = initial.clone();
577 for (idx, step) in self.steps.iter().enumerate() {
578 if step.is_developer_only() {
579 return Err(PrimitiveError::InStep {
580 step: idx,
581 inner: Box::new(PrimitiveError::DeveloperOnlyNotAllowedInPlan {
582 op: step.op_name(),
583 }),
584 });
585 }
586 if let Err(inner) = validate_primitive(step) {
587 return Err(PrimitiveError::InStep {
588 step: idx,
589 inner: Box::new(inner),
590 });
591 }
592 if let Err(inner) = validate_against(step, &state) {
593 return Err(PrimitiveError::InStep {
594 step: idx,
595 inner: Box::new(inner),
596 });
597 }
598 apply_shadow(step, &mut state);
599 }
600 Ok(())
601 }
602}
603
604pub fn validate_against(p: &Primitive, schema: &Schema) -> Result<(), PrimitiveError> {
608 match p {
609 Primitive::AddModel(m) => {
610 if schema.models.iter().any(|x| x.name == m.name) {
611 return Err(PrimitiveError::AlreadyExists {
612 what: "model",
613 name: m.name.clone(),
614 });
615 }
616 Ok(())
617 }
618 Primitive::RemoveModel(m) => {
619 if !schema.models.iter().any(|x| x.name == m.name) {
620 return Err(PrimitiveError::NotFound {
621 what: "model",
622 name: m.name.clone(),
623 });
624 }
625 Ok(())
626 }
627 Primitive::AddField(af) => {
628 let model = find_model(schema, &af.model)?;
629 if model.fields.iter().any(|f| f.name == af.field.name) {
630 return Err(PrimitiveError::AlreadyExists {
631 what: "field",
632 name: format!("{}.{}", af.model, af.field.name),
633 });
634 }
635 Ok(())
636 }
637 Primitive::RemoveField(rf) => {
638 let model = find_model(schema, &rf.model)?;
639 if !model.fields.iter().any(|f| f.name == rf.field) {
640 return Err(PrimitiveError::NotFound {
641 what: "field",
642 name: format!("{}.{}", rf.model, rf.field),
643 });
644 }
645 Ok(())
646 }
647 Primitive::RenameModel(rm) => {
648 let _ = find_model(schema, &rm.from)?;
649 if schema.models.iter().any(|m| m.name == rm.to) {
650 return Err(PrimitiveError::AlreadyExists {
651 what: "model",
652 name: rm.to.clone(),
653 });
654 }
655 Ok(())
656 }
657 Primitive::RenameField(rf) => {
658 let model = find_model(schema, &rf.model)?;
659 if !model.fields.iter().any(|f| f.name == rf.from) {
660 return Err(PrimitiveError::NotFound {
661 what: "field",
662 name: format!("{}.{}", rf.model, rf.from),
663 });
664 }
665 if model.fields.iter().any(|f| f.name == rf.to) {
666 return Err(PrimitiveError::AlreadyExists {
667 what: "field",
668 name: format!("{}.{}", rf.model, rf.to),
669 });
670 }
671 Ok(())
672 }
673 Primitive::ChangeFieldType(c) => {
674 let model = find_model(schema, &c.model)?;
675 if !model.fields.iter().any(|f| f.name == c.field) {
676 return Err(PrimitiveError::NotFound {
677 what: "field",
678 name: format!("{}.{}", c.model, c.field),
679 });
680 }
681 Ok(())
682 }
683 Primitive::ChangeFieldNullability(c) => {
684 let model = find_model(schema, &c.model)?;
685 if !model.fields.iter().any(|f| f.name == c.field) {
686 return Err(PrimitiveError::NotFound {
687 what: "field",
688 name: format!("{}.{}", c.model, c.field),
689 });
690 }
691 Ok(())
692 }
693 Primitive::AddRelation(r) => {
694 let from = find_model(schema, &r.from)?;
695 if !schema.models.iter().any(|m| m.name == r.to) {
696 return Err(PrimitiveError::UnknownRelationTarget {
697 from: r.from.clone(),
698 to: r.to.clone(),
699 });
700 }
701 if from.relations.iter().any(|rel| rel.via == r.via) {
702 return Err(PrimitiveError::AlreadyExists {
703 what: "relation",
704 name: format!("{}.{}", r.from, r.via),
705 });
706 }
707 Ok(())
708 }
709 Primitive::RemoveRelation(r) => {
710 let from = find_model(schema, &r.from)?;
711 if !from.relations.iter().any(|rel| rel.via == r.via) {
712 return Err(PrimitiveError::NotFound {
713 what: "relation",
714 name: format!("{}.{}", r.from, r.via),
715 });
716 }
717 Ok(())
718 }
719 Primitive::UpdateAdmin(u) => {
720 let model = find_model(schema, &u.model)?;
721 if !model.fields.iter().any(|f| f.name == u.field) {
722 return Err(PrimitiveError::NotFound {
723 what: "field",
724 name: format!("{}.{}", u.model, u.field),
725 });
726 }
727 Ok(())
728 }
729 Primitive::CreateMigration(_) => Ok(()),
732 }
733}
734
735fn find_model<'a>(schema: &'a Schema, name: &str) -> Result<&'a SchemaModel, PrimitiveError> {
736 schema
737 .models
738 .iter()
739 .find(|m| m.name == name)
740 .ok_or_else(|| PrimitiveError::NotFound {
741 what: "model",
742 name: name.to_string(),
743 })
744}
745
746fn apply_shadow(p: &Primitive, schema: &mut Schema) {
753 match p {
754 Primitive::AddModel(m) => {
755 let mut fields: Vec<SchemaField> = m
756 .fields
757 .iter()
758 .map(|f| SchemaField {
759 name: f.name.clone(),
760 ty: f.ty.clone(),
761 nullable: f.nullable,
762 editable: f.editable,
763 relation: None,
764 })
765 .collect();
766 fields.sort_by(|a, b| a.name.cmp(&b.name));
767 schema.models.push(SchemaModel {
768 name: m.name.clone(),
769 table: m.table.clone(),
770 admin_name: m.table.clone(),
771 display_name: m.name.clone(),
772 singular_name: m.name.clone(),
773 fields,
774 relations: Vec::new(),
775 core: false,
780 });
781 schema.models.sort_by(|a, b| a.name.cmp(&b.name));
782 }
783 Primitive::RemoveModel(m) => {
784 schema.models.retain(|x| x.name != m.name);
785 }
786 Primitive::AddField(af) => {
787 if let Some(model) = schema.models.iter_mut().find(|m| m.name == af.model) {
788 model.fields.push(SchemaField {
789 name: af.field.name.clone(),
790 ty: af.field.ty.clone(),
791 nullable: af.field.nullable,
792 editable: af.field.editable,
793 relation: None,
794 });
795 model.fields.sort_by(|a, b| a.name.cmp(&b.name));
796 }
797 }
798 Primitive::RemoveField(rf) => {
799 if let Some(model) = schema.models.iter_mut().find(|m| m.name == rf.model) {
800 model.fields.retain(|f| f.name != rf.field);
801 }
802 }
803 Primitive::RenameModel(rm) => {
804 if let Some(model) = schema.models.iter_mut().find(|m| m.name == rm.from) {
805 model.name = rm.to.clone();
806 model.singular_name = rm.to.clone();
807 }
808 schema.models.sort_by(|a, b| a.name.cmp(&b.name));
809 }
810 Primitive::RenameField(rf) => {
811 if let Some(model) = schema.models.iter_mut().find(|m| m.name == rf.model) {
812 if let Some(field) = model.fields.iter_mut().find(|f| f.name == rf.from) {
813 field.name = rf.to.clone();
814 }
815 model.fields.sort_by(|a, b| a.name.cmp(&b.name));
816 }
817 }
818 Primitive::ChangeFieldType(c) => {
819 if let Some(model) = schema.models.iter_mut().find(|m| m.name == c.model) {
820 if let Some(field) = model.fields.iter_mut().find(|f| f.name == c.field) {
821 field.ty = c.new_type.clone();
822 }
823 }
824 }
825 Primitive::ChangeFieldNullability(c) => {
826 if let Some(model) = schema.models.iter_mut().find(|m| m.name == c.model) {
827 if let Some(field) = model.fields.iter_mut().find(|f| f.name == c.field) {
828 field.nullable = c.nullable;
829 }
830 }
831 }
832 Primitive::AddRelation(r) => {
833 if let Some(model) = schema.models.iter_mut().find(|m| m.name == r.from) {
834 model.relations.push(SchemaRelation {
835 kind: r.kind.as_str().to_string(),
836 to: r.to.clone(),
837 via: r.via.clone(),
838 });
839 }
840 }
841 Primitive::RemoveRelation(r) => {
842 if let Some(model) = schema.models.iter_mut().find(|m| m.name == r.from) {
843 model.relations.retain(|rel| rel.via != r.via);
844 }
845 }
846 Primitive::UpdateAdmin(_) | Primitive::CreateMigration(_) => {}
850 }
851}
852
853pub fn assert_schema_version_supported(schema: &Schema) -> Result<(), PrimitiveError> {
857 if schema.version != SCHEMA_VERSION {
858 return Err(PrimitiveError::NotFound {
859 what: "schema version",
860 name: schema.version.to_string(),
861 });
862 }
863 Ok(())
864}
865
866#[cfg(test)]
867mod tests {
868 use super::*;
869 use crate::admin::{Admin, AdminField, AdminModel, FieldType, FormData};
870 use crate::error::Error as CoreError;
871 use crate::orm::{Model, Row, Value};
872
873 struct Post;
875 impl Model for Post {
876 const TABLE: &'static str = "posts";
877 const COLUMNS: &'static [&'static str] = &["id", "title"];
878 const INSERT_COLUMNS: &'static [&'static str] = &["title"];
879 fn id(&self) -> i64 {
880 0
881 }
882 fn from_row(_: Row<'_>) -> Result<Self, CoreError> {
883 unimplemented!()
884 }
885 fn insert_values(&self) -> Vec<Value> {
886 Vec::new()
887 }
888 }
889 impl AdminModel for Post {
890 const ADMIN_NAME: &'static str = "posts";
891 const DISPLAY_NAME: &'static str = "Posts";
892 const FIELDS: &'static [AdminField] = &[
893 AdminField {
894 name: "id",
895 ty: FieldType::I64,
896 editable: false,
897 nullable: false,
898 relation: None,
899 },
900 AdminField {
901 name: "title",
902 ty: FieldType::String,
903 editable: true,
904 nullable: false,
905 relation: None,
906 },
907 ];
908 fn singular_name() -> &'static str {
909 "Post"
910 }
911 fn field_display(&self, _: &str) -> Option<String> {
912 None
913 }
914 fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, CoreError> {
915 unimplemented!()
916 }
917 }
918
919 fn schema() -> Schema {
920 Schema::from_admin(&Admin::new().model::<Post>())
921 }
922
923 #[test]
926 fn add_field_round_trips_through_json() {
927 let p = Primitive::AddField(AddField {
928 model: "Post".to_string(),
929 field: FieldSpec {
930 name: "published".to_string(),
931 ty: "bool".to_string(),
932 nullable: false,
933 editable: true,
934 },
935 });
936 let json = serde_json::to_string(&p).unwrap();
937 assert!(json.contains(r#""op":"add_field""#));
938 assert!(json.contains(r#""name":"published""#));
939
940 let back: Primitive = serde_json::from_str(&json).unwrap();
941 match back {
942 Primitive::AddField(af) => {
943 assert_eq!(af.model, "Post");
944 assert_eq!(af.field.name, "published");
945 assert_eq!(af.field.ty, "bool");
946 }
947 _ => panic!("expected AddField"),
948 }
949 }
950
951 #[test]
952 fn unknown_op_is_rejected_not_swallowed() {
953 let bad = r#"{"op":"rewrite_universe","world":"goodbye"}"#;
954 let parsed: Result<Primitive, _> = serde_json::from_str(bad);
955 assert!(parsed.is_err(), "unknown op must not parse");
956 }
957
958 #[test]
959 fn unknown_field_on_known_op_is_rejected() {
960 let bad = r#"{"op":"add_model","name":"X","table":"xs","fields":[],"extra":true}"#;
963 let parsed: Result<Primitive, _> = serde_json::from_str(bad);
964 assert!(
965 parsed.is_err(),
966 "unknown keys on known ops must be rejected"
967 );
968 }
969
970 #[test]
971 fn missing_required_field_is_rejected() {
972 let bad = r#"{"op":"add_model","name":"X","fields":[]}"#;
974 let parsed: Result<Primitive, _> = serde_json::from_str(bad);
975 assert!(parsed.is_err(), "missing required fields must be rejected");
976 }
977
978 #[test]
979 fn add_relation_with_belongs_to_serialises_snake_case() {
980 let p = Primitive::AddRelation(AddRelation {
981 from: "Post".to_string(),
982 kind: RelationKind::BelongsTo,
983 to: "User".to_string(),
984 via: "user_id".to_string(),
985 required: false,
986 on_delete: OnDelete::Restrict,
987 });
988 let json = serde_json::to_string(&p).unwrap();
989 assert!(json.contains(r#""kind":"belongs_to""#));
990 }
991
992 #[test]
993 fn validate_primitive_rejects_unknown_type() {
994 let p = Primitive::AddField(AddField {
995 model: "Post".to_string(),
996 field: FieldSpec {
997 name: "flux".to_string(),
998 ty: "HyperFloat128".to_string(),
999 nullable: false,
1000 editable: true,
1001 },
1002 });
1003 assert!(matches!(
1004 validate_primitive(&p),
1005 Err(PrimitiveError::UnknownType { .. })
1006 ));
1007 }
1008
1009 #[test]
1010 fn validate_primitive_rejects_empty_names() {
1011 let p = Primitive::AddField(AddField {
1012 model: "".to_string(),
1013 field: FieldSpec {
1014 name: "x".to_string(),
1015 ty: "i64".to_string(),
1016 nullable: false,
1017 editable: true,
1018 },
1019 });
1020 assert_eq!(
1021 validate_primitive(&p),
1022 Err(PrimitiveError::EmptyIdentifier("model name"))
1023 );
1024 }
1025
1026 #[test]
1027 fn validate_primitive_rejects_duplicate_fields_in_add_model() {
1028 let p = Primitive::AddModel(AddModel {
1029 name: "Book".to_string(),
1030 table: "books".to_string(),
1031 fields: vec![
1032 FieldSpec {
1033 name: "title".to_string(),
1034 ty: "String".to_string(),
1035 nullable: false,
1036 editable: true,
1037 },
1038 FieldSpec {
1039 name: "title".to_string(),
1040 ty: "String".to_string(),
1041 nullable: false,
1042 editable: true,
1043 },
1044 ],
1045 });
1046 assert!(matches!(
1047 validate_primitive(&p),
1048 Err(PrimitiveError::DuplicateFieldInAddModel { .. })
1049 ));
1050 }
1051
1052 #[test]
1053 fn update_admin_rejects_unknown_attribute() {
1054 let p = Primitive::UpdateAdmin(UpdateAdmin {
1055 model: "Post".to_string(),
1056 field: "title".to_string(),
1057 attr: "telepathy".to_string(),
1058 value: serde_json::Value::Bool(true),
1059 });
1060 assert!(matches!(
1061 validate_primitive(&p),
1062 Err(PrimitiveError::UnknownAdminAttribute { .. })
1063 ));
1064 }
1065
1066 #[test]
1069 fn validate_against_rejects_remove_of_nonexistent_model() {
1070 let p = Primitive::RemoveModel(RemoveModel {
1071 name: "Ghost".to_string(),
1072 });
1073 let err = validate_against(&p, &schema()).unwrap_err();
1074 assert!(matches!(
1075 err,
1076 PrimitiveError::NotFound { what: "model", .. }
1077 ));
1078 }
1079
1080 #[test]
1081 fn validate_against_rejects_add_field_to_missing_model() {
1082 let p = Primitive::AddField(AddField {
1083 model: "Ghost".to_string(),
1084 field: FieldSpec {
1085 name: "age".to_string(),
1086 ty: "i32".to_string(),
1087 nullable: false,
1088 editable: true,
1089 },
1090 });
1091 let err = validate_against(&p, &schema()).unwrap_err();
1092 assert!(matches!(
1093 err,
1094 PrimitiveError::NotFound { what: "model", .. }
1095 ));
1096 }
1097
1098 #[test]
1099 fn validate_against_rejects_duplicate_field_add() {
1100 let p = Primitive::AddField(AddField {
1101 model: "Post".to_string(),
1102 field: FieldSpec {
1103 name: "title".to_string(),
1104 ty: "String".to_string(),
1105 nullable: false,
1106 editable: true,
1107 },
1108 });
1109 let err = validate_against(&p, &schema()).unwrap_err();
1110 assert!(matches!(
1111 err,
1112 PrimitiveError::AlreadyExists { what: "field", .. }
1113 ));
1114 }
1115
1116 #[test]
1117 fn validate_against_rejects_relation_to_missing_model() {
1118 let p = Primitive::AddRelation(AddRelation {
1119 from: "Post".to_string(),
1120 kind: RelationKind::BelongsTo,
1121 to: "Ghost".to_string(),
1122 via: "ghost_id".to_string(),
1123 required: false,
1124 on_delete: OnDelete::Restrict,
1125 });
1126 let err = validate_against(&p, &schema()).unwrap_err();
1127 assert!(matches!(err, PrimitiveError::UnknownRelationTarget { .. }));
1128 }
1129
1130 #[test]
1133 fn plan_validates_sequential_additions() {
1134 let plan = Plan::new(vec![
1135 Primitive::AddModel(AddModel {
1136 name: "Book".to_string(),
1137 table: "books".to_string(),
1138 fields: vec![FieldSpec {
1139 name: "title".to_string(),
1140 ty: "String".to_string(),
1141 nullable: false,
1142 editable: true,
1143 }],
1144 }),
1145 Primitive::AddField(AddField {
1148 model: "Book".to_string(),
1149 field: FieldSpec {
1150 name: "published".to_string(),
1151 ty: "bool".to_string(),
1152 nullable: false,
1153 editable: true,
1154 },
1155 }),
1156 ]);
1157 assert_eq!(plan.validate(&schema()), Ok(()));
1158 }
1159
1160 #[test]
1161 fn plan_rejects_second_add_of_same_model() {
1162 let add_book = Primitive::AddModel(AddModel {
1163 name: "Book".to_string(),
1164 table: "books".to_string(),
1165 fields: Vec::new(),
1166 });
1167 let plan = Plan::new(vec![add_book.clone(), add_book]);
1168 let err = plan.validate(&schema()).unwrap_err();
1169 assert!(
1170 matches!(
1171 &err,
1172 PrimitiveError::InStep { step: 1, inner } if matches!(**inner, PrimitiveError::AlreadyExists { what: "model", .. })
1173 ),
1174 "got: {err:?}"
1175 );
1176 }
1177
1178 #[test]
1179 fn plan_rejects_field_add_after_model_removed() {
1180 let plan = Plan::new(vec![
1181 Primitive::RemoveModel(RemoveModel {
1182 name: "Post".to_string(),
1183 }),
1184 Primitive::AddField(AddField {
1185 model: "Post".to_string(),
1186 field: FieldSpec {
1187 name: "subtitle".to_string(),
1188 ty: "String".to_string(),
1189 nullable: true,
1190 editable: true,
1191 },
1192 }),
1193 ]);
1194 let err = plan.validate(&schema()).unwrap_err();
1195 assert!(
1196 matches!(
1197 err,
1198 PrimitiveError::InStep { step: 1, inner } if matches!(*inner, PrimitiveError::NotFound { what: "model", .. })
1199 ),
1200 "plan must fail on the second step, not the first"
1201 );
1202 }
1203
1204 #[test]
1205 fn empty_plan_is_always_valid() {
1206 assert_eq!(Plan::new(Vec::new()).validate(&schema()), Ok(()));
1207 }
1208
1209 #[test]
1210 fn create_migration_is_developer_only() {
1211 let m = Primitive::CreateMigration(CreateMigration {
1212 name: "add_books".to_string(),
1213 sql: "CREATE TABLE books (id INTEGER);".to_string(),
1214 });
1215 assert!(m.is_developer_only());
1216 assert!(!Primitive::RemoveModel(RemoveModel {
1217 name: "X".to_string()
1218 })
1219 .is_developer_only());
1220 }
1221
1222 #[test]
1223 fn validate_primitive_still_accepts_create_migration_for_direct_use() {
1224 let m = Primitive::CreateMigration(CreateMigration {
1228 name: "add_books".to_string(),
1229 sql: "CREATE TABLE books (id INTEGER);".to_string(),
1230 });
1231 assert_eq!(validate_primitive(&m), Ok(()));
1232 }
1233
1234 #[test]
1235 fn plan_rejects_create_migration_even_when_structurally_valid() {
1236 let plan = Plan::new(vec![Primitive::CreateMigration(CreateMigration {
1237 name: "add_books".to_string(),
1238 sql: "CREATE TABLE books (id INTEGER);".to_string(),
1239 })]);
1240 let err = plan.validate(&schema()).unwrap_err();
1241 assert!(
1242 matches!(
1243 &err,
1244 PrimitiveError::InStep { step: 0, inner }
1245 if matches!(
1246 **inner,
1247 PrimitiveError::DeveloperOnlyNotAllowedInPlan { op: "create_migration" },
1248 )
1249 ),
1250 "got: {err:?}"
1251 );
1252 }
1253
1254 #[test]
1255 fn plan_rejects_create_migration_at_the_offending_step() {
1256 let plan = Plan::new(vec![
1257 Primitive::RemoveModel(RemoveModel {
1258 name: "Post".to_string(),
1259 }),
1260 Primitive::CreateMigration(CreateMigration {
1261 name: "tidy".to_string(),
1262 sql: "DROP TABLE posts;".to_string(),
1263 }),
1264 ]);
1265 let err = plan.validate(&schema()).unwrap_err();
1266 assert!(
1267 matches!(
1268 err,
1269 PrimitiveError::InStep { step: 1, inner }
1270 if matches!(*inner, PrimitiveError::DeveloperOnlyNotAllowedInPlan { .. })
1271 ),
1272 "developer-only check must locate the offending step index"
1273 );
1274 }
1275
1276 fn rename_model(from: &str, to: &str) -> Primitive {
1279 Primitive::RenameModel(RenameModel {
1280 from: from.to_string(),
1281 to: to.to_string(),
1282 })
1283 }
1284
1285 fn rename_field(model: &str, from: &str, to: &str) -> Primitive {
1286 Primitive::RenameField(RenameField {
1287 model: model.to_string(),
1288 from: from.to_string(),
1289 to: to.to_string(),
1290 })
1291 }
1292
1293 fn change_type(model: &str, field: &str, new_type: &str) -> Primitive {
1294 Primitive::ChangeFieldType(ChangeFieldType {
1295 model: model.to_string(),
1296 field: field.to_string(),
1297 new_type: new_type.to_string(),
1298 })
1299 }
1300
1301 fn change_nullable(model: &str, field: &str, nullable: bool) -> Primitive {
1302 Primitive::ChangeFieldNullability(ChangeFieldNullability {
1303 model: model.to_string(),
1304 field: field.to_string(),
1305 nullable,
1306 })
1307 }
1308
1309 #[test]
1310 fn rename_primitives_round_trip_through_json() {
1311 for p in [
1312 rename_model("Post", "Article"),
1313 rename_field("Post", "title", "heading"),
1314 change_type("Post", "priority", "i64"),
1315 change_nullable("Post", "title", true),
1316 ] {
1317 let json = serde_json::to_string(&p).unwrap();
1318 let back: Primitive = serde_json::from_str(&json).unwrap();
1319 assert_eq!(back.op_name(), p.op_name());
1320 }
1321 }
1322
1323 #[test]
1324 fn rename_model_rejects_noop() {
1325 let p = rename_model("Post", "Post");
1326 assert!(matches!(
1327 validate_primitive(&p),
1328 Err(PrimitiveError::NoOpRename { what: "model", .. })
1329 ));
1330 }
1331
1332 #[test]
1333 fn rename_field_rejects_noop() {
1334 let p = rename_field("Post", "title", "title");
1335 assert!(matches!(
1336 validate_primitive(&p),
1337 Err(PrimitiveError::NoOpRename { what: "field", .. })
1338 ));
1339 }
1340
1341 #[test]
1342 fn rename_model_rejects_empty_names() {
1343 let p = rename_model("", "X");
1344 assert!(matches!(
1345 validate_primitive(&p),
1346 Err(PrimitiveError::EmptyIdentifier(_))
1347 ));
1348 }
1349
1350 #[test]
1351 fn change_field_type_rejects_unknown_type() {
1352 let p = change_type("Post", "priority", "HyperFloat128");
1353 assert!(matches!(
1354 validate_primitive(&p),
1355 Err(PrimitiveError::UnknownType { .. })
1356 ));
1357 }
1358
1359 #[test]
1360 fn validate_against_rejects_rename_of_missing_model() {
1361 let err = validate_against(&rename_model("Ghost", "Wraith"), &schema()).unwrap_err();
1362 assert!(matches!(
1363 err,
1364 PrimitiveError::NotFound { what: "model", .. }
1365 ));
1366 }
1367
1368 #[test]
1369 fn validate_against_rejects_rename_to_existing_model() {
1370 let plan = Plan::new(vec![
1375 Primitive::AddModel(AddModel {
1376 name: "Draft".to_string(),
1377 table: "drafts".to_string(),
1378 fields: Vec::new(),
1379 }),
1380 rename_model("Draft", "Post"),
1381 ]);
1382 let err = plan.validate(&schema()).unwrap_err();
1383 assert!(
1384 matches!(
1385 err,
1386 PrimitiveError::InStep { step: 1, inner }
1387 if matches!(*inner, PrimitiveError::AlreadyExists { what: "model", .. })
1388 ),
1389 "must reject rename-over-existing-name"
1390 );
1391 }
1392
1393 #[test]
1394 fn validate_against_rejects_rename_field_to_existing_name() {
1395 let err = validate_against(&rename_field("Post", "id", "title"), &schema()).unwrap_err();
1397 assert!(matches!(
1398 err,
1399 PrimitiveError::AlreadyExists { what: "field", .. }
1400 ));
1401 }
1402
1403 #[test]
1404 fn validate_against_rejects_change_type_on_missing_field() {
1405 let err = validate_against(&change_type("Post", "ghost", "i32"), &schema()).unwrap_err();
1406 assert!(matches!(
1407 err,
1408 PrimitiveError::NotFound { what: "field", .. }
1409 ));
1410 }
1411
1412 #[test]
1413 fn validate_against_rejects_change_nullability_on_missing_field() {
1414 let err = validate_against(&change_nullable("Post", "ghost", true), &schema()).unwrap_err();
1415 assert!(matches!(
1416 err,
1417 PrimitiveError::NotFound { what: "field", .. }
1418 ));
1419 }
1420
1421 #[test]
1422 fn plan_chains_rename_then_change_type_correctly() {
1423 let plan = Plan::new(vec![
1427 rename_model("Post", "Article"),
1428 change_type("Article", "title", "String"),
1429 ]);
1430 assert_eq!(plan.validate(&schema()), Ok(()));
1431 }
1432
1433 #[test]
1434 fn plan_chains_rename_field_then_change_nullability() {
1435 let plan = Plan::new(vec![
1436 rename_field("Post", "title", "heading"),
1437 change_nullable("Post", "heading", true),
1438 ]);
1439 assert_eq!(plan.validate(&schema()), Ok(()));
1440 }
1441
1442 #[test]
1443 fn plan_json_round_trip() {
1444 let plan = Plan::new(vec![Primitive::CreateMigration(CreateMigration {
1445 name: "add_books".to_string(),
1446 sql: "CREATE TABLE books (id INTEGER);".to_string(),
1447 })]);
1448 let json = serde_json::to_string(&plan).unwrap();
1449 let back: Plan = serde_json::from_str(&json).unwrap();
1450 assert_eq!(back.steps.len(), 1);
1451 }
1452}