Skip to main content

shaperail_core/
schema.rs

1use crate::FieldType;
2use serde::de::{self, Deserializer, MapAccess, Visitor};
3use serde::{Deserialize, Serialize};
4use std::fmt;
5
6/// Element specification for `type: array` fields.
7///
8/// Accepts two YAML shapes — both are equivalent for fields that need no element
9/// constraints:
10///
11/// ```yaml
12/// items: string                             # bare-name shorthand
13/// items: { type: string }                   # equivalent map form
14/// items: { type: string, min: 3, max: 3 }   # element-level constraints
15/// items: { type: enum, values: [a, b] }     # element allowlist
16/// items: { type: uuid, ref: organizations.id }  # FK array (Postgres only)
17/// ```
18#[derive(Debug, Clone, PartialEq, Serialize)]
19pub struct ItemsSpec {
20    #[serde(rename = "type")]
21    pub field_type: FieldType,
22
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub min: Option<serde_json::Value>,
25
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub max: Option<serde_json::Value>,
28
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub format: Option<String>,
31
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub values: Option<Vec<String>>,
34
35    #[serde(default, skip_serializing_if = "Option::is_none", rename = "ref")]
36    pub reference: Option<String>,
37}
38
39impl ItemsSpec {
40    /// Constructs a bare `ItemsSpec` with only `field_type` set.
41    pub fn of(field_type: FieldType) -> Self {
42        Self {
43            field_type,
44            min: None,
45            max: None,
46            format: None,
47            values: None,
48            reference: None,
49        }
50    }
51}
52
53impl<'de> Deserialize<'de> for ItemsSpec {
54    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
55        struct ItemsSpecVisitor;
56
57        impl<'de> Visitor<'de> for ItemsSpecVisitor {
58            type Value = ItemsSpec;
59
60            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
61                f.write_str("a type name (e.g. \"string\") or a constraint map with `type:`")
62            }
63
64            fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
65                let field_type = FieldType::deserialize(de::value::StrDeserializer::new(v))?;
66                Ok(ItemsSpec::of(field_type))
67            }
68
69            fn visit_map<M: MapAccess<'de>>(self, map: M) -> Result<Self::Value, M::Error> {
70                #[derive(Deserialize)]
71                #[serde(deny_unknown_fields)]
72                struct Inner {
73                    #[serde(rename = "type")]
74                    field_type: FieldType,
75                    #[serde(default)]
76                    min: Option<serde_json::Value>,
77                    #[serde(default)]
78                    max: Option<serde_json::Value>,
79                    #[serde(default)]
80                    format: Option<String>,
81                    #[serde(default)]
82                    values: Option<Vec<String>>,
83                    #[serde(default, rename = "ref")]
84                    reference: Option<String>,
85                }
86                let inner = Inner::deserialize(de::value::MapAccessDeserializer::new(map))?;
87                Ok(ItemsSpec {
88                    field_type: inner.field_type,
89                    min: inner.min,
90                    max: inner.max,
91                    format: inner.format,
92                    values: inner.values,
93                    reference: inner.reference,
94                })
95            }
96        }
97
98        deserializer.deserialize_any(ItemsSpecVisitor)
99    }
100}
101
102/// Definition of a single field in a resource schema.
103///
104/// Matches the inline YAML format:
105/// ```yaml
106/// email: { type: string, format: email, unique: true, required: true }
107/// ```
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109#[serde(deny_unknown_fields)]
110pub struct FieldSchema {
111    /// The data type of this field.
112    #[serde(rename = "type")]
113    pub field_type: FieldType,
114
115    /// Whether this field is the primary key.
116    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
117    pub primary: bool,
118
119    /// Whether this field is auto-generated (e.g., uuid v4, timestamps).
120    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
121    pub generated: bool,
122
123    /// Whether this field is required (NOT NULL + validated on input).
124    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
125    pub required: bool,
126
127    /// Whether this field has a unique constraint.
128    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
129    pub unique: bool,
130
131    /// Whether this field is explicitly nullable.
132    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
133    pub nullable: bool,
134
135    /// Foreign key reference in `resource.field` format.
136    #[serde(default, skip_serializing_if = "Option::is_none", rename = "ref")]
137    pub reference: Option<String>,
138
139    /// Minimum value (number) or length (string).
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub min: Option<serde_json::Value>,
142
143    /// Maximum value (number) or length (string).
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub max: Option<serde_json::Value>,
146
147    /// String format validation (email, url, uuid).
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub format: Option<String>,
150
151    /// Allowed values for enum-type fields.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub values: Option<Vec<String>>,
154
155    /// Default value for this field.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub default: Option<serde_json::Value>,
158
159    /// Whether this field contains sensitive data (redacted in logs).
160    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
161    pub sensitive: bool,
162
163    /// Whether this field is included in full-text search.
164    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
165    pub search: bool,
166
167    /// Element type for array fields.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub items: Option<ItemsSpec>,
170
171    /// Input-only field: validated and exposed to the before-controller in `ctx.input`,
172    /// but never persisted (no migration column, no SQL reference) and never returned
173    /// in API responses. Stripped from `ctx.input` after the before-controller runs.
174    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
175    pub transient: bool,
176}
177
178impl FieldSchema {
179    /// Returns true if this field is stored in the database. Transient fields exist only
180    /// at the API boundary and are never persisted.
181    pub fn is_persisted(&self) -> bool {
182        !self.transient
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn field_schema_minimal() {
192        let json = r#"{"type": "string"}"#;
193        let fs: FieldSchema = serde_json::from_str(json).unwrap();
194        assert_eq!(fs.field_type, FieldType::String);
195        assert!(!fs.primary);
196        assert!(!fs.generated);
197        assert!(!fs.required);
198        assert!(!fs.unique);
199        assert!(!fs.nullable);
200        assert!(fs.reference.is_none());
201        assert!(fs.min.is_none());
202        assert!(fs.max.is_none());
203        assert!(fs.format.is_none());
204        assert!(fs.values.is_none());
205        assert!(fs.default.is_none());
206        assert!(!fs.sensitive);
207        assert!(!fs.search);
208        assert!(fs.items.is_none());
209        assert!(!fs.transient);
210    }
211
212    #[test]
213    fn field_schema_full() {
214        let json = r#"{
215            "type": "enum",
216            "primary": false,
217            "generated": false,
218            "required": true,
219            "unique": false,
220            "nullable": false,
221            "values": ["admin", "member", "viewer"],
222            "default": "member"
223        }"#;
224        let fs: FieldSchema = serde_json::from_str(json).unwrap();
225        assert_eq!(fs.field_type, FieldType::Enum);
226        assert!(fs.required);
227        assert_eq!(fs.values.as_ref().unwrap().len(), 3);
228        assert_eq!(fs.default.as_ref().unwrap(), "member");
229    }
230
231    #[test]
232    fn field_schema_with_ref() {
233        let json = r#"{"type": "uuid", "ref": "organizations.id", "required": true}"#;
234        let fs: FieldSchema = serde_json::from_str(json).unwrap();
235        assert_eq!(fs.reference.as_deref(), Some("organizations.id"));
236    }
237
238    #[test]
239    fn field_schema_serde_roundtrip() {
240        let fs = FieldSchema {
241            field_type: FieldType::String,
242            primary: false,
243            generated: false,
244            required: true,
245            unique: true,
246            nullable: false,
247            reference: None,
248            min: Some(serde_json::json!(1)),
249            max: Some(serde_json::json!(200)),
250            format: Some("email".to_string()),
251            values: None,
252            default: None,
253            sensitive: false,
254            search: true,
255            items: None,
256            transient: false,
257        };
258        let json = serde_json::to_string(&fs).unwrap();
259        let back: FieldSchema = serde_json::from_str(&json).unwrap();
260        assert_eq!(fs, back);
261    }
262
263    #[test]
264    fn items_spec_bare_string_form() {
265        let yaml = r#"type: array
266items: string"#;
267        let fs: FieldSchema = serde_yaml::from_str(yaml).unwrap();
268        let items = fs.items.expect("items present");
269        assert_eq!(items.field_type, FieldType::String);
270        assert!(items.min.is_none());
271        assert!(items.max.is_none());
272        assert!(items.values.is_none());
273        assert!(items.reference.is_none());
274        assert!(items.format.is_none());
275    }
276
277    #[test]
278    fn items_spec_full_map_form() {
279        let yaml = r#"type: array
280items: { type: string, min: 3, max: 3 }"#;
281        let fs: FieldSchema = serde_yaml::from_str(yaml).unwrap();
282        let items = fs.items.expect("items present");
283        assert_eq!(items.field_type, FieldType::String);
284        assert_eq!(items.min, Some(serde_json::json!(3)));
285        assert_eq!(items.max, Some(serde_json::json!(3)));
286    }
287
288    #[test]
289    fn items_spec_enum_form() {
290        let yaml = r#"type: array
291items: { type: enum, values: [a, b, c] }"#;
292        let fs: FieldSchema = serde_yaml::from_str(yaml).unwrap();
293        let items = fs.items.expect("items present");
294        assert_eq!(items.field_type, FieldType::Enum);
295        assert_eq!(
296            items.values.as_deref(),
297            Some(["a".to_string(), "b".to_string(), "c".to_string()].as_slice())
298        );
299    }
300
301    #[test]
302    fn items_spec_uuid_ref_form() {
303        let yaml = r#"type: array
304items: { type: uuid, ref: organizations.id }"#;
305        let fs: FieldSchema = serde_yaml::from_str(yaml).unwrap();
306        let items = fs.items.expect("items present");
307        assert_eq!(items.field_type, FieldType::Uuid);
308        assert_eq!(items.reference.as_deref(), Some("organizations.id"));
309    }
310
311    #[test]
312    fn items_spec_unknown_field_rejected() {
313        let yaml = r#"type: array
314items: { type: string, unknown_key: 1 }"#;
315        let result: Result<FieldSchema, _> = serde_yaml::from_str(yaml);
316        assert!(
317            result.is_err(),
318            "unknown field on ItemsSpec must be rejected"
319        );
320    }
321}