Skip to main content

prax_schema/
validator.rs

1//! Schema validation and semantic analysis.
2//!
3//! This module validates parsed schemas for semantic correctness:
4//! - All type references are valid
5//! - Relations are properly defined
6//! - Required attributes are present
7//! - No duplicate definitions
8
9use crate::ast::*;
10use crate::error::{SchemaError, SchemaResult};
11
12/// Schema validator for semantic analysis.
13#[derive(Debug)]
14pub struct Validator {
15    /// Collected validation errors.
16    errors: Vec<SchemaError>,
17}
18
19impl Default for Validator {
20    fn default() -> Self {
21        Self::new()
22    }
23}
24
25impl Validator {
26    /// Create a new validator.
27    pub fn new() -> Self {
28        Self { errors: vec![] }
29    }
30
31    /// Validate a schema and return the validated schema or errors.
32    pub fn validate(&mut self, mut schema: Schema) -> SchemaResult<Schema> {
33        self.errors.clear();
34
35        // Check for duplicate definitions
36        self.check_duplicates(&schema);
37
38        // Resolve field types (convert Model references to Enum or Composite where appropriate)
39        self.resolve_field_types(&mut schema);
40
41        // Validate each model
42        for model in schema.models.values() {
43            self.validate_model(model, &schema);
44        }
45
46        // Validate computed / virtual field combinations
47        self.validate_computed_fields(&schema);
48
49        // Validate each enum
50        for e in schema.enums.values() {
51            self.validate_enum(e);
52        }
53
54        // Validate each composite type
55        for t in schema.types.values() {
56            self.validate_composite_type(t, &schema);
57        }
58
59        // Validate each view
60        for v in schema.views.values() {
61            self.validate_view(v, &schema);
62        }
63
64        // Validate each server group
65        for sg in schema.server_groups.values() {
66            self.validate_server_group(sg);
67        }
68
69        // Resolve relations
70        let relations = self.resolve_relations(&schema);
71        schema.relations = relations;
72
73        if self.errors.is_empty() {
74            Ok(schema)
75        } else {
76            Err(SchemaError::ValidationFailed {
77                count: self.errors.len(),
78                errors: std::mem::take(&mut self.errors),
79            })
80        }
81    }
82
83    /// Check for duplicate model, enum, or type names.
84    fn check_duplicates(&mut self, schema: &Schema) {
85        let mut seen = std::collections::HashSet::new();
86
87        for name in schema.models.keys() {
88            if !seen.insert(name.as_str()) {
89                self.errors
90                    .push(SchemaError::duplicate("model", name.as_str()));
91            }
92        }
93
94        for name in schema.enums.keys() {
95            if !seen.insert(name.as_str()) {
96                self.errors
97                    .push(SchemaError::duplicate("enum", name.as_str()));
98            }
99        }
100
101        for name in schema.types.keys() {
102            if !seen.insert(name.as_str()) {
103                self.errors
104                    .push(SchemaError::duplicate("type", name.as_str()));
105            }
106        }
107
108        for name in schema.views.keys() {
109            if !seen.insert(name.as_str()) {
110                self.errors
111                    .push(SchemaError::duplicate("view", name.as_str()));
112            }
113        }
114
115        // Check server group names (separately, since they don't conflict with types)
116        let mut server_group_names = std::collections::HashSet::new();
117        for name in schema.server_groups.keys() {
118            if !server_group_names.insert(name.as_str()) {
119                self.errors
120                    .push(SchemaError::duplicate("serverGroup", name.as_str()));
121            }
122        }
123    }
124
125    /// Resolve field types to their correct types (Enum or Composite) instead of Model.
126    ///
127    /// The parser initially treats all non-scalar type references as Model references.
128    /// This pass corrects them to Enum or Composite where appropriate.
129    fn resolve_field_types(&self, schema: &mut Schema) {
130        // Collect enum and composite type names into owned strings to avoid borrow conflicts
131        let enum_names: std::collections::HashSet<String> =
132            schema.enums.keys().map(|s| s.to_string()).collect();
133        let composite_names: std::collections::HashSet<String> =
134            schema.types.keys().map(|s| s.to_string()).collect();
135
136        // Update field types in models
137        for model in schema.models.values_mut() {
138            for field in model.fields.values_mut() {
139                if let FieldType::Model(ref type_name) = field.field_type {
140                    let name = type_name.as_str();
141                    if enum_names.contains(name) {
142                        field.field_type = FieldType::Enum(type_name.clone());
143                    } else if composite_names.contains(name) {
144                        field.field_type = FieldType::Composite(type_name.clone());
145                    }
146                }
147            }
148        }
149
150        // Also update field types in composite types
151        for composite in schema.types.values_mut() {
152            for field in composite.fields.values_mut() {
153                if let FieldType::Model(ref type_name) = field.field_type {
154                    let name = type_name.as_str();
155                    if enum_names.contains(name) {
156                        field.field_type = FieldType::Enum(type_name.clone());
157                    } else if composite_names.contains(name) {
158                        field.field_type = FieldType::Composite(type_name.clone());
159                    }
160                }
161            }
162        }
163
164        // Also update field types in views
165        for view in schema.views.values_mut() {
166            for field in view.fields.values_mut() {
167                if let FieldType::Model(ref type_name) = field.field_type {
168                    let name = type_name.as_str();
169                    if enum_names.contains(name) {
170                        field.field_type = FieldType::Enum(type_name.clone());
171                    } else if composite_names.contains(name) {
172                        field.field_type = FieldType::Composite(type_name.clone());
173                    }
174                }
175            }
176        }
177    }
178
179    /// Validate a model definition.
180    fn validate_model(&mut self, model: &Model, schema: &Schema) {
181        // Check for @id field
182        let id_fields: Vec<_> = model.fields.values().filter(|f| f.is_id()).collect();
183        if id_fields.is_empty() && !self.has_composite_id(model) {
184            self.errors.push(SchemaError::MissingId {
185                model: model.name().to_string(),
186            });
187        }
188
189        // Validate each field
190        for field in model.fields.values() {
191            self.validate_field(field, model.name(), schema);
192        }
193
194        // Validate model attributes
195        for attr in &model.attributes {
196            self.validate_model_attribute(attr, model);
197        }
198    }
199
200    /// Check if model has a composite ID (@@id attribute).
201    fn has_composite_id(&self, model: &Model) -> bool {
202        model.attributes.iter().any(|a| a.is("id"))
203    }
204
205    /// Validate a field definition.
206    fn validate_field(&mut self, field: &Field, model_name: &str, schema: &Schema) {
207        // Validate type references
208        match &field.field_type {
209            FieldType::Model(name) => {
210                // Check if it's actually a model, enum, or composite type
211                if schema.models.contains_key(name.as_str()) {
212                    // Valid model reference
213                } else if schema.enums.contains_key(name.as_str()) {
214                    // Parser initially treats non-scalar types as Model references
215                    // This is actually an enum type - we'll handle this during resolution
216                } else if schema.types.contains_key(name.as_str()) {
217                    // This is a composite type
218                } else {
219                    self.errors.push(SchemaError::unknown_type(
220                        model_name,
221                        field.name(),
222                        name.as_str(),
223                    ));
224                }
225            }
226            FieldType::Enum(name) => {
227                if !schema.enums.contains_key(name.as_str()) {
228                    self.errors.push(SchemaError::unknown_type(
229                        model_name,
230                        field.name(),
231                        name.as_str(),
232                    ));
233                }
234            }
235            FieldType::Composite(name) if !schema.types.contains_key(name.as_str()) => {
236                self.errors.push(SchemaError::unknown_type(
237                    model_name,
238                    field.name(),
239                    name.as_str(),
240                ));
241            }
242            _ => {}
243        }
244
245        // Validate field attributes
246        for attr in &field.attributes {
247            self.validate_field_attribute(attr, field, model_name, schema);
248        }
249
250        // Validate relation fields have @relation or are back-references
251        // Only check actual model relations (not enums or composite types parsed as Model)
252        if let FieldType::Model(ref target_name) = field.field_type {
253            // Skip validation for enum and composite type references
254            let is_actual_relation = schema.models.contains_key(target_name.as_str())
255                && !schema.enums.contains_key(target_name.as_str())
256                && !schema.types.contains_key(target_name.as_str());
257
258            if is_actual_relation && !field.is_list() {
259                // One-side of relation should have foreign key fields
260                let attrs = field.extract_attributes();
261                if let Some(rel) = attrs.relation.as_ref() {
262                    // Validate foreign key fields exist
263                    for fk_field in &rel.fields {
264                        if !schema
265                            .models
266                            .get(model_name)
267                            .map(|m| m.fields.contains_key(fk_field.as_str()))
268                            .unwrap_or(false)
269                        {
270                            self.errors.push(SchemaError::invalid_relation(
271                                model_name,
272                                field.name(),
273                                format!("foreign key field '{}' does not exist", fk_field),
274                            ));
275                        }
276                    }
277                }
278            }
279        }
280    }
281
282    /// Validate a field attribute.
283    fn validate_field_attribute(
284        &mut self,
285        attr: &Attribute,
286        field: &Field,
287        model_name: &str,
288        schema: &Schema,
289    ) {
290        match attr.name() {
291            "id" => {
292                // @id should be on a scalar or composite type, not a relation
293                if field.field_type.is_relation() {
294                    self.errors.push(SchemaError::InvalidAttribute {
295                        attribute: "id".to_string(),
296                        message: format!(
297                            "@id cannot be applied to relation field '{}.{}'",
298                            model_name,
299                            field.name()
300                        ),
301                    });
302                }
303            }
304            "auto" => {
305                // @auto should only be on Int or BigInt
306                if !matches!(
307                    field.field_type,
308                    FieldType::Scalar(ScalarType::Int) | FieldType::Scalar(ScalarType::BigInt)
309                ) {
310                    self.errors.push(SchemaError::InvalidAttribute {
311                        attribute: "auto".to_string(),
312                        message: format!(
313                            "@auto can only be applied to Int or BigInt fields, not '{}.{}'",
314                            model_name,
315                            field.name()
316                        ),
317                    });
318                }
319            }
320            "default" => {
321                // Validate default value type matches field type
322                if let Some(value) = attr.first_arg() {
323                    self.validate_default_value(value, field, model_name, schema);
324                }
325            }
326            "relation" => {
327                // Validate relation attribute - should only be on actual model references
328                let is_model_ref = matches!(&field.field_type, FieldType::Model(name)
329                    if schema.models.contains_key(name.as_str()));
330                if !is_model_ref {
331                    self.errors.push(SchemaError::InvalidAttribute {
332                        attribute: "relation".to_string(),
333                        message: format!(
334                            "@relation can only be applied to model reference fields, not '{}.{}'",
335                            model_name,
336                            field.name()
337                        ),
338                    });
339                }
340            }
341            "updated_at" => {
342                // @updated_at should only be on DateTime
343                if !matches!(field.field_type, FieldType::Scalar(ScalarType::DateTime)) {
344                    self.errors.push(SchemaError::InvalidAttribute {
345                        attribute: "updated_at".to_string(),
346                        message: format!(
347                            "@updated_at can only be applied to DateTime fields, not '{}.{}'",
348                            model_name,
349                            field.name()
350                        ),
351                    });
352                }
353            }
354            "map" => {
355                // @map("col") rewrites the SQL column name. Identifiers must
356                // be safe for direct splicing into queries — see the
357                // `.cursor/rules/sql-safety.mdc` trust-boundary subsection.
358                if let Some(AttributeValue::String(name)) = attr.first_arg()
359                    && !is_safe_sql_identifier(name)
360                {
361                    self.errors.push(SchemaError::invalid_field(
362                        model_name,
363                        field.name(),
364                        format!(
365                            "@map(\"{}\") contains characters outside [A-Za-z0-9_.]; \
366                             SQL identifiers must be safe to splice into queries",
367                            name
368                        ),
369                    ));
370                }
371            }
372            _ => {}
373        }
374    }
375
376    /// Validate a default value matches the field type.
377    fn validate_default_value(
378        &mut self,
379        value: &AttributeValue,
380        field: &Field,
381        model_name: &str,
382        schema: &Schema,
383    ) {
384        match (&field.field_type, value) {
385            // Functions are generally allowed (now(), uuid(), etc.)
386            (_, AttributeValue::Function(_, _)) => {}
387
388            // Int fields should have int defaults
389            (FieldType::Scalar(ScalarType::Int), AttributeValue::Int(_)) => {}
390            (FieldType::Scalar(ScalarType::BigInt), AttributeValue::Int(_)) => {}
391
392            // Float fields can have int or float defaults
393            (FieldType::Scalar(ScalarType::Float), AttributeValue::Int(_)) => {}
394            (FieldType::Scalar(ScalarType::Float), AttributeValue::Float(_)) => {}
395            (FieldType::Scalar(ScalarType::Decimal), AttributeValue::Int(_)) => {}
396            (FieldType::Scalar(ScalarType::Decimal), AttributeValue::Float(_)) => {}
397
398            // String fields should have string defaults
399            (FieldType::Scalar(ScalarType::String), AttributeValue::String(_)) => {}
400
401            // Json fields accept any constant as the @default — the payload is
402            // stored as a text literal that the database parses into jsonb.
403            // Prisma writes empty objects/arrays as `@default("[]")` or
404            // `@default("{}")`, so accept string, array, and scalar primitives.
405            (FieldType::Scalar(ScalarType::Json), AttributeValue::String(_))
406            | (FieldType::Scalar(ScalarType::Json), AttributeValue::Array(_))
407            | (FieldType::Scalar(ScalarType::Json), AttributeValue::Boolean(_))
408            | (FieldType::Scalar(ScalarType::Json), AttributeValue::Int(_))
409            | (FieldType::Scalar(ScalarType::Json), AttributeValue::Float(_)) => {}
410
411            // Boolean fields should have boolean defaults
412            (FieldType::Scalar(ScalarType::Boolean), AttributeValue::Boolean(_)) => {}
413
414            // Enum fields should have ident defaults matching a variant
415            (FieldType::Enum(enum_name), AttributeValue::Ident(variant)) => {
416                if let Some(e) = schema.enums.get(enum_name.as_str())
417                    && e.get_variant(variant).is_none()
418                {
419                    self.errors.push(SchemaError::invalid_field(
420                        model_name,
421                        field.name(),
422                        format!(
423                            "default value '{}' is not a valid variant of enum '{}'",
424                            variant, enum_name
425                        ),
426                    ));
427                }
428            }
429
430            // Model type might actually be an enum (parser treats non-scalar as Model initially)
431            (FieldType::Model(type_name), AttributeValue::Ident(variant)) => {
432                // Check if this is actually an enum reference
433                if let Some(e) = schema.enums.get(type_name.as_str())
434                    && e.get_variant(variant).is_none()
435                {
436                    self.errors.push(SchemaError::invalid_field(
437                        model_name,
438                        field.name(),
439                        format!(
440                            "default value '{}' is not a valid variant of enum '{}'",
441                            variant, type_name
442                        ),
443                    ));
444                }
445                // If it's a real model reference with an ident default, that's an error
446                // but we skip that here since it's likely a valid enum
447            }
448
449            // Type mismatch
450            _ => {
451                self.errors.push(SchemaError::invalid_field(
452                    model_name,
453                    field.name(),
454                    format!(
455                        "default value type does not match field type '{}'",
456                        field.field_type
457                    ),
458                ));
459            }
460        }
461    }
462
463    /// Validate a model-level attribute.
464    fn validate_model_attribute(&mut self, attr: &Attribute, model: &Model) {
465        match attr.name() {
466            "index" | "unique" => {
467                // Validate referenced fields exist
468                if let Some(AttributeValue::FieldRefList(fields)) = attr.first_arg() {
469                    for field_name in fields {
470                        if !model.fields.contains_key(field_name.as_str()) {
471                            self.errors.push(SchemaError::invalid_model(
472                                model.name(),
473                                format!(
474                                    "@@{} references non-existent field '{}'",
475                                    attr.name(),
476                                    field_name
477                                ),
478                            ));
479                        }
480                    }
481                }
482            }
483            "id" => {
484                // Composite primary key
485                if let Some(AttributeValue::FieldRefList(fields)) = attr.first_arg() {
486                    for field_name in fields {
487                        if !model.fields.contains_key(field_name.as_str()) {
488                            self.errors.push(SchemaError::invalid_model(
489                                model.name(),
490                                format!("@@id references non-existent field '{}'", field_name),
491                            ));
492                        }
493                    }
494                }
495            }
496            "search" => {
497                // Full-text search on fields
498                if let Some(AttributeValue::FieldRefList(fields)) = attr.first_arg() {
499                    for field_name in fields {
500                        if let Some(field) = model.fields.get(field_name.as_str()) {
501                            // Only string fields can be searched
502                            if !matches!(field.field_type, FieldType::Scalar(ScalarType::String)) {
503                                self.errors.push(SchemaError::invalid_model(
504                                    model.name(),
505                                    format!(
506                                        "@@search field '{}' must be of type String",
507                                        field_name
508                                    ),
509                                ));
510                            }
511                        } else {
512                            self.errors.push(SchemaError::invalid_model(
513                                model.name(),
514                                format!("@@search references non-existent field '{}'", field_name),
515                            ));
516                        }
517                    }
518                }
519            }
520            "map" => {
521                // @@map("table_name") — the value is flowed verbatim into
522                // generated SQL via `RelationFilterMeta::PARENT_TABLE` /
523                // `CHILD_TABLE`. Per `.cursor/rules/sql-safety.mdc`,
524                // identifiers must be whitelisted: enforce ASCII
525                // alphanumeric + underscore + dot (for schema-qualified
526                // names) here so the trust boundary is actually
527                // enforced, not just documented.
528                if let Some(AttributeValue::String(name)) = attr.first_arg()
529                    && !is_safe_sql_identifier(name)
530                {
531                    self.errors.push(SchemaError::invalid_model(
532                        model.name(),
533                        format!(
534                            "@@map(\"{}\") contains characters outside [A-Za-z0-9_.]; \
535                             SQL identifiers must be safe to splice into queries",
536                            name
537                        ),
538                    ));
539                }
540            }
541            _ => {}
542        }
543    }
544
545    /// Validate an enum definition.
546    fn validate_enum(&mut self, e: &Enum) {
547        if e.variants.is_empty() {
548            self.errors.push(SchemaError::invalid_model(
549                e.name(),
550                "enum must have at least one variant".to_string(),
551            ));
552        }
553
554        // Check for duplicate variant names
555        let mut seen = std::collections::HashSet::new();
556        for variant in &e.variants {
557            if !seen.insert(variant.name()) {
558                self.errors.push(SchemaError::duplicate(
559                    format!("enum variant in {}", e.name()),
560                    variant.name(),
561                ));
562            }
563        }
564    }
565
566    /// Validate a composite type definition.
567    fn validate_composite_type(&mut self, t: &CompositeType, schema: &Schema) {
568        if t.fields.is_empty() {
569            self.errors.push(SchemaError::invalid_model(
570                t.name(),
571                "composite type must have at least one field".to_string(),
572            ));
573        }
574
575        // Validate field types
576        for field in t.fields.values() {
577            match &field.field_type {
578                FieldType::Model(_) => {
579                    self.errors.push(SchemaError::invalid_field(
580                        t.name(),
581                        field.name(),
582                        "composite types cannot have model relations".to_string(),
583                    ));
584                }
585                FieldType::Enum(name) => {
586                    if !schema.enums.contains_key(name.as_str()) {
587                        self.errors.push(SchemaError::unknown_type(
588                            t.name(),
589                            field.name(),
590                            name.as_str(),
591                        ));
592                    }
593                }
594                FieldType::Composite(name) if !schema.types.contains_key(name.as_str()) => {
595                    self.errors.push(SchemaError::unknown_type(
596                        t.name(),
597                        field.name(),
598                        name.as_str(),
599                    ));
600                }
601                _ => {}
602            }
603        }
604    }
605
606    /// Validate a view definition.
607    fn validate_view(&mut self, v: &View, schema: &Schema) {
608        // Views should have at least one field
609        if v.fields.is_empty() {
610            self.errors.push(SchemaError::invalid_model(
611                v.name(),
612                "view must have at least one field".to_string(),
613            ));
614        }
615
616        // Validate field types
617        for field in v.fields.values() {
618            self.validate_field(field, v.name(), schema);
619        }
620    }
621
622    /// Validate a server group definition.
623    fn validate_server_group(&mut self, sg: &ServerGroup) {
624        // Server groups should have at least one server
625        if sg.servers.is_empty() {
626            self.errors.push(SchemaError::invalid_model(
627                sg.name.name.as_str(),
628                "serverGroup must have at least one server".to_string(),
629            ));
630        }
631
632        // Check for duplicate server names within the group
633        let mut seen_servers = std::collections::HashSet::new();
634        for server_name in sg.servers.keys() {
635            if !seen_servers.insert(server_name.as_str()) {
636                self.errors.push(SchemaError::duplicate(
637                    format!("server in serverGroup {}", sg.name.name),
638                    server_name.as_str(),
639                ));
640            }
641        }
642
643        // Validate each server
644        for server in sg.servers.values() {
645            self.validate_server(server, sg.name.name.as_str());
646        }
647
648        // Validate server group attributes
649        for attr in &sg.attributes {
650            self.validate_server_group_attribute(attr, sg);
651        }
652
653        // Check for at least one primary server in read replica strategy
654        if let Some(strategy) = sg.strategy()
655            && strategy == ServerGroupStrategy::ReadReplica
656        {
657            let has_primary = sg
658                .servers
659                .values()
660                .any(|s| s.role() == Some(ServerRole::Primary));
661            if !has_primary {
662                self.errors.push(SchemaError::invalid_model(
663                    sg.name.name.as_str(),
664                    "ReadReplica strategy requires at least one server with role = \"primary\""
665                        .to_string(),
666                ));
667            }
668        }
669    }
670
671    /// Validate an individual server definition.
672    fn validate_server(&mut self, server: &Server, group_name: &str) {
673        // Server should have a URL property
674        if server.url().is_none() {
675            self.errors.push(SchemaError::invalid_model(
676                group_name,
677                format!("server '{}' must have a 'url' property", server.name.name),
678            ));
679        }
680
681        // Validate weight is positive if specified
682        if let Some(weight) = server.weight()
683            && weight == 0
684        {
685            self.errors.push(SchemaError::invalid_model(
686                group_name,
687                format!(
688                    "server '{}' weight must be greater than 0",
689                    server.name.name
690                ),
691            ));
692        }
693
694        // Validate priority is positive if specified
695        if let Some(priority) = server.priority()
696            && priority == 0
697        {
698            self.errors.push(SchemaError::invalid_model(
699                group_name,
700                format!(
701                    "server '{}' priority must be greater than 0",
702                    server.name.name
703                ),
704            ));
705        }
706    }
707
708    /// Validate a server group attribute.
709    fn validate_server_group_attribute(&mut self, attr: &Attribute, sg: &ServerGroup) {
710        match attr.name() {
711            "strategy" => {
712                // Validate strategy value
713                if let Some(arg) = attr.first_arg() {
714                    let value_str = arg
715                        .as_string()
716                        .map(|s| s.to_string())
717                        .or_else(|| arg.as_ident().map(|s| s.to_string()));
718                    if let Some(val) = value_str
719                        && ServerGroupStrategy::parse(&val).is_none()
720                    {
721                        self.errors.push(SchemaError::InvalidAttribute {
722                                attribute: "strategy".to_string(),
723                                message: format!(
724                                    "invalid strategy '{}' for serverGroup '{}'. Valid values: ReadReplica, Sharding, MultiRegion, HighAvailability, Custom",
725                                    val,
726                                    sg.name.name
727                                ),
728                            });
729                    }
730                }
731            }
732            "loadBalance" => {
733                // Validate load balance value
734                if let Some(arg) = attr.first_arg() {
735                    let value_str = arg
736                        .as_string()
737                        .map(|s| s.to_string())
738                        .or_else(|| arg.as_ident().map(|s| s.to_string()));
739                    if let Some(val) = value_str
740                        && LoadBalanceStrategy::parse(&val).is_none()
741                    {
742                        self.errors.push(SchemaError::InvalidAttribute {
743                                attribute: "loadBalance".to_string(),
744                                message: format!(
745                                    "invalid loadBalance '{}' for serverGroup '{}'. Valid values: RoundRobin, Random, LeastConnections, Weighted, Nearest, Sticky",
746                                    val,
747                                    sg.name.name
748                                ),
749                            });
750                    }
751                }
752            }
753            _ => {} // Other attributes are allowed
754        }
755    }
756
757    /// Validate computed and virtual field combinations within every model.
758    ///
759    /// Illegal cases:
760    /// - `@generated` + `@id` or `@auto`
761    /// - `@generated` + an aggregate attribute on the same field
762    /// - Empty expression inside `@generated("")`
763    /// - `@count(rel.field)` — count takes only a relation name, not a dotted path
764    /// - `@sum/@avg/@min/@max(rel)` — non-Count aggregates need `rel.field`
765    /// - Unknown relation name in any aggregate attribute
766    /// - `@stored` or `@virtual` without a sibling `@generated`
767    fn validate_computed_fields(&mut self, schema: &Schema) {
768        for model in schema.models.values() {
769            for field in model.fields.values() {
770                let attrs = field.extract_attributes();
771
772                // ── @generated validations ─────────────────────────────────
773                if let Some(g) = &attrs.generated {
774                    if attrs.is_id || attrs.is_auto {
775                        self.errors.push(SchemaError::invalid_field(
776                            model.name(),
777                            field.name(),
778                            format!(
779                                "field `{}` cannot be both @generated and @id/@auto",
780                                field.name()
781                            ),
782                        ));
783                    }
784                    if attrs.aggregate.is_some() {
785                        self.errors.push(SchemaError::invalid_field(
786                            model.name(),
787                            field.name(),
788                            format!(
789                                "field `{}` cannot be both @generated and an aggregate",
790                                field.name()
791                            ),
792                        ));
793                    }
794                    if g.expression.trim().is_empty() {
795                        self.errors.push(SchemaError::invalid_field(
796                            model.name(),
797                            field.name(),
798                            format!(
799                                "field `{}`: @generated expression must not be empty",
800                                field.name()
801                            ),
802                        ));
803                    }
804                }
805
806                // ── aggregate validations ──────────────────────────────────
807                // Check each raw aggregate attribute so we can also detect
808                // malformed forms that the extractor silently drops (e.g.
809                // `@sum(posts)` with no dot, `@count(posts.id)` with a dot).
810                for raw_attr in &field.attributes {
811                    match raw_attr.name() {
812                        "count" => {
813                            // @count must have a plain relation name with no dot.
814                            // first_path_arg() returns the raw string/ident;
815                            // if it contains a dot the user wrote `@count(rel.field)`.
816                            if let Some(path) = raw_attr.first_path_arg() {
817                                if path.contains('.') {
818                                    self.errors.push(SchemaError::invalid_field(
819                                        model.name(),
820                                        field.name(),
821                                        format!(
822                                            "field `{}`: @count takes a relation name, not `relation.field`",
823                                            field.name()
824                                        ),
825                                    ));
826                                } else {
827                                    // Relation must exist on this model.
828                                    let rel_exists = model
829                                        .fields
830                                        .values()
831                                        .any(|f| f.name() == path && f.is_list());
832                                    if !rel_exists {
833                                        self.errors.push(SchemaError::invalid_field(
834                                            model.name(),
835                                            field.name(),
836                                            format!(
837                                                "field `{}`: unknown relation `{}` in @count",
838                                                field.name(),
839                                                path
840                                            ),
841                                        ));
842                                    }
843                                }
844                            }
845                        }
846                        "sum" | "avg" | "min" | "max" => {
847                            let kind_name = raw_attr.name();
848                            if let Some(path) = raw_attr.first_path_arg() {
849                                if let Some((rel, _field_part)) = path.split_once('.') {
850                                    // Validate relation exists.
851                                    let rel_exists = model
852                                        .fields
853                                        .values()
854                                        .any(|f| f.name() == rel && f.is_list());
855                                    if !rel_exists {
856                                        self.errors.push(SchemaError::invalid_field(
857                                            model.name(),
858                                            field.name(),
859                                            format!(
860                                                "field `{}`: unknown relation `{}` in @{}",
861                                                field.name(),
862                                                rel,
863                                                kind_name
864                                            ),
865                                        ));
866                                    }
867                                } else {
868                                    // No dot — missing field path.
869                                    self.errors.push(SchemaError::invalid_field(
870                                        model.name(),
871                                        field.name(),
872                                        format!(
873                                            "field `{}`: @{} requires `relation.field`",
874                                            field.name(),
875                                            kind_name
876                                        ),
877                                    ));
878                                }
879                            }
880                        }
881                        _ => {}
882                    }
883                }
884
885                // ── orphan @stored / @virtual ──────────────────────────────
886                let has_generated = attrs.generated.is_some();
887                for attr_name in ["stored", "virtual"] {
888                    if field.has_attribute(attr_name) && !has_generated {
889                        self.errors.push(SchemaError::invalid_field(
890                            model.name(),
891                            field.name(),
892                            format!(
893                                "field `{}`: @{} is only valid alongside @generated",
894                                field.name(),
895                                attr_name
896                            ),
897                        ));
898                    }
899                }
900            }
901        }
902    }
903
904    /// Resolve all relations in the schema.
905    fn resolve_relations(&mut self, schema: &Schema) -> Vec<Relation> {
906        let mut relations = Vec::new();
907
908        for model in schema.models.values() {
909            for field in model.fields.values() {
910                if let FieldType::Model(ref target_model) = field.field_type {
911                    // Skip if this is actually an enum reference (parser treats non-scalar as Model initially)
912                    if schema.enums.contains_key(target_model.as_str()) {
913                        continue;
914                    }
915
916                    // Skip if this is actually a composite type reference
917                    if schema.types.contains_key(target_model.as_str()) {
918                        continue;
919                    }
920
921                    // Skip if the target model doesn't exist (error was already reported)
922                    if !schema.models.contains_key(target_model.as_str()) {
923                        continue;
924                    }
925
926                    let attrs = field.extract_attributes();
927
928                    let relation_type = if field.is_list() {
929                        // This model has many of target
930                        RelationType::OneToMany
931                    } else {
932                        // This model has one of target
933                        RelationType::ManyToOne
934                    };
935
936                    let mut relation = Relation::new(
937                        model.name(),
938                        field.name(),
939                        target_model.as_str(),
940                        relation_type,
941                    );
942
943                    if let Some(rel_attr) = &attrs.relation {
944                        if let Some(name) = &rel_attr.name {
945                            relation = relation.with_name(name.as_str());
946                        }
947                        if !rel_attr.fields.is_empty() {
948                            relation = relation.with_from_fields(rel_attr.fields.clone());
949                        }
950                        if !rel_attr.references.is_empty() {
951                            relation = relation.with_to_fields(rel_attr.references.clone());
952                        }
953                        if let Some(action) = rel_attr.on_delete {
954                            relation = relation.with_on_delete(action);
955                        }
956                        if let Some(action) = rel_attr.on_update {
957                            relation = relation.with_on_update(action);
958                        }
959                        if let Some(map) = &rel_attr.map {
960                            relation = relation.with_map(map.as_str());
961                        }
962                    }
963
964                    relations.push(relation);
965                }
966            }
967        }
968
969        relations
970    }
971}
972
973/// Whether a string is a safe SQL identifier — ASCII alphanumeric plus
974/// underscore, plus dot for schema-qualified names. Defense-in-depth
975/// for the compile-time-trusted schema-author boundary described in
976/// `.cursor/rules/sql-safety.mdc`.
977fn is_safe_sql_identifier(s: &str) -> bool {
978    !s.is_empty()
979        && s.chars()
980            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
981}
982
983/// Validate a schema string and return the validated schema.
984pub fn validate_schema(input: &str) -> SchemaResult<Schema> {
985    let schema = crate::parser::parse_schema(input)?;
986    let mut validator = Validator::new();
987    validator.validate(schema)
988}
989
990#[cfg(test)]
991mod tests {
992    use super::*;
993
994    #[test]
995    fn test_validate_simple_model() {
996        let schema = validate_schema(
997            r#"
998            model User {
999                id    Int    @id @auto
1000                email String @unique
1001            }
1002        "#,
1003        )
1004        .unwrap();
1005
1006        assert_eq!(schema.models.len(), 1);
1007    }
1008
1009    #[test]
1010    fn test_validate_model_missing_id() {
1011        let result = validate_schema(
1012            r#"
1013            model User {
1014                email String
1015                name  String
1016            }
1017        "#,
1018        );
1019
1020        assert!(result.is_err());
1021        let err = result.unwrap_err();
1022        assert!(matches!(err, SchemaError::ValidationFailed { .. }));
1023    }
1024
1025    #[test]
1026    fn test_validate_model_with_composite_id() {
1027        let schema = validate_schema(
1028            r#"
1029            model PostTag {
1030                post_id Int
1031                tag_id  Int
1032
1033                @@id([post_id, tag_id])
1034            }
1035        "#,
1036        )
1037        .unwrap();
1038
1039        assert_eq!(schema.models.len(), 1);
1040    }
1041
1042    #[test]
1043    fn test_validate_unknown_type_reference() {
1044        let result = validate_schema(
1045            r#"
1046            model User {
1047                id      Int    @id @auto
1048                profile UnknownType
1049            }
1050        "#,
1051        );
1052
1053        assert!(result.is_err());
1054    }
1055
1056    #[test]
1057    fn test_validate_enum_reference() {
1058        let schema = validate_schema(
1059            r#"
1060            enum Role {
1061                User
1062                Admin
1063            }
1064
1065            model User {
1066                id   Int    @id @auto
1067                role Role   @default(User)
1068            }
1069        "#,
1070        )
1071        .unwrap();
1072
1073        assert_eq!(schema.models.len(), 1);
1074        assert_eq!(schema.enums.len(), 1);
1075    }
1076
1077    #[test]
1078    fn test_validate_enum_field_type_resolved() {
1079        let schema = validate_schema(
1080            r#"
1081            enum Role {
1082                User
1083                Admin
1084            }
1085
1086            model User {
1087                id   Int    @id @auto
1088                role Role   @default(User)
1089            }
1090        "#,
1091        )
1092        .unwrap();
1093
1094        // The parser initially tags `role Role` as FieldType::Model("Role");
1095        // resolve_field_types must rewrite it to FieldType::Enum so downstream
1096        // consumers (e.g. prax-migrate) see the enum, not a model reference.
1097        let role = schema
1098            .models
1099            .get("User")
1100            .unwrap()
1101            .fields
1102            .get("role")
1103            .unwrap();
1104        assert_eq!(role.field_type, FieldType::Enum("Role".into()));
1105    }
1106
1107    #[test]
1108    fn test_validate_invalid_enum_default() {
1109        let result = validate_schema(
1110            r#"
1111            enum Role {
1112                User
1113                Admin
1114            }
1115
1116            model User {
1117                id   Int    @id @auto
1118                role Role   @default(Unknown)
1119            }
1120        "#,
1121        );
1122
1123        assert!(result.is_err());
1124    }
1125
1126    #[test]
1127    fn test_validate_auto_on_non_int() {
1128        let result = validate_schema(
1129            r#"
1130            model User {
1131                id    String @id @auto
1132                email String
1133            }
1134        "#,
1135        );
1136
1137        assert!(result.is_err());
1138    }
1139
1140    #[test]
1141    fn test_validate_updated_at_on_non_datetime() {
1142        let result = validate_schema(
1143            r#"
1144            model User {
1145                id         Int    @id @auto
1146                updated_at String @updated_at
1147            }
1148        "#,
1149        );
1150
1151        assert!(result.is_err());
1152    }
1153
1154    #[test]
1155    fn test_validate_empty_enum() {
1156        let result = validate_schema(
1157            r#"
1158            enum Empty {
1159            }
1160
1161            model User {
1162                id Int @id @auto
1163            }
1164        "#,
1165        );
1166
1167        assert!(result.is_err());
1168    }
1169
1170    #[test]
1171    fn test_validate_duplicate_model_names() {
1172        let result = validate_schema(
1173            r#"
1174            model User {
1175                id Int @id @auto
1176            }
1177
1178            model User {
1179                id Int @id @auto
1180            }
1181        "#,
1182        );
1183
1184        // Note: This might parse as a single model due to grammar
1185        // The duplicate check happens at validation time
1186        assert!(result.is_ok() || result.is_err());
1187    }
1188
1189    #[test]
1190    fn test_validate_relation() {
1191        let schema = validate_schema(
1192            r#"
1193            model User {
1194                id    Int    @id @auto
1195                posts Post[]
1196            }
1197
1198            model Post {
1199                id        Int    @id @auto
1200                author_id Int
1201                author    User   @relation(fields: [author_id], references: [id])
1202            }
1203        "#,
1204        )
1205        .unwrap();
1206
1207        assert_eq!(schema.models.len(), 2);
1208        assert!(!schema.relations.is_empty());
1209    }
1210
1211    #[test]
1212    fn test_validate_index_with_invalid_field() {
1213        let result = validate_schema(
1214            r#"
1215            model User {
1216                id    Int    @id @auto
1217                email String
1218
1219                @@index([nonexistent])
1220            }
1221        "#,
1222        );
1223
1224        assert!(result.is_err());
1225    }
1226
1227    #[test]
1228    fn test_validate_search_on_non_string_field() {
1229        let result = validate_schema(
1230            r#"
1231            model Post {
1232                id    Int    @id @auto
1233                views Int
1234
1235                @@search([views])
1236            }
1237        "#,
1238        );
1239
1240        assert!(result.is_err());
1241    }
1242
1243    #[test]
1244    fn test_validate_composite_type() {
1245        let schema = validate_schema(
1246            r#"
1247            type Address {
1248                street  String
1249                city    String
1250                country String @default("US")
1251            }
1252
1253            model User {
1254                id      Int     @id @auto
1255                address Address
1256            }
1257        "#,
1258        );
1259
1260        // Note: Composite type support depends on parser handling
1261        assert!(schema.is_ok() || schema.is_err());
1262    }
1263
1264    // ==================== Server Group Validation Tests ====================
1265
1266    #[test]
1267    fn test_validate_server_group_basic() {
1268        let schema = validate_schema(
1269            r#"
1270            model User {
1271                id Int @id @auto
1272            }
1273
1274            serverGroup MainCluster {
1275                server primary {
1276                    url = "postgres://localhost/db"
1277                    role = "primary"
1278                }
1279            }
1280        "#,
1281        )
1282        .unwrap();
1283
1284        assert_eq!(schema.server_groups.len(), 1);
1285    }
1286
1287    #[test]
1288    fn test_validate_server_group_empty_servers() {
1289        let result = validate_schema(
1290            r#"
1291            model User {
1292                id Int @id @auto
1293            }
1294
1295            serverGroup EmptyCluster {
1296            }
1297        "#,
1298        );
1299
1300        assert!(result.is_err());
1301    }
1302
1303    #[test]
1304    fn test_validate_server_group_missing_url() {
1305        let result = validate_schema(
1306            r#"
1307            model User {
1308                id Int @id @auto
1309            }
1310
1311            serverGroup Cluster {
1312                server db {
1313                    role = "primary"
1314                }
1315            }
1316        "#,
1317        );
1318
1319        assert!(result.is_err());
1320    }
1321
1322    #[test]
1323    fn test_validate_server_group_invalid_strategy() {
1324        let result = validate_schema(
1325            r#"
1326            model User {
1327                id Int @id @auto
1328            }
1329
1330            serverGroup Cluster {
1331                @@strategy(InvalidStrategy)
1332
1333                server db {
1334                    url = "postgres://localhost/db"
1335                }
1336            }
1337        "#,
1338        );
1339
1340        assert!(result.is_err());
1341    }
1342
1343    #[test]
1344    fn test_validate_server_group_valid_strategy() {
1345        let schema = validate_schema(
1346            r#"
1347            model User {
1348                id Int @id @auto
1349            }
1350
1351            serverGroup Cluster {
1352                @@strategy(ReadReplica)
1353                @@loadBalance(RoundRobin)
1354
1355                server primary {
1356                    url = "postgres://localhost/db"
1357                    role = "primary"
1358                }
1359            }
1360        "#,
1361        )
1362        .unwrap();
1363
1364        assert_eq!(schema.server_groups.len(), 1);
1365    }
1366
1367    #[test]
1368    fn test_validate_server_group_read_replica_needs_primary() {
1369        let result = validate_schema(
1370            r#"
1371            model User {
1372                id Int @id @auto
1373            }
1374
1375            serverGroup Cluster {
1376                @@strategy(ReadReplica)
1377
1378                server replica1 {
1379                    url = "postgres://localhost/db"
1380                    role = "replica"
1381                }
1382            }
1383        "#,
1384        );
1385
1386        assert!(result.is_err());
1387    }
1388
1389    #[test]
1390    fn test_validate_server_group_with_replicas() {
1391        let schema = validate_schema(
1392            r#"
1393            model User {
1394                id Int @id @auto
1395            }
1396
1397            serverGroup Cluster {
1398                @@strategy(ReadReplica)
1399
1400                server primary {
1401                    url = "postgres://primary/db"
1402                    role = "primary"
1403                    weight = 1
1404                }
1405
1406                server replica1 {
1407                    url = "postgres://replica1/db"
1408                    role = "replica"
1409                    weight = 2
1410                }
1411
1412                server replica2 {
1413                    url = "postgres://replica2/db"
1414                    role = "replica"
1415                    weight = 2
1416                    region = "us-west-1"
1417                }
1418            }
1419        "#,
1420        )
1421        .unwrap();
1422
1423        let cluster = schema.get_server_group("Cluster").unwrap();
1424        assert_eq!(cluster.servers.len(), 3);
1425    }
1426
1427    #[test]
1428    fn test_validate_server_group_zero_weight() {
1429        let result = validate_schema(
1430            r#"
1431            model User {
1432                id Int @id @auto
1433            }
1434
1435            serverGroup Cluster {
1436                server db {
1437                    url = "postgres://localhost/db"
1438                    weight = 0
1439                }
1440            }
1441        "#,
1442        );
1443
1444        assert!(result.is_err());
1445    }
1446
1447    #[test]
1448    fn test_validate_server_group_invalid_load_balance() {
1449        let result = validate_schema(
1450            r#"
1451            model User {
1452                id Int @id @auto
1453            }
1454
1455            serverGroup Cluster {
1456                @@loadBalance(InvalidStrategy)
1457
1458                server db {
1459                    url = "postgres://localhost/db"
1460                }
1461            }
1462        "#,
1463        );
1464
1465        assert!(result.is_err());
1466    }
1467}