protograph-core 0.1.0

Core types and SDL parsing for protograph
Documentation
use crate::ast::*;
use thiserror::Error;

#[derive(Error, Debug, Clone)]
pub enum ValidationError {
    #[error("Field '{field}' on type '{parent_type}' references '{target_type}' which is not an @entity. Add @entity directive to '{target_type}'.")]
    NonEntityRelationship {
        parent_type: String,
        field: String,
        target_type: String,
    },

    #[error("Field '{field}' on type '{parent_type}' references unknown type '{target_type}'.")]
    UnknownType {
        parent_type: String,
        field: String,
        target_type: String,
    },

    #[error("@{directive} on '{parent_type}::{field}' references foreign key '{foreign_key}' which doesn't exist on '{check_type}'.")]
    MissingForeignKey {
        parent_type: String,
        field: String,
        directive: String,
        foreign_key: String,
        check_type: String,
    },

    #[error("@manyToMany on '{parent_type}::{field}' references junction table '{junction_table}' which doesn't exist.")]
    MissingJunctionTable {
        parent_type: String,
        field: String,
        junction_table: String,
    },

    #[error("Junction table '{junction_table}' must have @entity directive.")]
    JunctionTableNotEntity { junction_table: String },
}

pub fn validate_schema(schema: &ProtographSchema) -> Result<(), Vec<ValidationError>> {
    let mut errors = Vec::new();

    for (type_name, entity) in &schema.types {
        for field in &entity.fields {
            if let Some(relationship) = &field.relationship {
                validate_relationship(field, entity, relationship, schema, &mut errors);
            }
        }
    }

    validate_junction_tables(schema, &mut errors);

    if errors.is_empty() {
        Ok(())
    } else {
        Err(errors)
    }
}

fn validate_relationship(
    field: &Field,
    parent: &EntityType,
    relationship: &Relationship,
    schema: &ProtographSchema,
    errors: &mut Vec<ValidationError>,
) {
    let target_type_name = field.field_type.base_type();

    let target_entity = match schema.types.get(target_type_name) {
        Some(e) => e,
        None => {
            errors.push(ValidationError::UnknownType {
                parent_type: parent.name.clone(),
                field: field.name.clone(),
                target_type: target_type_name.to_string(),
            });
            return;
        }
    };

    if !target_entity.is_entity {
        errors.push(ValidationError::NonEntityRelationship {
            parent_type: parent.name.clone(),
            field: field.name.clone(),
            target_type: target_type_name.to_string(),
        });
    }

    match relationship {
        Relationship::BelongsTo { foreign_key } => {
            if !parent.fields.iter().any(|f| &f.name == foreign_key) {
                errors.push(ValidationError::MissingForeignKey {
                    parent_type: parent.name.clone(),
                    field: field.name.clone(),
                    directive: "belongsTo".to_string(),
                    foreign_key: foreign_key.clone(),
                    check_type: parent.name.clone(),
                });
            }
        }
        Relationship::HasMany { foreign_key } => {
            if !target_entity.fields.iter().any(|f| &f.name == foreign_key) {
                errors.push(ValidationError::MissingForeignKey {
                    parent_type: parent.name.clone(),
                    field: field.name.clone(),
                    directive: "hasMany".to_string(),
                    foreign_key: foreign_key.clone(),
                    check_type: target_type_name.to_string(),
                });
            }
        }
        Relationship::ManyToMany {
            junction_table,
            foreign_key,
        } => {
            if !schema.types.contains_key(junction_table) {
                errors.push(ValidationError::MissingJunctionTable {
                    parent_type: parent.name.clone(),
                    field: field.name.clone(),
                    junction_table: junction_table.clone(),
                });
            }
        }
    }
}

fn validate_junction_tables(schema: &ProtographSchema, errors: &mut Vec<ValidationError>) {
    for (_, entity) in &schema.types {
        for field in &entity.fields {
            if let Some(Relationship::ManyToMany { junction_table, .. }) = &field.relationship {
                if let Some(junction) = schema.types.get(junction_table) {
                    if !junction.is_entity {
                        errors.push(ValidationError::JunctionTableNotEntity {
                            junction_table: junction_table.clone(),
                        });
                    }
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::parser::parse_schema_file;

    #[test]
    fn test_valid_schema() {
        let schema = r#"
            type User @entity {
                id: ID!
                name: String!
                posts: [Post!]! @hasMany(field: "authorId")
            }

            type Post @entity {
                id: ID!
                title: String!
                author: User! @belongsTo(field: "authorId")
                authorId: ID!
            }
        "#;

        let parsed = parse_schema_file(schema).unwrap();
        let result = validate_schema(&parsed);
        assert!(result.is_ok());
    }

    #[test]
    fn test_non_entity_relationship() {
        let schema = r#"
            type User {
                id: ID!
                name: String!
            }

            type Post @entity {
                id: ID!
                author: User! @belongsTo(field: "authorId")
                authorId: ID!
            }
        "#;

        let parsed = parse_schema_file(schema).unwrap();
        let result = validate_schema(&parsed);
        assert!(result.is_err());

        let errors = result.unwrap_err();
        assert!(errors.iter().any(|e| matches!(e, ValidationError::NonEntityRelationship { .. })));
    }

    #[test]
    fn test_missing_foreign_key() {
        let schema = r#"
            type User @entity {
                id: ID!
                name: String!
            }

            type Post @entity {
                id: ID!
                author: User! @belongsTo(field: "userId")
            }
        "#;

        let parsed = parse_schema_file(schema).unwrap();
        let result = validate_schema(&parsed);
        assert!(result.is_err());

        let errors = result.unwrap_err();
        assert!(errors.iter().any(|e| matches!(e, ValidationError::MissingForeignKey { .. })));
    }
}