Skip to main content

remodel_core/validation/
mod.rs

1//! Structural validation of conceptual and logical models.
2//!
3//! Validation is *non-fatal* by default: [`validate_conceptual`] returns a
4//! `Vec<Diagnostic>`, and the caller chooses whether any diagnostic is severe
5//! enough to abort. The transform pipeline rejects models that produce one or
6//! more [`Severity::Error`] diagnostics.
7
8use serde::{Deserialize, Serialize};
9
10use crate::models::conceptual::{ConceptualModel, EntityId};
11use crate::models::logical::{LogicalModel, TableId};
12
13/// Severity of a [`Diagnostic`].
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
15pub enum Severity {
16    /// Informational: the model is valid; this is just a hint.
17    Info,
18    /// Warning: the model is technically valid but may not behave as expected.
19    Warning,
20    /// Error: the model violates a structural invariant.
21    Error,
22}
23
24/// One validation finding.
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct Diagnostic {
27    /// Severity classifier.
28    pub severity: Severity,
29    /// Stable code, e.g. `"E001"`. Useful for filtering and i18n.
30    pub code: &'static str,
31    /// Human-readable message.
32    pub message: String,
33}
34
35impl Diagnostic {
36    /// Construct a new error diagnostic.
37    pub fn error(code: &'static str, message: impl Into<String>) -> Self {
38        Self { severity: Severity::Error, code, message: message.into() }
39    }
40
41    /// Construct a new warning diagnostic.
42    pub fn warning(code: &'static str, message: impl Into<String>) -> Self {
43        Self { severity: Severity::Warning, code, message: message.into() }
44    }
45}
46
47/// Validate a [`ConceptualModel`] and return all findings.
48pub fn validate_conceptual(model: &ConceptualModel) -> Vec<Diagnostic> {
49    let mut out = Vec::new();
50
51    for entity in model.entities.values() {
52        if entity.attributes.is_empty() && !is_in_specialization_child(model, entity.id) {
53            out.push(Diagnostic::warning(
54                "W001",
55                format!("entity `{}` has no attributes", entity.name),
56            ));
57        }
58    }
59
60    for entity in model.entities.values() {
61        if !entity.attributes.is_empty()
62            && !entity.attributes.iter().any(|a| {
63                model.attributes.get(a).map(|x| x.is_primary).unwrap_or(false)
64            })
65            && !is_in_specialization_child(model, entity.id)
66            && !entity.weak
67        {
68            out.push(Diagnostic::error(
69                "E002",
70                format!("entity `{}` has no primary-key attribute", entity.name),
71            ));
72        }
73    }
74
75    for rel in model.relationships.values() {
76        if rel.endpoints.len() < 2 {
77            out.push(Diagnostic::error(
78                "E003",
79                format!(
80                    "relationship `{}` has only {} endpoint(s); at least 2 required",
81                    rel.name,
82                    rel.endpoints.len()
83                ),
84            ));
85        }
86    }
87
88    for rel in model.relationships.values() {
89        for ep in &rel.endpoints {
90            if !model.entities.contains_key(&ep.entity) {
91                out.push(Diagnostic::error(
92                    "E004",
93                    format!(
94                        "relationship `{}` references unknown entity #{}",
95                        rel.name, ep.entity.0
96                    ),
97                ));
98            }
99        }
100    }
101
102    out
103}
104
105/// Validate a [`LogicalModel`].
106pub fn validate_logical(model: &LogicalModel) -> Vec<Diagnostic> {
107    let mut out = Vec::new();
108
109    for table in model.tables.values() {
110        if table.columns.is_empty() {
111            out.push(Diagnostic::error(
112                "L001",
113                format!("table `{}` has no columns", table.name),
114            ));
115            continue;
116        }
117        if table.primary_key().is_none() {
118            out.push(Diagnostic::warning(
119                "L002",
120                format!("table `{}` has no primary key", table.name),
121            ));
122        }
123        for c in table.constraints.values() {
124            if let crate::models::logical::ConstraintKind::ForeignKey(fk) = &c.kind {
125                if !model.tables.contains_key(&fk.references_table) {
126                    out.push(Diagnostic::error(
127                        "L003",
128                        format!(
129                            "table `{}` has FK referencing unknown table #{}",
130                            table.name, fk.references_table.0
131                        ),
132                    ));
133                }
134                if fk.columns.len() != fk.references_columns.len() {
135                    out.push(Diagnostic::error(
136                        "L004",
137                        format!(
138                            "table `{}` has FK with mismatched arity ({} local vs {} referenced)",
139                            table.name,
140                            fk.columns.len(),
141                            fk.references_columns.len()
142                        ),
143                    ));
144                }
145            }
146        }
147    }
148
149    let _ = (TableId(0),);
150    out
151}
152
153fn is_in_specialization_child(model: &ConceptualModel, eid: EntityId) -> bool {
154    model
155        .specializations
156        .values()
157        .any(|s| s.children.iter().any(|c| *c == eid))
158}