use crate::ast::{FieldType, RelationType, Schema};
use forgedb_validation::{is_pascal_case, validate_field_name, validate_model_name, ValidationError};
pub fn validate_schema(schema: &Schema) -> Vec<ValidationError> {
let mut errors = Vec::new();
collect_naming_errors(schema, &mut errors);
collect_structure_errors(schema, &mut errors);
errors
}
pub fn collect_naming_errors(schema: &Schema, errors: &mut Vec<ValidationError>) {
for model in &schema.models {
if let Err(e) = validate_model_name(&model.name, model.position) {
errors.push(e);
}
for field in &model.fields {
if let Err(e) = validate_field_name(&field.name, field.position) {
errors.push(e);
}
}
}
for struct_def in &schema.structs {
if let Err(e) = validate_model_name(&struct_def.name, struct_def.position) {
errors.push(e);
}
for field in &struct_def.fields {
if let Err(e) = validate_field_name(&field.name, field.position) {
errors.push(e);
}
}
}
for enum_def in &schema.enums {
if let Err(e) = validate_model_name(&enum_def.name, enum_def.position) {
errors.push(e);
}
for variant in &enum_def.variants {
if !is_pascal_case(variant) {
let mut err = ValidationError::new(format!(
"Enum '{}' variant '{}' must be PascalCase",
enum_def.name, variant
));
if let Some(pos) = enum_def.position {
err = err.with_position(pos);
}
errors.push(err);
}
}
}
}
pub fn collect_structure_errors(schema: &Schema, errors: &mut Vec<ValidationError>) {
check_duplicate_names(
schema.models.iter().map(|m| (m.name.as_str(), m.position)),
"model",
errors,
);
check_duplicate_names(
schema.structs.iter().map(|s| (s.name.as_str(), s.position)),
"struct",
errors,
);
check_duplicate_names(
schema.enums.iter().map(|e| (e.name.as_str(), e.position)),
"enum",
errors,
);
for struct_def in &schema.structs {
let mut seen = std::collections::HashSet::new();
for field in &struct_def.fields {
if !seen.insert(field.name.as_str()) {
errors.push(positioned(
format!(
"Duplicate field name '{}' in struct '{}'",
field.name, struct_def.name
),
field.position,
));
}
if !field.field_type.is_fixed_size() {
errors.push(positioned(
format!(
"Struct '{}' field '{}' contains variable-length type. Structs can only contain fixed-size types.",
struct_def.name, field.name
),
field.position,
));
}
}
}
for enum_def in &schema.enums {
let mut seen = std::collections::HashSet::new();
for variant in &enum_def.variants {
if !seen.insert(variant.as_str()) {
errors.push(positioned(
format!(
"Duplicate variant '{}' in enum '{}'",
variant, enum_def.name
),
enum_def.position,
));
}
}
}
for model in &schema.models {
let field_names: std::collections::HashSet<&str> =
model.fields.iter().map(|f| f.name.as_str()).collect();
let mut seen = std::collections::HashSet::new();
for field in &model.fields {
if !seen.insert(field.name.as_str()) {
errors.push(positioned(
format!(
"Duplicate field name '{}' in model '{}'",
field.name, model.name
),
field.position,
));
}
if let FieldType::Relation(rel) = &field.field_type {
let target = match rel {
RelationType::OneToMany(t)
| RelationType::RequiredReference(t)
| RelationType::OptionalReference(t)
| RelationType::ManyToMany(t) => t,
};
if schema.find_model(target).is_none() {
errors.push(positioned(
format!(
"Model '{}' field '{}' references undefined model '{}'",
model.name, field.name, target
),
field.position,
));
}
}
if let Some(named) = field.field_type.struct_name()
&& schema.find_struct(named).is_none()
&& schema.find_enum(named).is_none()
{
errors.push(positioned(
format!(
"Model '{}' field '{}' references unknown type '{}' (no such struct or enum)",
model.name, field.name, named
),
field.position,
));
}
}
for comp_idx in &model.composite_indexes {
for field_name in &comp_idx.fields {
if !field_names.contains(field_name.as_str()) {
errors.push(positioned(
format!(
"Composite index in model '{}' references undefined field '{}'",
model.name, field_name
),
model.position,
));
}
}
}
let mut projection_names = std::collections::HashSet::new();
for proj in &model.projections {
if !projection_names.insert(proj.name.as_str()) {
errors.push(positioned(
format!(
"Duplicate @projection name '{}' in model '{}'",
proj.name, model.name
),
model.position,
));
}
for field_name in &proj.fields {
if !field_names.contains(field_name.as_str()) {
errors.push(positioned(
format!(
"@projection '{}' in model '{}' references undefined field '{}'",
proj.name, model.name, field_name
),
model.position,
));
}
}
}
}
}
fn positioned(message: String, pos: Option<forgedb_validation::Position>) -> ValidationError {
let err = ValidationError::new(message);
match pos {
Some(p) => err.with_position(p),
None => err,
}
}
fn check_duplicate_names<'a>(
names: impl Iterator<Item = (&'a str, Option<forgedb_validation::Position>)>,
kind: &str,
errors: &mut Vec<ValidationError>,
) {
let mut seen = std::collections::HashSet::new();
for (name, pos) in names {
if !seen.insert(name) {
errors.push(positioned(
format!("Duplicate {} name '{}'", kind, name),
pos,
));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Parser;
fn ast(input: &str) -> Schema {
Parser::new(input)
.unwrap()
.parse_unvalidated()
.expect("structural parse should succeed")
}
#[test]
fn validate_schema_collects_all_errors_with_positions() {
let schema = ast("User {\n id: +uuid\n BadField: string\n friend: *Ghost\n}\n");
let errors = validate_schema(&schema);
assert_eq!(errors.len(), 2, "both defects reported, not just the first: {errors:?}");
let naming = errors
.iter()
.find(|e| e.message.contains("snake_case"))
.expect("field-naming diagnostic present");
assert_eq!(
naming.position.map(|p| p.line),
Some(3),
"naming error points at the offending field's line"
);
let dangling = errors
.iter()
.find(|e| e.message.contains("references undefined model 'Ghost'"))
.expect("dangling-relation diagnostic present");
assert_eq!(
dangling.position.map(|p| p.line),
Some(4),
"relation error points at the offending field's line"
);
}
#[test]
fn validate_schema_accepts_a_valid_schema() {
let schema = ast("User {\n id: +uuid\n email: string\n}\n");
assert!(validate_schema(&schema).is_empty());
}
#[test]
fn structure_pass_ignores_naming_but_catches_references() {
let schema = ast("User {\n id: +uuid\n BadField: string\n friend: *Ghost\n}\n");
let mut errors = Vec::new();
collect_structure_errors(&schema, &mut errors);
assert_eq!(errors.len(), 1, "only the structural defect: {errors:?}");
assert!(errors[0].message.contains("undefined model 'Ghost'"));
}
}