Skip to main content

Type Checker

The type checker validates the parsed AST before runtime, catching errors like invalid relations, missing primary keys, and annotation misuse. It runs after parsing and before schema registration in the vault.

Overview

Validation is organized into three specialized modules:
  1. Relations (relations.rs) - Entity references and foreign key consistency
  2. Constraints (constraints.rs) - Primary keys and annotation rules
  3. Orchestration (mod.rs) - Pipeline coordination and error reporting

Architecture

Parsed AST

Type Check Pipeline
    ├── Check duplicate entities
    ├── Check duplicate fields
    ├── Check relations (relations.rs)
    │   ├── Target entity exists?
    │   ├── Foreign key exists?
    │   └── HasMany requires 'via'?
    ├── Check circular dependencies (relations.rs)
    │   └── DFS cycle detection
    ├── Check primary keys (constraints.rs)
    │   ├── Exactly one per entity?
    │   └── No composite keys?
    └── Check annotations (constraints.rs)
        ├── @vector only on vector(N)?
        └── No annotations on primary/unique?

TypeCheckResult { errors: Vec<TypeCheckError> }

Valid: errors.is_empty() == true

Entry Point

Location: chameleon-core/src/typechecker/mod.rs:36
pub fn type_check(schema: &Schema) -> TypeCheckResult {
    let mut errors: Vec<TypeCheckError> = Vec::new();
    
    // 1. Check for duplicate entities
    // 2. Check for duplicate fields
    // 3. Check relations
    errors.extend(relations::check_relations(schema));
    errors.extend(relations::check_circular_dependencies(schema));
    
    // 4. Check constraints
    errors.extend(constraints::check_primary_keys(schema));
    errors.extend(constraints::check_annotations(schema));
    
    TypeCheckResult { errors }
}
Key characteristics:
  • Non-short-circuiting: Collects all errors, not just the first
  • Zero allocation (where possible): Borrows schema, doesn’t modify
  • Deterministic: Same schema always produces same errors in same order

TypeCheckResult

Location: chameleon-core/src/typechecker/mod.rs:9
#[derive(Debug, Clone)]
pub struct TypeCheckResult {
    pub errors: Vec<TypeCheckError>,
}

impl TypeCheckResult {
    pub fn is_valid(&self) -> bool {
        self.errors.is_empty()
    }
    
    pub fn error_report(&self) -> String {
        if self.is_valid() {
            return "✅ Schema is valid".to_string();
        }
        
        let mut report = format!("❌ Found {} error(s):\n\n", self.errors.len());
        for (i, error) in self.errors.iter().enumerate() {
            report.push_str(&format!("  {}. {}\n", i + 1, error));
        }
        report
    }
}
Example report:
❌ Found 3 error(s):

  1. Entity 'User' has no primary key
  2. Entity 'Post' references unknown entity 'Comment' in relation 'comments'
  3. Field 'embedding' in 'Product' uses @vector but type is 'String', expected vector(N)

Relations Validation

Location: chameleon-core/src/typechecker/relations.rs

Check Relations

pub fn check_relations(schema: &Schema) -> Vec<TypeCheckError> {
    let mut errors = Vec::new();
    
    for entity in &schema.entities {
        for (_, relation) in &entity.relations {
            // 1. Target entity exists?
            if schema.get_entity(&relation.target_entity).is_none() {
                errors.push(TypeCheckError::UnknownRelationTarget {
                    entity: entity.name.clone(),
                    relation: relation.name.clone(),
                    target: relation.target_entity.clone(),
                });
                continue;
            }
            
            // 2. Foreign key exists in target entity?
            if let Some(fk) = &relation.foreign_key {
                let target_entity = schema.get_entity(&relation.target_entity).unwrap();
                if !target_entity.fields.contains_key(fk) {
                    errors.push(TypeCheckError::InvalidForeignKey { ... });
                }
            }
            
            // 3. HasMany relations MUST have a foreign key
            if relation.kind == RelationKind::HasMany && relation.foreign_key.is_none() {
                errors.push(TypeCheckError::MissingForeignKey { ... });
            }
        }
    }
    
    errors
}
Checks performed:
  • UnknownRelationTarget - Target entity doesn’t exist
  • InvalidForeignKey - Foreign key field doesn’t exist in target
  • MissingForeignKey - HasMany without via clause

Circular Dependency Detection

pub fn check_circular_dependencies(schema: &Schema) -> Vec<TypeCheckError> {
    let mut errors = Vec::new();
    let mut visited: Vec<String> = Vec::new();
    let mut in_stack: Vec<String> = Vec::new();
    
    for entity in &schema.entities {
        if !visited.contains(&entity.name) {
            if let Some(cycle) = dfs(schema, &entity.name, &mut visited, &mut in_stack) {
                errors.push(TypeCheckError::CircularDependency { cycle });
            }
        }
    }
    
    errors
}
Algorithm: Depth-First Search (DFS) Location: chameleon-core/src/typechecker/relations.rs:64
fn dfs(
    schema: &Schema,
    current: &str,
    visited: &mut Vec<String>,
    in_stack: &mut Vec<String>,
) -> Option<Vec<String>> {
    visited.push(current.to_string());
    in_stack.push(current.to_string());
    
    if let Some(entity) = schema.get_entity(current) {
        for (_, relation) in &entity.relations {
            // Skip BelongsTo (inverse side of relation)
            if relation.kind == RelationKind::BelongsTo {
                continue;
            }
            
            let target = &relation.target_entity;
            
            if !visited.contains(target) {
                // Recurse into unvisited target
                if let Some(cycle) = dfs(schema, target, visited, in_stack) {
                    return Some(cycle);
                }
            } else if in_stack.contains(target) {
                // Cycle detected! Build cycle path
                let start = in_stack.iter().position(|n| n == target).unwrap();
                let mut cycle: Vec<String> = in_stack[start..].to_vec();
                cycle.push(target.clone());
                return Some(cycle);
            }
        }
    }
    
    in_stack.pop();
    None
}
Example cycle:
A → B → C → A

Error: Circular dependency detected: ["A", "B", "C", "A"]
Why skip BelongsTo?
  • BelongsTo is the inverse of HasOne/HasMany
  • It doesn’t create a new dependency, just references the parent
  • Example: Post.user (BelongsTo User) doesn’t add Post → User edge

Constraints Validation

Location: chameleon-core/src/typechecker/constraints.rs

Primary Key Validation

pub fn check_primary_keys(schema: &Schema) -> Vec<TypeCheckError> {
    let mut errors = Vec::new();
    
    for entity in &schema.entities {
        let primary_keys: Vec<String> = entity.fields.iter()
            .filter(|(_, field)| field.primary_key)
            .map(|(name, _)| name.clone())
            .collect();
        
        match primary_keys.len() {
            0 => errors.push(TypeCheckError::MissingPrimaryKey {
                entity: entity.name.clone(),
            }),
            1 => {} // Valid
            _ => errors.push(TypeCheckError::MultiplePrimaryKeys {
                entity: entity.name.clone(),
                fields: primary_keys,
            }),
        }
    }
    
    errors
}
Rules:
  • Every entity must have exactly one primary key
  • Composite primary keys are not supported in v1.0
  • Primary key must be a field (not a relation)

Annotation Validation

pub fn check_annotations(schema: &Schema) -> Vec<TypeCheckError> {
    let mut errors = Vec::new();
    
    for entity in &schema.entities {
        for (_, field) in &entity.fields {
            if let Some(annotation) = &field.backend {
                // Rule 1: @vector only on vector(N) types
                if *annotation == BackendAnnotation::Vector {
                    if !matches!(field.field_type, FieldType::Vector(_)) {
                        errors.push(TypeCheckError::InvalidVectorAnnotation {
                            entity: entity.name.clone(),
                            field: field.name.clone(),
                            actual_type: format!("{:?}", field.field_type),
                        });
                    }
                }
                
                // Rule 2: Primary keys cannot have annotations
                if field.primary_key {
                    errors.push(TypeCheckError::AnnotationOnConstrainedField {
                        entity: entity.name.clone(),
                        field: field.name.clone(),
                        constraint: "primary".to_string(),
                        annotation: format!("{:?}", annotation).to_lowercase(),
                    });
                }
                
                // Rule 3: Unique fields cannot have annotations
                if field.unique {
                    errors.push(TypeCheckError::AnnotationOnConstrainedField {
                        entity: entity.name.clone(),
                        field: field.name.clone(),
                        constraint: "unique".to_string(),
                        annotation: format!("{:?}", annotation).to_lowercase(),
                    });
                }
            }
        }
    }
    
    errors
}
Annotation rules:
AnnotationAllowed TypesConstraints
@cacheAny❌ Not on primary/unique
@olapAny❌ Not on primary/unique
@vectorvector(N) only❌ Not on primary/unique
@mlAny❌ Not on primary/unique
Rationale:
  • Primary/unique fields must reside in the main OLTP database
  • They’re used for joins, indexes, and integrity constraints
  • Splitting them across backends breaks referential integrity

Error Types

Location: chameleon-core/src/typechecker/errors.rs
use thiserror::Error;

#[derive(Error, Debug, Clone, PartialEq)]
pub enum TypeCheckError {
    #[error("Entity '{entity}' references unknown entity '{target}' in relation '{relation}'")]
    UnknownRelationTarget {
        entity: String,
        relation: String,
        target: String,
    },
    
    #[error("Relation '{relation}' in '{entity}' references foreign key '{foreign_key}', but '{target}' has no such field")]
    InvalidForeignKey {
        entity: String,
        relation: String,
        target: String,
        foreign_key: String,
    },
    
    #[error("HasMany relation '{relation}' in '{entity}' requires a 'via' foreign key")]
    MissingForeignKey {
        entity: String,
        relation: String,
    },
    
    #[error("Entity '{entity}' has no primary key")]
    MissingPrimaryKey {
        entity: String,
    },
    
    #[error("Entity '{entity}' has multiple primary keys: {fields:?}")]
    MultiplePrimaryKeys {
        entity: String,
        fields: Vec<String>,
    },
    
    #[error("Field '{field}' in '{entity}' uses @vector but type is '{actual_type}', expected vector(N)")]
    InvalidVectorAnnotation {
        entity: String,
        field: String,
        actual_type: String,
    },
    
    #[error("Field '{field}' in '{entity}' is {constraint} and cannot have @{annotation} annotation")]
    AnnotationOnConstrainedField {
        entity: String,
        field: String,
        constraint: String,  // "primary" or "unique"
        annotation: String,
    },
    
    #[error("Circular dependency detected: {cycle:?}")]
    CircularDependency {
        cycle: Vec<String>,
    },
    
    #[error("Duplicate entity name '{entity}' (first defined at #{first}, redefined at #{second})")]
    DuplicateEntity {
        entity: String,
        first: usize,
        second: usize,
    },
    
    #[error("Duplicate field name '{field}' in entity '{entity}'")]
    DuplicateField {
        entity: String,
        field: String,
    },
}
Error design:
  • Uses thiserror for automatic Display implementation
  • Structured fields for programmatic access (e.g., CI tooling)
  • Human-readable messages with context

Performance Characteristics

OperationTime ComplexitySpace ComplexityNotes
Relations checkO(E × R × F)O(1)E=entities, R=relations/entity, F=fields
Cycle detectionO(E + R)O(E)DFS graph traversal
Primary keysO(E × F)O(1)Linear scan per entity
AnnotationsO(E × F)O(1)Linear scan per entity
TotalO(E × F)O(E)Dominated by field scans
Benchmarks (typical schema: 20 entities, 10 fields each):
  • Type check: ~5ms
  • Error report generation: ~0.5ms
  • Memory overhead: ~2KB (visited sets)

Example Usage

Valid Schema

use chameleon::typechecker::type_check;

let schema = build_schema(vec![
    ("User", vec![
        ("id", FieldType::UUID, true, false, None),
        ("email", FieldType::String, false, true, None),
    ], vec![
        ("posts", RelationKind::HasMany, "Post", Some("user_id")),
    ]),
    ("Post", vec![
        ("id", FieldType::UUID, true, false, None),
        ("user_id", FieldType::UUID, false, false, None),
    ], vec![
        ("user", RelationKind::BelongsTo, "User", None),
    ]),
]);

let result = type_check(&schema);
assert!(result.is_valid());

Invalid Schema

let schema = build_schema(vec![
    ("User", vec![
        ("email", FieldType::String, false, false, None), // No primary key!
    ], vec![
        ("posts", RelationKind::HasMany, "NonExistent", Some("user_id")), // Unknown target!
    ]),
]);

let result = type_check(&schema);
assert!(!result.is_valid());
assert_eq!(result.errors.len(), 2);

println!("{}", result.error_report());
// Output:
// ❌ Found 2 error(s):
//
//   1. Entity 'User' has no primary key
//   2. Entity 'User' references unknown entity 'NonExistent' in relation 'posts'

Annotation Errors

let schema = build_schema(vec![
    ("Product", vec![
        ("id", FieldType::UUID, true, false, Some(BackendAnnotation::Cache)), // Invalid!
        ("embedding", FieldType::String, false, false, Some(BackendAnnotation::Vector)), // Invalid!
    ], vec![]),
]);

let result = type_check(&schema);
assert_eq!(result.errors.len(), 2);

match &result.errors[0] {
    TypeCheckError::AnnotationOnConstrainedField { field, constraint, .. } => {
        assert_eq!(field, "id");
        assert_eq!(constraint, "primary");
    }
    _ => panic!("Unexpected error type"),
}

Testing

Location: chameleon-core/src/typechecker/mod.rs:98 Test coverage:
  • ✅ Valid simple schemas
  • ✅ Valid schemas with relations
  • ✅ Valid backend annotations
  • ✅ Unknown relation targets
  • ✅ Invalid foreign keys
  • ✅ Missing foreign keys (HasMany)
  • ✅ Missing primary keys
  • ✅ Multiple primary keys
  • ✅ Invalid vector annotations
  • ✅ Annotations on primary keys
  • ✅ Annotations on unique fields
  • ✅ Circular dependencies
  • ✅ Error report formatting
Example test:
#[test]
fn test_circular_dependency() {
    let schema = build_schema(vec![
        ("A", vec![("id", FieldType::UUID, true, false, None)], 
              vec![("b", RelationKind::HasOne, "B", None)]),
        ("B", vec![("id", FieldType::UUID, true, false, None)], 
              vec![("c", RelationKind::HasOne, "C", None)]),
        ("C", vec![("id", FieldType::UUID, true, false, None)], 
              vec![("a", RelationKind::HasOne, "A", None)]),
    ]);
    
    let result = type_check(&schema);
    assert!(!result.is_valid());
    assert!(result.errors.iter().any(|e| matches!(e, TypeCheckError::CircularDependency { .. })));
}

Integration with FFI

The type checker is exposed to Go via FFI (see FFI Interface): Location: chameleon-core/src/ffi/mod.rs:106
#[no_mangle]
pub unsafe extern "C" fn chameleon_validate_schema(
    input: *const c_char,
    error_out: *mut *mut c_char,
) -> ChameleonResult {
    let schema = parse_schema(input_str)?;
    let result = type_check(&schema);
    
    if result.is_valid() {
        return ChameleonResult::Ok;
    } else {
        // Serialize errors to JSON
        let json = serde_json::to_string(&result.errors)?;
        *error_out = CString::new(json)?.into_raw();
        return ChameleonResult::ValidationError;
    }
}
JSON error format:
{
  "valid": false,
  "errors": [
    {
      "kind": "ValidationError",
      "message": "Entity 'User' has no primary key",
      "line": null,
      "column": null
    }
  ]
}

See Also