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 planner;
34pub mod review;
35
36#[cfg(test)]
37mod context_tests;
38#[cfg(test)]
39mod executor_pg_tests;
40#[cfg(test)]
41mod executor_tests;
42#[cfg(test)]
43mod executor_tests_advanced;
44#[cfg(test)]
45mod planner_tests;
46#[cfg(test)]
47mod review_tests;
48
49pub use executor::{
50 execute_plan_document, plan_execution, plan_retrofit_foreign_keys, render_preview_human,
51 ExecuteOptions, ExecutionError, ExecutionPreview, ExecutionResult, FileChangeKind,
52 ParsedModelsFile, PlannedFileChange, ProjectView, RetrofitReport,
53};
54pub use industry::{industry_schema_for, IndustrySchema};
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};
870 use crate::http::FormData;
871 use crate::error::Error as CoreError;
872 use crate::orm::{Model, Row, Value};
873
874 struct Post;
876 impl Model for Post {
877 const TABLE: &'static str = "posts";
878 const COLUMNS: &'static [&'static str] = &["id", "title"];
879 const INSERT_COLUMNS: &'static [&'static str] = &["title"];
880 fn id(&self) -> i64 {
881 0
882 }
883 fn from_row(_: Row<'_>) -> Result<Self, CoreError> {
884 unimplemented!()
885 }
886 fn insert_values(&self) -> Vec<Value> {
887 Vec::new()
888 }
889 }
890 impl AdminModel for Post {
891 const ADMIN_NAME: &'static str = "posts";
892 const DISPLAY_NAME: &'static str = "Posts";
893 const SINGULAR_NAME: &'static str = "Post";
894 const FIELDS: &'static [AdminField] = &[
895 AdminField {
896 name: "id",
897 label: "id",
898 field_type: FieldType::I64,
899 editable: false,
900 relation: None,
901 choices: None,
902 },
903 AdminField {
904 name: "title",
905 label: "title",
906 field_type: FieldType::String,
907 editable: true,
908 relation: None,
909 choices: None,
910 },
911 ];
912 fn display_values(&self) -> Vec<(String, String)> {
913 Vec::new()
914 }
915 fn from_form(_: &FormData) -> std::result::Result<Self, Vec<String>> {
916 unimplemented!()
917 }
918 fn object_label(&self) -> String {
919 "Post".into()
920 }
921 fn id(&self) -> i64 {
922 0
923 }
924 fn values_to_update(&self) -> Vec<(&'static str, Value)> {
925 Vec::new()
926 }
927 }
928
929 fn schema() -> Schema {
930 Schema::from_admin(&Admin::new().model::<Post>())
931 }
932
933 #[test]
936 fn add_field_round_trips_through_json() {
937 let p = Primitive::AddField(AddField {
938 model: "Post".to_string(),
939 field: FieldSpec {
940 name: "published".to_string(),
941 ty: "bool".to_string(),
942 nullable: false,
943 editable: true,
944 },
945 });
946 let json = serde_json::to_string(&p).unwrap();
947 assert!(json.contains(r#""op":"add_field""#));
948 assert!(json.contains(r#""name":"published""#));
949
950 let back: Primitive = serde_json::from_str(&json).unwrap();
951 match back {
952 Primitive::AddField(af) => {
953 assert_eq!(af.model, "Post");
954 assert_eq!(af.field.name, "published");
955 assert_eq!(af.field.ty, "bool");
956 }
957 _ => panic!("expected AddField"),
958 }
959 }
960
961 #[test]
962 fn unknown_op_is_rejected_not_swallowed() {
963 let bad = r#"{"op":"rewrite_universe","world":"goodbye"}"#;
964 let parsed: Result<Primitive, _> = serde_json::from_str(bad);
965 assert!(parsed.is_err(), "unknown op must not parse");
966 }
967
968 #[test]
969 fn unknown_field_on_known_op_is_rejected() {
970 let bad = r#"{"op":"add_model","name":"X","table":"xs","fields":[],"extra":true}"#;
973 let parsed: Result<Primitive, _> = serde_json::from_str(bad);
974 assert!(
975 parsed.is_err(),
976 "unknown keys on known ops must be rejected"
977 );
978 }
979
980 #[test]
981 fn missing_required_field_is_rejected() {
982 let bad = r#"{"op":"add_model","name":"X","fields":[]}"#;
984 let parsed: Result<Primitive, _> = serde_json::from_str(bad);
985 assert!(parsed.is_err(), "missing required fields must be rejected");
986 }
987
988 #[test]
989 fn add_relation_with_belongs_to_serialises_snake_case() {
990 let p = Primitive::AddRelation(AddRelation {
991 from: "Post".to_string(),
992 kind: RelationKind::BelongsTo,
993 to: "User".to_string(),
994 via: "user_id".to_string(),
995 required: false,
996 on_delete: OnDelete::Restrict,
997 });
998 let json = serde_json::to_string(&p).unwrap();
999 assert!(json.contains(r#""kind":"belongs_to""#));
1000 }
1001
1002 #[test]
1003 fn validate_primitive_rejects_unknown_type() {
1004 let p = Primitive::AddField(AddField {
1005 model: "Post".to_string(),
1006 field: FieldSpec {
1007 name: "flux".to_string(),
1008 ty: "HyperFloat128".to_string(),
1009 nullable: false,
1010 editable: true,
1011 },
1012 });
1013 assert!(matches!(
1014 validate_primitive(&p),
1015 Err(PrimitiveError::UnknownType { .. })
1016 ));
1017 }
1018
1019 #[test]
1020 fn validate_primitive_rejects_empty_names() {
1021 let p = Primitive::AddField(AddField {
1022 model: "".to_string(),
1023 field: FieldSpec {
1024 name: "x".to_string(),
1025 ty: "i64".to_string(),
1026 nullable: false,
1027 editable: true,
1028 },
1029 });
1030 assert_eq!(
1031 validate_primitive(&p),
1032 Err(PrimitiveError::EmptyIdentifier("model name"))
1033 );
1034 }
1035
1036 #[test]
1037 fn validate_primitive_rejects_duplicate_fields_in_add_model() {
1038 let p = Primitive::AddModel(AddModel {
1039 name: "Book".to_string(),
1040 table: "books".to_string(),
1041 fields: vec![
1042 FieldSpec {
1043 name: "title".to_string(),
1044 ty: "String".to_string(),
1045 nullable: false,
1046 editable: true,
1047 },
1048 FieldSpec {
1049 name: "title".to_string(),
1050 ty: "String".to_string(),
1051 nullable: false,
1052 editable: true,
1053 },
1054 ],
1055 });
1056 assert!(matches!(
1057 validate_primitive(&p),
1058 Err(PrimitiveError::DuplicateFieldInAddModel { .. })
1059 ));
1060 }
1061
1062 #[test]
1063 fn update_admin_rejects_unknown_attribute() {
1064 let p = Primitive::UpdateAdmin(UpdateAdmin {
1065 model: "Post".to_string(),
1066 field: "title".to_string(),
1067 attr: "telepathy".to_string(),
1068 value: serde_json::Value::Bool(true),
1069 });
1070 assert!(matches!(
1071 validate_primitive(&p),
1072 Err(PrimitiveError::UnknownAdminAttribute { .. })
1073 ));
1074 }
1075
1076 #[test]
1079 fn validate_against_rejects_remove_of_nonexistent_model() {
1080 let p = Primitive::RemoveModel(RemoveModel {
1081 name: "Ghost".to_string(),
1082 });
1083 let err = validate_against(&p, &schema()).unwrap_err();
1084 assert!(matches!(
1085 err,
1086 PrimitiveError::NotFound { what: "model", .. }
1087 ));
1088 }
1089
1090 #[test]
1091 fn validate_against_rejects_add_field_to_missing_model() {
1092 let p = Primitive::AddField(AddField {
1093 model: "Ghost".to_string(),
1094 field: FieldSpec {
1095 name: "age".to_string(),
1096 ty: "i32".to_string(),
1097 nullable: false,
1098 editable: true,
1099 },
1100 });
1101 let err = validate_against(&p, &schema()).unwrap_err();
1102 assert!(matches!(
1103 err,
1104 PrimitiveError::NotFound { what: "model", .. }
1105 ));
1106 }
1107
1108 #[test]
1109 fn validate_against_rejects_duplicate_field_add() {
1110 let p = Primitive::AddField(AddField {
1111 model: "Post".to_string(),
1112 field: FieldSpec {
1113 name: "title".to_string(),
1114 ty: "String".to_string(),
1115 nullable: false,
1116 editable: true,
1117 },
1118 });
1119 let err = validate_against(&p, &schema()).unwrap_err();
1120 assert!(matches!(
1121 err,
1122 PrimitiveError::AlreadyExists { what: "field", .. }
1123 ));
1124 }
1125
1126 #[test]
1127 fn validate_against_rejects_relation_to_missing_model() {
1128 let p = Primitive::AddRelation(AddRelation {
1129 from: "Post".to_string(),
1130 kind: RelationKind::BelongsTo,
1131 to: "Ghost".to_string(),
1132 via: "ghost_id".to_string(),
1133 required: false,
1134 on_delete: OnDelete::Restrict,
1135 });
1136 let err = validate_against(&p, &schema()).unwrap_err();
1137 assert!(matches!(err, PrimitiveError::UnknownRelationTarget { .. }));
1138 }
1139
1140 #[test]
1143 fn plan_validates_sequential_additions() {
1144 let plan = Plan::new(vec![
1145 Primitive::AddModel(AddModel {
1146 name: "Book".to_string(),
1147 table: "books".to_string(),
1148 fields: vec![FieldSpec {
1149 name: "title".to_string(),
1150 ty: "String".to_string(),
1151 nullable: false,
1152 editable: true,
1153 }],
1154 }),
1155 Primitive::AddField(AddField {
1158 model: "Book".to_string(),
1159 field: FieldSpec {
1160 name: "published".to_string(),
1161 ty: "bool".to_string(),
1162 nullable: false,
1163 editable: true,
1164 },
1165 }),
1166 ]);
1167 assert_eq!(plan.validate(&schema()), Ok(()));
1168 }
1169
1170 #[test]
1171 fn plan_rejects_second_add_of_same_model() {
1172 let add_book = Primitive::AddModel(AddModel {
1173 name: "Book".to_string(),
1174 table: "books".to_string(),
1175 fields: Vec::new(),
1176 });
1177 let plan = Plan::new(vec![add_book.clone(), add_book]);
1178 let err = plan.validate(&schema()).unwrap_err();
1179 assert!(
1180 matches!(
1181 &err,
1182 PrimitiveError::InStep { step: 1, inner } if matches!(**inner, PrimitiveError::AlreadyExists { what: "model", .. })
1183 ),
1184 "got: {err:?}"
1185 );
1186 }
1187
1188 #[test]
1189 fn plan_rejects_field_add_after_model_removed() {
1190 let plan = Plan::new(vec![
1191 Primitive::RemoveModel(RemoveModel {
1192 name: "Post".to_string(),
1193 }),
1194 Primitive::AddField(AddField {
1195 model: "Post".to_string(),
1196 field: FieldSpec {
1197 name: "subtitle".to_string(),
1198 ty: "String".to_string(),
1199 nullable: true,
1200 editable: true,
1201 },
1202 }),
1203 ]);
1204 let err = plan.validate(&schema()).unwrap_err();
1205 assert!(
1206 matches!(
1207 err,
1208 PrimitiveError::InStep { step: 1, inner } if matches!(*inner, PrimitiveError::NotFound { what: "model", .. })
1209 ),
1210 "plan must fail on the second step, not the first"
1211 );
1212 }
1213
1214 #[test]
1215 fn empty_plan_is_always_valid() {
1216 assert_eq!(Plan::new(Vec::new()).validate(&schema()), Ok(()));
1217 }
1218
1219 #[test]
1220 fn create_migration_is_developer_only() {
1221 let m = Primitive::CreateMigration(CreateMigration {
1222 name: "add_books".to_string(),
1223 sql: "CREATE TABLE books (id INTEGER);".to_string(),
1224 });
1225 assert!(m.is_developer_only());
1226 assert!(!Primitive::RemoveModel(RemoveModel {
1227 name: "X".to_string()
1228 })
1229 .is_developer_only());
1230 }
1231
1232 #[test]
1233 fn validate_primitive_still_accepts_create_migration_for_direct_use() {
1234 let m = Primitive::CreateMigration(CreateMigration {
1238 name: "add_books".to_string(),
1239 sql: "CREATE TABLE books (id INTEGER);".to_string(),
1240 });
1241 assert_eq!(validate_primitive(&m), Ok(()));
1242 }
1243
1244 #[test]
1245 fn plan_rejects_create_migration_even_when_structurally_valid() {
1246 let plan = Plan::new(vec![Primitive::CreateMigration(CreateMigration {
1247 name: "add_books".to_string(),
1248 sql: "CREATE TABLE books (id INTEGER);".to_string(),
1249 })]);
1250 let err = plan.validate(&schema()).unwrap_err();
1251 assert!(
1252 matches!(
1253 &err,
1254 PrimitiveError::InStep { step: 0, inner }
1255 if matches!(
1256 **inner,
1257 PrimitiveError::DeveloperOnlyNotAllowedInPlan { op: "create_migration" },
1258 )
1259 ),
1260 "got: {err:?}"
1261 );
1262 }
1263
1264 #[test]
1265 fn plan_rejects_create_migration_at_the_offending_step() {
1266 let plan = Plan::new(vec![
1267 Primitive::RemoveModel(RemoveModel {
1268 name: "Post".to_string(),
1269 }),
1270 Primitive::CreateMigration(CreateMigration {
1271 name: "tidy".to_string(),
1272 sql: "DROP TABLE posts;".to_string(),
1273 }),
1274 ]);
1275 let err = plan.validate(&schema()).unwrap_err();
1276 assert!(
1277 matches!(
1278 err,
1279 PrimitiveError::InStep { step: 1, inner }
1280 if matches!(*inner, PrimitiveError::DeveloperOnlyNotAllowedInPlan { .. })
1281 ),
1282 "developer-only check must locate the offending step index"
1283 );
1284 }
1285
1286 fn rename_model(from: &str, to: &str) -> Primitive {
1289 Primitive::RenameModel(RenameModel {
1290 from: from.to_string(),
1291 to: to.to_string(),
1292 })
1293 }
1294
1295 fn rename_field(model: &str, from: &str, to: &str) -> Primitive {
1296 Primitive::RenameField(RenameField {
1297 model: model.to_string(),
1298 from: from.to_string(),
1299 to: to.to_string(),
1300 })
1301 }
1302
1303 fn change_type(model: &str, field: &str, new_type: &str) -> Primitive {
1304 Primitive::ChangeFieldType(ChangeFieldType {
1305 model: model.to_string(),
1306 field: field.to_string(),
1307 new_type: new_type.to_string(),
1308 })
1309 }
1310
1311 fn change_nullable(model: &str, field: &str, nullable: bool) -> Primitive {
1312 Primitive::ChangeFieldNullability(ChangeFieldNullability {
1313 model: model.to_string(),
1314 field: field.to_string(),
1315 nullable,
1316 })
1317 }
1318
1319 #[test]
1320 fn rename_primitives_round_trip_through_json() {
1321 for p in [
1322 rename_model("Post", "Article"),
1323 rename_field("Post", "title", "heading"),
1324 change_type("Post", "priority", "i64"),
1325 change_nullable("Post", "title", true),
1326 ] {
1327 let json = serde_json::to_string(&p).unwrap();
1328 let back: Primitive = serde_json::from_str(&json).unwrap();
1329 assert_eq!(back.op_name(), p.op_name());
1330 }
1331 }
1332
1333 #[test]
1334 fn rename_model_rejects_noop() {
1335 let p = rename_model("Post", "Post");
1336 assert!(matches!(
1337 validate_primitive(&p),
1338 Err(PrimitiveError::NoOpRename { what: "model", .. })
1339 ));
1340 }
1341
1342 #[test]
1343 fn rename_field_rejects_noop() {
1344 let p = rename_field("Post", "title", "title");
1345 assert!(matches!(
1346 validate_primitive(&p),
1347 Err(PrimitiveError::NoOpRename { what: "field", .. })
1348 ));
1349 }
1350
1351 #[test]
1352 fn rename_model_rejects_empty_names() {
1353 let p = rename_model("", "X");
1354 assert!(matches!(
1355 validate_primitive(&p),
1356 Err(PrimitiveError::EmptyIdentifier(_))
1357 ));
1358 }
1359
1360 #[test]
1361 fn change_field_type_rejects_unknown_type() {
1362 let p = change_type("Post", "priority", "HyperFloat128");
1363 assert!(matches!(
1364 validate_primitive(&p),
1365 Err(PrimitiveError::UnknownType { .. })
1366 ));
1367 }
1368
1369 #[test]
1370 fn validate_against_rejects_rename_of_missing_model() {
1371 let err = validate_against(&rename_model("Ghost", "Wraith"), &schema()).unwrap_err();
1372 assert!(matches!(
1373 err,
1374 PrimitiveError::NotFound { what: "model", .. }
1375 ));
1376 }
1377
1378 #[test]
1379 fn validate_against_rejects_rename_to_existing_model() {
1380 let plan = Plan::new(vec![
1385 Primitive::AddModel(AddModel {
1386 name: "Draft".to_string(),
1387 table: "drafts".to_string(),
1388 fields: Vec::new(),
1389 }),
1390 rename_model("Draft", "Post"),
1391 ]);
1392 let err = plan.validate(&schema()).unwrap_err();
1393 assert!(
1394 matches!(
1395 err,
1396 PrimitiveError::InStep { step: 1, inner }
1397 if matches!(*inner, PrimitiveError::AlreadyExists { what: "model", .. })
1398 ),
1399 "must reject rename-over-existing-name"
1400 );
1401 }
1402
1403 #[test]
1404 fn validate_against_rejects_rename_field_to_existing_name() {
1405 let err = validate_against(&rename_field("Post", "id", "title"), &schema()).unwrap_err();
1407 assert!(matches!(
1408 err,
1409 PrimitiveError::AlreadyExists { what: "field", .. }
1410 ));
1411 }
1412
1413 #[test]
1414 fn validate_against_rejects_change_type_on_missing_field() {
1415 let err = validate_against(&change_type("Post", "ghost", "i32"), &schema()).unwrap_err();
1416 assert!(matches!(
1417 err,
1418 PrimitiveError::NotFound { what: "field", .. }
1419 ));
1420 }
1421
1422 #[test]
1423 fn validate_against_rejects_change_nullability_on_missing_field() {
1424 let err = validate_against(&change_nullable("Post", "ghost", true), &schema()).unwrap_err();
1425 assert!(matches!(
1426 err,
1427 PrimitiveError::NotFound { what: "field", .. }
1428 ));
1429 }
1430
1431 #[test]
1432 fn plan_chains_rename_then_change_type_correctly() {
1433 let plan = Plan::new(vec![
1437 rename_model("Post", "Article"),
1438 change_type("Article", "title", "String"),
1439 ]);
1440 assert_eq!(plan.validate(&schema()), Ok(()));
1441 }
1442
1443 #[test]
1444 fn plan_chains_rename_field_then_change_nullability() {
1445 let plan = Plan::new(vec![
1446 rename_field("Post", "title", "heading"),
1447 change_nullable("Post", "heading", true),
1448 ]);
1449 assert_eq!(plan.validate(&schema()), Ok(()));
1450 }
1451
1452 #[test]
1453 fn plan_json_round_trip() {
1454 let plan = Plan::new(vec![Primitive::CreateMigration(CreateMigration {
1455 name: "add_books".to_string(),
1456 sql: "CREATE TABLE books (id INTEGER);".to_string(),
1457 })]);
1458 let json = serde_json::to_string(&plan).unwrap();
1459 let back: Plan = serde_json::from_str(&json).unwrap();
1460 assert_eq!(back.steps.len(), 1);
1461 }
1462}