protograph_core/
validation.rs

1use crate::ast::*;
2use thiserror::Error;
3
4#[derive(Error, Debug, Clone)]
5pub enum ValidationError {
6    #[error("Field '{field}' on type '{parent_type}' references '{target_type}' which is not an @entity. Add @entity directive to '{target_type}'.")]
7    NonEntityRelationship {
8        parent_type: String,
9        field: String,
10        target_type: String,
11    },
12
13    #[error("Field '{field}' on type '{parent_type}' references unknown type '{target_type}'.")]
14    UnknownType {
15        parent_type: String,
16        field: String,
17        target_type: String,
18    },
19
20    #[error("@{directive} on '{parent_type}::{field}' references foreign key '{foreign_key}' which doesn't exist on '{check_type}'.")]
21    MissingForeignKey {
22        parent_type: String,
23        field: String,
24        directive: String,
25        foreign_key: String,
26        check_type: String,
27    },
28
29    #[error("@manyToMany on '{parent_type}::{field}' references junction table '{junction_table}' which doesn't exist.")]
30    MissingJunctionTable {
31        parent_type: String,
32        field: String,
33        junction_table: String,
34    },
35
36    #[error("Junction table '{junction_table}' must have @entity directive.")]
37    JunctionTableNotEntity { junction_table: String },
38}
39
40pub fn validate_schema(schema: &ProtographSchema) -> Result<(), Vec<ValidationError>> {
41    let mut errors = Vec::new();
42
43    for (type_name, entity) in &schema.types {
44        for field in &entity.fields {
45            if let Some(relationship) = &field.relationship {
46                validate_relationship(field, entity, relationship, schema, &mut errors);
47            }
48        }
49    }
50
51    validate_junction_tables(schema, &mut errors);
52
53    if errors.is_empty() {
54        Ok(())
55    } else {
56        Err(errors)
57    }
58}
59
60fn validate_relationship(
61    field: &Field,
62    parent: &EntityType,
63    relationship: &Relationship,
64    schema: &ProtographSchema,
65    errors: &mut Vec<ValidationError>,
66) {
67    let target_type_name = field.field_type.base_type();
68
69    let target_entity = match schema.types.get(target_type_name) {
70        Some(e) => e,
71        None => {
72            errors.push(ValidationError::UnknownType {
73                parent_type: parent.name.clone(),
74                field: field.name.clone(),
75                target_type: target_type_name.to_string(),
76            });
77            return;
78        }
79    };
80
81    if !target_entity.is_entity {
82        errors.push(ValidationError::NonEntityRelationship {
83            parent_type: parent.name.clone(),
84            field: field.name.clone(),
85            target_type: target_type_name.to_string(),
86        });
87    }
88
89    match relationship {
90        Relationship::BelongsTo { foreign_key } => {
91            if !parent.fields.iter().any(|f| &f.name == foreign_key) {
92                errors.push(ValidationError::MissingForeignKey {
93                    parent_type: parent.name.clone(),
94                    field: field.name.clone(),
95                    directive: "belongsTo".to_string(),
96                    foreign_key: foreign_key.clone(),
97                    check_type: parent.name.clone(),
98                });
99            }
100        }
101        Relationship::HasMany { foreign_key } => {
102            if !target_entity.fields.iter().any(|f| &f.name == foreign_key) {
103                errors.push(ValidationError::MissingForeignKey {
104                    parent_type: parent.name.clone(),
105                    field: field.name.clone(),
106                    directive: "hasMany".to_string(),
107                    foreign_key: foreign_key.clone(),
108                    check_type: target_type_name.to_string(),
109                });
110            }
111        }
112        Relationship::ManyToMany {
113            junction_table,
114            foreign_key,
115        } => {
116            if !schema.types.contains_key(junction_table) {
117                errors.push(ValidationError::MissingJunctionTable {
118                    parent_type: parent.name.clone(),
119                    field: field.name.clone(),
120                    junction_table: junction_table.clone(),
121                });
122            }
123        }
124    }
125}
126
127fn validate_junction_tables(schema: &ProtographSchema, errors: &mut Vec<ValidationError>) {
128    for (_, entity) in &schema.types {
129        for field in &entity.fields {
130            if let Some(Relationship::ManyToMany { junction_table, .. }) = &field.relationship {
131                if let Some(junction) = schema.types.get(junction_table) {
132                    if !junction.is_entity {
133                        errors.push(ValidationError::JunctionTableNotEntity {
134                            junction_table: junction_table.clone(),
135                        });
136                    }
137                }
138            }
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::parser::parse_schema_file;
147
148    #[test]
149    fn test_valid_schema() {
150        let schema = r#"
151            type User @entity {
152                id: ID!
153                name: String!
154                posts: [Post!]! @hasMany(field: "authorId")
155            }
156
157            type Post @entity {
158                id: ID!
159                title: String!
160                author: User! @belongsTo(field: "authorId")
161                authorId: ID!
162            }
163        "#;
164
165        let parsed = parse_schema_file(schema).unwrap();
166        let result = validate_schema(&parsed);
167        assert!(result.is_ok());
168    }
169
170    #[test]
171    fn test_non_entity_relationship() {
172        let schema = r#"
173            type User {
174                id: ID!
175                name: String!
176            }
177
178            type Post @entity {
179                id: ID!
180                author: User! @belongsTo(field: "authorId")
181                authorId: ID!
182            }
183        "#;
184
185        let parsed = parse_schema_file(schema).unwrap();
186        let result = validate_schema(&parsed);
187        assert!(result.is_err());
188
189        let errors = result.unwrap_err();
190        assert!(errors.iter().any(|e| matches!(e, ValidationError::NonEntityRelationship { .. })));
191    }
192
193    #[test]
194    fn test_missing_foreign_key() {
195        let schema = r#"
196            type User @entity {
197                id: ID!
198                name: String!
199            }
200
201            type Post @entity {
202                id: ID!
203                author: User! @belongsTo(field: "userId")
204            }
205        "#;
206
207        let parsed = parse_schema_file(schema).unwrap();
208        let result = validate_schema(&parsed);
209        assert!(result.is_err());
210
211        let errors = result.unwrap_err();
212        assert!(errors.iter().any(|e| matches!(e, ValidationError::MissingForeignKey { .. })));
213    }
214}