Skip to main content

shaperail_codegen/
validator.rs

1use shaperail_core::{FieldType, HttpMethod, ResourceDefinition, WASM_HOOK_PREFIX};
2
3/// A semantic validation error for a resource definition.
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct ValidationError {
6    pub message: String,
7}
8
9impl std::fmt::Display for ValidationError {
10    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
11        write!(f, "{}", self.message)
12    }
13}
14
15/// Validate a parsed `ResourceDefinition` for semantic correctness.
16///
17/// Returns a list of all validation errors found. An empty list means the
18/// resource is valid.
19pub fn validate_resource(rd: &ResourceDefinition) -> Vec<ValidationError> {
20    let mut errors = Vec::new();
21    let res = &rd.resource;
22
23    // Resource name must not be empty
24    if res.is_empty() {
25        errors.push(err("resource name must not be empty"));
26    }
27
28    // Version must be >= 1
29    if rd.version == 0 {
30        errors.push(err(&format!("resource '{res}': version must be >= 1")));
31    }
32
33    // Schema must have at least one field
34    if rd.schema.is_empty() {
35        errors.push(err(&format!(
36            "resource '{res}': schema must have at least one field"
37        )));
38    }
39
40    // Must have exactly one primary key
41    let primary_count = rd.schema.values().filter(|f| f.primary).count();
42    if primary_count == 0 {
43        errors.push(err(&format!(
44            "resource '{res}': schema must have a primary key field"
45        )));
46    } else if primary_count > 1 {
47        errors.push(err(&format!(
48            "resource '{res}': schema must have exactly one primary key, found {primary_count}"
49        )));
50    }
51
52    // Per-field validation
53    for (name, field) in &rd.schema {
54        // Enum type requires values
55        if field.field_type == FieldType::Enum && field.values.is_none() {
56            errors.push(err(&format!(
57                "resource '{res}': field '{name}' is type enum but has no values"
58            )));
59        }
60
61        // Non-enum type should not have values
62        if field.field_type != FieldType::Enum && field.values.is_some() {
63            errors.push(err(&format!(
64                "resource '{res}': field '{name}' has values but is not type enum"
65            )));
66        }
67
68        // Ref field must be uuid type
69        if field.reference.is_some() && field.field_type != FieldType::Uuid {
70            errors.push(err(&format!(
71                "resource '{res}': field '{name}' has ref but is not type uuid"
72            )));
73        }
74
75        // Ref format must be "resource.field"
76        if let Some(ref reference) = field.reference {
77            if !reference.contains('.') {
78                errors.push(err(&format!(
79                    "resource '{res}': field '{name}' ref must be in 'resource.field' format, got '{reference}'"
80                )));
81            }
82        }
83
84        // Array type requires items
85        if field.field_type == FieldType::Array && field.items.is_none() {
86            errors.push(err(&format!(
87                "resource '{res}': field '{name}' is type array but has no items"
88            )));
89        }
90
91        // Format only valid for string type
92        if field.format.is_some() && field.field_type != FieldType::String {
93            errors.push(err(&format!(
94                "resource '{res}': field '{name}' has format but is not type string"
95            )));
96        }
97
98        // Primary key should be generated or required
99        if field.primary && !field.generated && !field.required {
100            errors.push(err(&format!(
101                "resource '{res}': primary key field '{name}' must be generated or required"
102            )));
103        }
104
105        // Transient field constraints — `transient: true` means input-only, never persisted.
106        // Combinations that imply persistence are nonsensical and rejected loudly.
107        if field.transient {
108            if field.primary {
109                errors.push(err(&format!(
110                    "resource '{res}': field '{name}' cannot be both transient and primary"
111                )));
112            }
113            if field.generated {
114                errors.push(err(&format!(
115                    "resource '{res}': field '{name}' cannot be both transient and generated"
116                )));
117            }
118            if field.reference.is_some() {
119                errors.push(err(&format!(
120                    "resource '{res}': field '{name}' cannot be both transient and have a ref (foreign keys imply persistence)"
121                )));
122            }
123            if field.unique {
124                errors.push(err(&format!(
125                    "resource '{res}': field '{name}' cannot be both transient and unique (unique constraints require persistence)"
126                )));
127            }
128            if field.default.is_some() {
129                errors.push(err(&format!(
130                    "resource '{res}': field '{name}' cannot be both transient and have a default (defaults apply to stored columns)"
131                )));
132            }
133        }
134    }
135
136    // Transient fields must appear in at least one endpoint's `input:` list — otherwise
137    // they're unreachable: never populated, never validated, never seen anywhere.
138    let transient_fields: Vec<&String> = rd
139        .schema
140        .iter()
141        .filter(|(_, f)| f.transient)
142        .map(|(name, _)| name)
143        .collect();
144    if !transient_fields.is_empty() {
145        let referenced: std::collections::HashSet<&str> = rd
146            .endpoints
147            .as_ref()
148            .map(|eps| {
149                eps.values()
150                    .filter_map(|ep| ep.input.as_ref())
151                    .flat_map(|inputs| inputs.iter().map(|s| s.as_str()))
152                    .collect()
153            })
154            .unwrap_or_default();
155        for name in transient_fields {
156            if !referenced.contains(name.as_str()) {
157                errors.push(err(&format!(
158                    "resource '{res}': transient field '{name}' is not declared in any endpoint's input: list (the field would be unreachable)"
159                )));
160            }
161        }
162    }
163
164    // Tenant key validation (M18)
165    if let Some(ref tenant_key) = rd.tenant_key {
166        match rd.schema.get(tenant_key) {
167            Some(field) => {
168                if field.field_type != FieldType::Uuid {
169                    errors.push(err(&format!(
170                        "resource '{res}': tenant_key '{tenant_key}' must reference a uuid field, found {}",
171                        field.field_type
172                    )));
173                }
174            }
175            None => {
176                errors.push(err(&format!(
177                    "resource '{res}': tenant_key '{tenant_key}' not found in schema"
178                )));
179            }
180        }
181    }
182
183    // Endpoint validation
184    if let Some(endpoints) = &rd.endpoints {
185        for (action, ep) in endpoints {
186            // method and path must be set (either explicitly or via convention defaults)
187            if ep.method.is_none() {
188                errors.push(err(&format!(
189                    "resource '{res}': endpoint '{action}' has no method. Use a known action name (list, get, create, update, delete) or set method explicitly"
190                )));
191            }
192            if ep.path.is_none() {
193                errors.push(err(&format!(
194                    "resource '{res}': endpoint '{action}' has no path. Use a known action name (list, get, create, update, delete) or set path explicitly"
195                )));
196            }
197
198            if let Some(controller) = &ep.controller {
199                if let Some(before) = &controller.before {
200                    if before.is_empty() {
201                        errors.push(err(&format!(
202                            "resource '{res}': endpoint '{action}' has an empty controller.before name"
203                        )));
204                    }
205                    validate_controller_name(res, action, "before", before, &mut errors);
206                }
207                if let Some(after) = &controller.after {
208                    if after.is_empty() {
209                        errors.push(err(&format!(
210                            "resource '{res}': endpoint '{action}' has an empty controller.after name"
211                        )));
212                    }
213                    validate_controller_name(res, action, "after", after, &mut errors);
214                }
215            }
216
217            if let Some(events) = &ep.events {
218                for event in events {
219                    if event.is_empty() {
220                        errors.push(err(&format!(
221                            "resource '{res}': endpoint '{action}' has an empty event name"
222                        )));
223                    }
224                }
225            }
226
227            if let Some(jobs) = &ep.jobs {
228                for job in jobs {
229                    if job.is_empty() {
230                        errors.push(err(&format!(
231                            "resource '{res}': endpoint '{action}' has an empty job name"
232                        )));
233                    }
234                }
235            }
236
237            // Input fields must exist in schema
238            if let Some(input) = &ep.input {
239                for field_name in input {
240                    if !rd.schema.contains_key(field_name) {
241                        errors.push(err(&format!(
242                            "resource '{res}': endpoint '{action}' input field '{field_name}' not found in schema"
243                        )));
244                    }
245                }
246            }
247
248            // Filter fields must exist in schema
249            if let Some(filters) = &ep.filters {
250                for field_name in filters {
251                    if !rd.schema.contains_key(field_name) {
252                        errors.push(err(&format!(
253                            "resource '{res}': endpoint '{action}' filter field '{field_name}' not found in schema"
254                        )));
255                    }
256                }
257            }
258
259            // Search fields must exist in schema
260            if let Some(search) = &ep.search {
261                for field_name in search {
262                    if !rd.schema.contains_key(field_name) {
263                        errors.push(err(&format!(
264                            "resource '{res}': endpoint '{action}' search field '{field_name}' not found in schema"
265                        )));
266                    }
267                }
268            }
269
270            // Sort fields must exist in schema
271            if let Some(sort) = &ep.sort {
272                for field_name in sort {
273                    if !rd.schema.contains_key(field_name) {
274                        errors.push(err(&format!(
275                            "resource '{res}': endpoint '{action}' sort field '{field_name}' not found in schema"
276                        )));
277                    }
278                }
279            }
280
281            // soft_delete requires deleted_at field in schema
282            if ep.soft_delete && !rd.schema.contains_key("deleted_at") {
283                errors.push(err(&format!(
284                    "resource '{res}': endpoint '{action}' has soft_delete but schema has no 'deleted_at' field"
285                )));
286            }
287
288            if let Some(upload) = &ep.upload {
289                match ep.method.as_ref() {
290                    Some(HttpMethod::Post | HttpMethod::Patch | HttpMethod::Put) => {}
291                    Some(_) => errors.push(err(&format!(
292                        "resource '{res}': endpoint '{action}' uses upload but method must be POST, PATCH, or PUT"
293                    ))),
294                    None => {} // already reported above
295                }
296
297                match rd.schema.get(&upload.field) {
298                    Some(field) if field.field_type == FieldType::File => {}
299                    Some(_) => errors.push(err(&format!(
300                        "resource '{res}': endpoint '{action}' upload field '{}' must be type file",
301                        upload.field
302                    ))),
303                    None => errors.push(err(&format!(
304                        "resource '{res}': endpoint '{action}' upload field '{}' not found in schema",
305                        upload.field
306                    ))),
307                }
308
309                if !matches!(upload.storage.as_str(), "local" | "s3" | "gcs" | "azure") {
310                    errors.push(err(&format!(
311                        "resource '{res}': endpoint '{action}' upload storage '{}' is invalid",
312                        upload.storage
313                    )));
314                }
315
316                if !ep
317                    .input
318                    .as_ref()
319                    .is_some_and(|fields| fields.iter().any(|field| field == &upload.field))
320                {
321                    errors.push(err(&format!(
322                        "resource '{res}': endpoint '{action}' upload field '{}' must appear in input",
323                        upload.field
324                    )));
325                }
326
327                for (suffix, expected_types) in [
328                    ("filename", &[FieldType::String][..]),
329                    ("mime_type", &[FieldType::String][..]),
330                    ("size", &[FieldType::Integer, FieldType::Bigint][..]),
331                ] {
332                    let companion = format!("{}_{}", upload.field, suffix);
333                    if let Some(field) = rd.schema.get(&companion) {
334                        if !expected_types.contains(&field.field_type) {
335                            let expected = expected_types
336                                .iter()
337                                .map(ToString::to_string)
338                                .collect::<Vec<_>>()
339                                .join(" or ");
340                            errors.push(err(&format!(
341                                "resource '{res}': companion upload field '{companion}' must be type {expected}"
342                            )));
343                        }
344                    }
345                }
346            }
347        }
348    }
349
350    // Relation validation
351    if let Some(relations) = &rd.relations {
352        for (name, rel) in relations {
353            use shaperail_core::RelationType;
354
355            // belongs_to should have key
356            if rel.relation_type == RelationType::BelongsTo && rel.key.is_none() {
357                errors.push(err(&format!(
358                    "resource '{res}': relation '{name}' is belongs_to but has no key"
359                )));
360            }
361
362            // has_many/has_one should have foreign_key
363            if matches!(
364                rel.relation_type,
365                RelationType::HasMany | RelationType::HasOne
366            ) && rel.foreign_key.is_none()
367            {
368                errors.push(err(&format!(
369                    "resource '{res}': relation '{name}' is {} but has no foreign_key",
370                    rel.relation_type
371                )));
372            }
373
374            // belongs_to key must exist in schema
375            if let Some(key) = &rel.key {
376                if !rd.schema.contains_key(key) {
377                    errors.push(err(&format!(
378                        "resource '{res}': relation '{name}' key '{key}' not found in schema"
379                    )));
380                }
381            }
382        }
383    }
384
385    // Index validation
386    if let Some(indexes) = &rd.indexes {
387        for (i, idx) in indexes.iter().enumerate() {
388            if idx.fields.is_empty() {
389                errors.push(err(&format!("resource '{res}': index {i} has no fields")));
390            }
391            for field_name in &idx.fields {
392                if !rd.schema.contains_key(field_name) {
393                    errors.push(err(&format!(
394                        "resource '{res}': index {i} references field '{field_name}' not in schema"
395                    )));
396                }
397            }
398            if let Some(order) = &idx.order {
399                if order != "asc" && order != "desc" {
400                    errors.push(err(&format!(
401                        "resource '{res}': index {i} has invalid order '{order}', must be 'asc' or 'desc'"
402                    )));
403                }
404            }
405        }
406    }
407
408    errors
409}
410
411/// Validates a controller name — either a Rust function name or a `wasm:` prefixed path.
412fn validate_controller_name(
413    res: &str,
414    action: &str,
415    phase: &str,
416    name: &str,
417    errors: &mut Vec<ValidationError>,
418) {
419    if let Some(wasm_path) = name.strip_prefix(WASM_HOOK_PREFIX) {
420        if wasm_path.is_empty() {
421            errors.push(err(&format!(
422                "resource '{res}': endpoint '{action}' controller.{phase} has 'wasm:' prefix but no path"
423            )));
424        } else if !wasm_path.ends_with(".wasm") {
425            errors.push(err(&format!(
426                "resource '{res}': endpoint '{action}' controller.{phase} WASM path must end with '.wasm', got '{wasm_path}'"
427            )));
428        }
429    }
430}
431
432fn err(message: &str) -> ValidationError {
433    ValidationError {
434        message: message.to_string(),
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use crate::parser::parse_resource;
442
443    #[test]
444    fn valid_resource_passes() {
445        let yaml = include_str!("../../resources/users.yaml");
446        let rd = parse_resource(yaml).unwrap();
447        let errors = validate_resource(&rd);
448        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
449    }
450
451    #[test]
452    fn enum_without_values() {
453        let yaml = r#"
454resource: items
455version: 1
456schema:
457  id: { type: uuid, primary: true, generated: true }
458  status: { type: enum, required: true }
459"#;
460        let rd = parse_resource(yaml).unwrap();
461        let errors = validate_resource(&rd);
462        assert!(errors
463            .iter()
464            .any(|e| e.message.contains("type enum but has no values")));
465    }
466
467    #[test]
468    fn ref_field_not_uuid() {
469        let yaml = r#"
470resource: items
471version: 1
472schema:
473  id: { type: uuid, primary: true, generated: true }
474  org_id: { type: string, ref: organizations.id }
475"#;
476        let rd = parse_resource(yaml).unwrap();
477        let errors = validate_resource(&rd);
478        assert!(errors
479            .iter()
480            .any(|e| e.message.contains("has ref but is not type uuid")));
481    }
482
483    #[test]
484    fn missing_primary_key() {
485        let yaml = r#"
486resource: items
487version: 1
488schema:
489  name: { type: string, required: true }
490"#;
491        let rd = parse_resource(yaml).unwrap();
492        let errors = validate_resource(&rd);
493        assert!(errors
494            .iter()
495            .any(|e| e.message.contains("must have a primary key")));
496    }
497
498    #[test]
499    fn soft_delete_without_deleted_at() {
500        let yaml = r#"
501resource: items
502version: 1
503schema:
504  id: { type: uuid, primary: true, generated: true }
505  name: { type: string, required: true }
506endpoints:
507  delete:
508    method: DELETE
509    path: /items/:id
510    auth: [admin]
511    soft_delete: true
512"#;
513        let rd = parse_resource(yaml).unwrap();
514        let errors = validate_resource(&rd);
515        assert!(errors.iter().any(|e| e
516            .message
517            .contains("soft_delete but schema has no 'deleted_at'")));
518    }
519
520    #[test]
521    fn input_field_not_in_schema() {
522        let yaml = r#"
523resource: items
524version: 1
525schema:
526  id: { type: uuid, primary: true, generated: true }
527  name: { type: string, required: true }
528endpoints:
529  create:
530    method: POST
531    path: /items
532    auth: [admin]
533    input: [name, nonexistent]
534"#;
535        let rd = parse_resource(yaml).unwrap();
536        let errors = validate_resource(&rd);
537        assert!(errors.iter().any(|e| e
538            .message
539            .contains("input field 'nonexistent' not found in schema")));
540    }
541
542    #[test]
543    fn belongs_to_without_key() {
544        let yaml = r#"
545resource: items
546version: 1
547schema:
548  id: { type: uuid, primary: true, generated: true }
549relations:
550  org: { resource: organizations, type: belongs_to }
551"#;
552        let rd = parse_resource(yaml).unwrap();
553        let errors = validate_resource(&rd);
554        assert!(errors
555            .iter()
556            .any(|e| e.message.contains("belongs_to but has no key")));
557    }
558
559    #[test]
560    fn has_many_without_foreign_key() {
561        let yaml = r#"
562resource: items
563version: 1
564schema:
565  id: { type: uuid, primary: true, generated: true }
566relations:
567  orders: { resource: orders, type: has_many }
568"#;
569        let rd = parse_resource(yaml).unwrap();
570        let errors = validate_resource(&rd);
571        assert!(errors
572            .iter()
573            .any(|e| e.message.contains("has_many but has no foreign_key")));
574    }
575
576    #[test]
577    fn index_references_missing_field() {
578        let yaml = r#"
579resource: items
580version: 1
581schema:
582  id: { type: uuid, primary: true, generated: true }
583indexes:
584  - fields: [missing_field]
585"#;
586        let rd = parse_resource(yaml).unwrap();
587        let errors = validate_resource(&rd);
588        assert!(errors.iter().any(|e| e
589            .message
590            .contains("references field 'missing_field' not in schema")));
591    }
592
593    #[test]
594    fn error_message_format() {
595        let yaml = r#"
596resource: users
597version: 1
598schema:
599  id: { type: uuid, primary: true, generated: true }
600  role: { type: enum }
601"#;
602        let rd = parse_resource(yaml).unwrap();
603        let errors = validate_resource(&rd);
604        assert_eq!(
605            errors[0].message,
606            "resource 'users': field 'role' is type enum but has no values"
607        );
608    }
609
610    #[test]
611    fn wasm_controller_valid_path() {
612        let yaml = r#"
613resource: items
614version: 1
615schema:
616  id: { type: uuid, primary: true, generated: true }
617  name: { type: string, required: true }
618endpoints:
619  create:
620    method: POST
621    path: /items
622    input: [name]
623    controller: { before: "wasm:./plugins/my_validator.wasm" }
624"#;
625        let rd = parse_resource(yaml).unwrap();
626        let errors = validate_resource(&rd);
627        assert!(
628            errors.is_empty(),
629            "Expected no errors for valid WASM controller, got: {errors:?}"
630        );
631    }
632
633    #[test]
634    fn wasm_controller_missing_extension() {
635        let yaml = r#"
636resource: items
637version: 1
638schema:
639  id: { type: uuid, primary: true, generated: true }
640  name: { type: string, required: true }
641endpoints:
642  create:
643    method: POST
644    path: /items
645    input: [name]
646    controller: { before: "wasm:./plugins/my_validator" }
647"#;
648        let rd = parse_resource(yaml).unwrap();
649        let errors = validate_resource(&rd);
650        assert!(errors
651            .iter()
652            .any(|e| e.message.contains("WASM path must end with '.wasm'")));
653    }
654
655    #[test]
656    fn wasm_controller_empty_path() {
657        let yaml = r#"
658resource: items
659version: 1
660schema:
661  id: { type: uuid, primary: true, generated: true }
662  name: { type: string, required: true }
663endpoints:
664  create:
665    method: POST
666    path: /items
667    input: [name]
668    controller: { before: "wasm:" }
669"#;
670        let rd = parse_resource(yaml).unwrap();
671        let errors = validate_resource(&rd);
672        assert!(errors
673            .iter()
674            .any(|e| e.message.contains("'wasm:' prefix but no path")));
675    }
676
677    #[test]
678    fn upload_endpoint_valid_when_file_field_declared() {
679        let yaml = r#"
680resource: assets
681version: 1
682schema:
683  id: { type: uuid, primary: true, generated: true }
684  file: { type: file, required: true }
685  file_filename: { type: string }
686  file_mime_type: { type: string }
687  file_size: { type: bigint }
688  updated_at: { type: timestamp, generated: true }
689endpoints:
690  upload:
691    method: POST
692    path: /assets/upload
693    input: [file]
694    upload:
695      field: file
696      storage: local
697      max_size: 5mb
698"#;
699        let rd = parse_resource(yaml).unwrap();
700        let errors = validate_resource(&rd);
701        assert!(
702            errors.is_empty(),
703            "Expected valid upload resource, got {errors:?}"
704        );
705    }
706
707    #[test]
708    fn upload_endpoint_requires_file_field() {
709        let yaml = r#"
710resource: assets
711version: 1
712schema:
713  id: { type: uuid, primary: true, generated: true }
714  file_path: { type: string, required: true }
715endpoints:
716  upload:
717    method: POST
718    path: /assets/upload
719    input: [file_path]
720    upload:
721      field: file_path
722      storage: local
723      max_size: 5mb
724"#;
725        let rd = parse_resource(yaml).unwrap();
726        let errors = validate_resource(&rd);
727        assert!(errors.iter().any(|e| e
728            .message
729            .contains("upload field 'file_path' must be type file")));
730    }
731
732    #[test]
733    fn tenant_key_valid_uuid_field() {
734        let yaml = r#"
735resource: projects
736version: 1
737tenant_key: org_id
738schema:
739  id: { type: uuid, primary: true, generated: true }
740  org_id: { type: uuid, ref: organizations.id, required: true }
741  name: { type: string, required: true }
742"#;
743        let rd = parse_resource(yaml).unwrap();
744        let errors = validate_resource(&rd);
745        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
746    }
747
748    #[test]
749    fn tenant_key_missing_field() {
750        let yaml = r#"
751resource: projects
752version: 1
753tenant_key: org_id
754schema:
755  id: { type: uuid, primary: true, generated: true }
756  name: { type: string, required: true }
757"#;
758        let rd = parse_resource(yaml).unwrap();
759        let errors = validate_resource(&rd);
760        assert!(errors.iter().any(|e| e
761            .message
762            .contains("tenant_key 'org_id' not found in schema")));
763    }
764
765    #[test]
766    fn transient_field_valid() {
767        let yaml = r#"
768resource: users
769version: 1
770schema:
771  id:            { type: uuid, primary: true, generated: true }
772  password:      { type: string, transient: true, min: 12, required: true }
773  password_hash: { type: string, required: true }
774endpoints:
775  create:
776    method: POST
777    path: /users
778    input: [password]
779    controller: { before: hash_password }
780"#;
781        let rd = parse_resource(yaml).unwrap();
782        let errors = validate_resource(&rd);
783        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
784    }
785
786    #[test]
787    fn transient_field_dead_when_not_in_input() {
788        let yaml = r#"
789resource: users
790version: 1
791schema:
792  id:       { type: uuid, primary: true, generated: true }
793  password: { type: string, transient: true, min: 12 }
794endpoints:
795  create:
796    method: POST
797    path: /users
798    input: []
799"#;
800        let rd = parse_resource(yaml).unwrap();
801        let errors = validate_resource(&rd);
802        assert!(errors.iter().any(|e| e
803            .message
804            .contains("transient field 'password' is not declared in any endpoint's input")));
805    }
806
807    #[test]
808    fn transient_field_rejects_primary() {
809        let yaml = r#"
810resource: users
811version: 1
812schema:
813  bad: { type: uuid, transient: true, primary: true }
814"#;
815        let rd = parse_resource(yaml).unwrap();
816        let errors = validate_resource(&rd);
817        assert!(errors
818            .iter()
819            .any(|e| e.message.contains("cannot be both transient and primary")));
820    }
821
822    #[test]
823    fn transient_field_rejects_generated() {
824        let yaml = r#"
825resource: users
826version: 1
827schema:
828  id:  { type: uuid, primary: true, generated: true }
829  bad: { type: timestamp, transient: true, generated: true }
830endpoints:
831  create:
832    method: POST
833    path: /users
834    input: [bad]
835"#;
836        let rd = parse_resource(yaml).unwrap();
837        let errors = validate_resource(&rd);
838        assert!(errors
839            .iter()
840            .any(|e| e.message.contains("cannot be both transient and generated")));
841    }
842
843    #[test]
844    fn transient_field_rejects_ref() {
845        let yaml = r#"
846resource: users
847version: 1
848schema:
849  id:  { type: uuid, primary: true, generated: true }
850  bad: { type: uuid, transient: true, ref: orgs.id }
851endpoints:
852  create:
853    method: POST
854    path: /users
855    input: [bad]
856"#;
857        let rd = parse_resource(yaml).unwrap();
858        let errors = validate_resource(&rd);
859        assert!(errors.iter().any(|e| e
860            .message
861            .contains("cannot be both transient and have a ref")));
862    }
863
864    #[test]
865    fn transient_field_rejects_unique() {
866        let yaml = r#"
867resource: users
868version: 1
869schema:
870  id:  { type: uuid, primary: true, generated: true }
871  bad: { type: string, transient: true, unique: true }
872endpoints:
873  create:
874    method: POST
875    path: /users
876    input: [bad]
877"#;
878        let rd = parse_resource(yaml).unwrap();
879        let errors = validate_resource(&rd);
880        assert!(errors
881            .iter()
882            .any(|e| e.message.contains("cannot be both transient and unique")));
883    }
884
885    #[test]
886    fn transient_field_rejects_default() {
887        let yaml = r#"
888resource: users
889version: 1
890schema:
891  id:  { type: uuid, primary: true, generated: true }
892  bad: { type: string, transient: true, default: "x" }
893endpoints:
894  create:
895    method: POST
896    path: /users
897    input: [bad]
898"#;
899        let rd = parse_resource(yaml).unwrap();
900        let errors = validate_resource(&rd);
901        assert!(errors.iter().any(|e| e
902            .message
903            .contains("cannot be both transient and have a default")));
904    }
905
906    #[test]
907    fn tenant_key_wrong_type() {
908        let yaml = r#"
909resource: projects
910version: 1
911tenant_key: org_name
912schema:
913  id: { type: uuid, primary: true, generated: true }
914  org_name: { type: string, required: true }
915"#;
916        let rd = parse_resource(yaml).unwrap();
917        let errors = validate_resource(&rd);
918        assert!(errors.iter().any(|e| e
919            .message
920            .contains("tenant_key 'org_name' must reference a uuid field")));
921    }
922}