Skip to main content

ferro_projections/
field.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4/// Abstract data type categories for service fields.
5///
6/// Represents structural types independent of database storage details.
7/// Maps from database column types at introspection time.
8#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
9#[serde(rename_all = "snake_case")]
10pub enum DataType {
11    String,
12    Integer,
13    Float,
14    Boolean,
15    DateTime,
16    Date,
17    Json,
18    Binary,
19    Uuid,
20    Enum,
21}
22
23/// Semantic meaning of a field, driving rendering and behavior decisions.
24///
25/// Known variants map to specific UI treatments (e.g., `Money` formats as currency,
26/// `Status` renders as a badge). The `Custom` fallback captures domain-specific
27/// meanings not covered by built-in variants.
28///
29/// `Custom(String)` must remain the last variant for correct serde deserialization.
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
31#[serde(rename_all = "snake_case")]
32#[schemars(
33    description = "Semantic field meaning. Known variants: identifier, foreign_key, entity_name, email, phone, url, image_url, money, percentage, quantity, status, category, boolean, free_text, created_at, updated_at, date_time, sensitive. Any other string is a custom domain-specific meaning."
34)]
35pub enum FieldMeaning {
36    Identifier,
37    ForeignKey,
38    EntityName,
39    Email,
40    Phone,
41    Url,
42    ImageUrl,
43    Money,
44    Percentage,
45    Quantity,
46    Status,
47    Category,
48    Boolean,
49    FreeText,
50    CreatedAt,
51    UpdatedAt,
52    DateTime,
53    Sensitive,
54    #[serde(untagged)]
55    Custom(String),
56}
57
58/// A field definition within a service projection.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
60pub struct FieldDef {
61    pub name: String,
62    pub data_type: DataType,
63    pub meaning: FieldMeaning,
64    #[serde(default = "default_true")]
65    pub required: bool,
66    #[serde(default)]
67    pub is_list: bool,
68    #[serde(default = "default_true")]
69    pub readable: bool,
70    #[serde(default = "default_true")]
71    pub writable: bool,
72}
73
74fn default_true() -> bool {
75    true
76}
77
78impl DataType {
79    /// Infers a DataType from a Rust/SeaORM column type string.
80    ///
81    /// Strips `Option<>` wrappers before matching. Falls back to `String`
82    /// for unrecognized types.
83    pub fn from_column_type(type_str: &str) -> Self {
84        let inner = if let Some(stripped) = type_str
85            .strip_prefix("Option<")
86            .and_then(|s| s.strip_suffix('>'))
87        {
88            stripped
89        } else {
90            type_str
91        };
92
93        match inner {
94            "i32" | "i64" | "u32" | "u64" | "i8" | "i16" | "u8" | "u16" => Self::Integer,
95            "f32" | "f64" => Self::Float,
96            "bool" => Self::Boolean,
97            "Uuid" | "uuid::Uuid" => Self::Uuid,
98            s if s.contains("Decimal") => Self::Float,
99            s if s.starts_with("DateTime") || s.contains("chrono::") => Self::DateTime,
100            s if s.starts_with("NaiveDate") => Self::Date,
101            "Vec<u8>" => Self::Binary,
102            s if s.contains("Json") || s.contains("serde_json") => Self::Json,
103            _ => Self::String,
104        }
105    }
106}
107
108/// Infers a [`FieldMeaning`] from a field name using common naming conventions.
109///
110/// Applies seven inference rules based on patterns found across the codebase:
111/// - Exact matches: `id`, `email`, `created_at`, `updated_at`
112/// - Suffix: `_id` (foreign key), `_at` (datetime)
113/// - Prefix: `is_`, `has_` (boolean)
114/// - Contains: `password`, `secret`, `token`, `api_key`, `hashed_key` (sensitive)
115/// - Fallback: `Custom(field_name)`
116pub fn infer_meaning(field_name: &str) -> FieldMeaning {
117    // Exact matches first
118    match field_name {
119        "id" => return FieldMeaning::Identifier,
120        "email" => return FieldMeaning::Email,
121        "created_at" => return FieldMeaning::CreatedAt,
122        "updated_at" => return FieldMeaning::UpdatedAt,
123        _ => {}
124    }
125
126    // Suffix patterns
127    if field_name.ends_with("_id") {
128        return FieldMeaning::ForeignKey;
129    }
130    if field_name.ends_with("_at") {
131        return FieldMeaning::DateTime;
132    }
133
134    // Prefix patterns
135    if field_name.starts_with("is_") || field_name.starts_with("has_") {
136        return FieldMeaning::Boolean;
137    }
138
139    // Sensitive field patterns
140    const SENSITIVE: &[&str] = &["password", "secret", "token", "api_key", "hashed_key"];
141    if SENSITIVE.iter().any(|s| field_name.contains(s)) {
142        return FieldMeaning::Sensitive;
143    }
144
145    FieldMeaning::Custom(field_name.to_string())
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn from_column_type_mappings() {
154        assert_eq!(DataType::from_column_type("i32"), DataType::Integer);
155        assert_eq!(DataType::from_column_type("i64"), DataType::Integer);
156        assert_eq!(DataType::from_column_type("u32"), DataType::Integer);
157        assert_eq!(DataType::from_column_type("u64"), DataType::Integer);
158        assert_eq!(DataType::from_column_type("i8"), DataType::Integer);
159        assert_eq!(DataType::from_column_type("i16"), DataType::Integer);
160        assert_eq!(DataType::from_column_type("u8"), DataType::Integer);
161        assert_eq!(DataType::from_column_type("u16"), DataType::Integer);
162        assert_eq!(DataType::from_column_type("f32"), DataType::Float);
163        assert_eq!(DataType::from_column_type("f64"), DataType::Float);
164        assert_eq!(DataType::from_column_type("bool"), DataType::Boolean);
165        assert_eq!(DataType::from_column_type("String"), DataType::String);
166        assert_eq!(DataType::from_column_type("Uuid"), DataType::Uuid);
167        assert_eq!(DataType::from_column_type("uuid::Uuid"), DataType::Uuid);
168        assert_eq!(
169            DataType::from_column_type("DateTime<Utc>"),
170            DataType::DateTime
171        );
172        assert_eq!(
173            DataType::from_column_type("chrono::DateTime<chrono::Utc>"),
174            DataType::DateTime
175        );
176        assert_eq!(DataType::from_column_type("NaiveDate"), DataType::Date);
177        assert_eq!(DataType::from_column_type("Vec<u8>"), DataType::Binary);
178        assert_eq!(
179            DataType::from_column_type("serde_json::Value"),
180            DataType::Json
181        );
182        assert_eq!(DataType::from_column_type("Json"), DataType::Json);
183        assert_eq!(DataType::from_column_type("Decimal"), DataType::Float);
184        assert_eq!(
185            DataType::from_column_type("UnknownCustomType"),
186            DataType::String
187        );
188    }
189
190    #[test]
191    fn from_column_type_option_stripping() {
192        assert_eq!(
193            DataType::from_column_type("Option<String>"),
194            DataType::String
195        );
196        assert_eq!(DataType::from_column_type("Option<i32>"), DataType::Integer);
197        assert_eq!(
198            DataType::from_column_type("Option<DateTime<Utc>>"),
199            DataType::DateTime
200        );
201    }
202
203    #[test]
204    fn data_type_is_copy() {
205        let dt = DataType::Float;
206        let dt2 = dt;
207        assert_eq!(dt, dt2);
208    }
209
210    #[test]
211    fn data_type_serde_round_trip() {
212        for dt in [
213            DataType::String,
214            DataType::Integer,
215            DataType::Float,
216            DataType::Boolean,
217            DataType::DateTime,
218            DataType::Date,
219            DataType::Json,
220            DataType::Binary,
221            DataType::Uuid,
222            DataType::Enum,
223        ] {
224            let json = serde_json::to_string(&dt).unwrap();
225            let parsed: DataType = serde_json::from_str(&json).unwrap();
226            assert_eq!(dt, parsed);
227        }
228    }
229
230    #[test]
231    fn field_meaning_known_variants_serde_round_trip() {
232        let known = [
233            FieldMeaning::Identifier,
234            FieldMeaning::ForeignKey,
235            FieldMeaning::EntityName,
236            FieldMeaning::Email,
237            FieldMeaning::Phone,
238            FieldMeaning::Url,
239            FieldMeaning::ImageUrl,
240            FieldMeaning::Money,
241            FieldMeaning::Percentage,
242            FieldMeaning::Quantity,
243            FieldMeaning::Status,
244            FieldMeaning::Category,
245            FieldMeaning::Boolean,
246            FieldMeaning::FreeText,
247            FieldMeaning::CreatedAt,
248            FieldMeaning::UpdatedAt,
249            FieldMeaning::DateTime,
250            FieldMeaning::Sensitive,
251        ];
252        for meaning in known {
253            let json = serde_json::to_string(&meaning).unwrap();
254            let parsed: FieldMeaning = serde_json::from_str(&json).unwrap();
255            assert_eq!(meaning, parsed);
256        }
257    }
258
259    #[test]
260    fn field_meaning_custom_fallback() {
261        let parsed: FieldMeaning = serde_json::from_str(r#""tax_rate""#).unwrap();
262        assert_eq!(parsed, FieldMeaning::Custom("tax_rate".to_string()));
263    }
264
265    #[test]
266    fn field_meaning_custom_round_trip() {
267        let custom = FieldMeaning::Custom("my_thing".into());
268        let json = serde_json::to_string(&custom).unwrap();
269        let parsed: FieldMeaning = serde_json::from_str(&json).unwrap();
270        assert_eq!(parsed, FieldMeaning::Custom("my_thing".into()));
271    }
272
273    #[test]
274    fn field_meaning_known_not_custom() {
275        // Deserializing "money" must match Money variant, not Custom("money")
276        let parsed: FieldMeaning = serde_json::from_str(r#""money""#).unwrap();
277        assert_eq!(parsed, FieldMeaning::Money);
278        assert_ne!(parsed, FieldMeaning::Custom("money".into()));
279    }
280
281    #[test]
282    fn field_meaning_money_serializes_to_snake_case() {
283        let json = serde_json::to_string(&FieldMeaning::Money).unwrap();
284        assert_eq!(json, r#""money""#);
285    }
286
287    #[test]
288    fn field_meaning_foreign_key_serializes_to_snake_case() {
289        let json = serde_json::to_string(&FieldMeaning::ForeignKey).unwrap();
290        assert_eq!(json, r#""foreign_key""#);
291    }
292
293    #[test]
294    fn field_def_serde_round_trip() {
295        let field = FieldDef {
296            name: "total".to_string(),
297            data_type: DataType::Float,
298            meaning: FieldMeaning::Money,
299            required: true,
300            is_list: false,
301            readable: true,
302            writable: true,
303        };
304        let json = serde_json::to_string(&field).unwrap();
305        let parsed: FieldDef = serde_json::from_str(&json).unwrap();
306        assert_eq!(field, parsed);
307    }
308
309    #[test]
310    fn field_def_defaults() {
311        // Verify that omitting required/is_list/readable/writable uses correct defaults
312        let json = r#"{"name":"total","data_type":"float","meaning":"money"}"#;
313        let parsed: FieldDef = serde_json::from_str(json).unwrap();
314        assert!(parsed.required);
315        assert!(!parsed.is_list);
316        assert!(parsed.readable);
317        assert!(parsed.writable);
318    }
319
320    #[test]
321    fn field_def_read_only() {
322        let json = r#"{"name":"id","data_type":"integer","meaning":"identifier","readable":true,"writable":false}"#;
323        let parsed: FieldDef = serde_json::from_str(json).unwrap();
324        assert!(parsed.readable);
325        assert!(!parsed.writable);
326    }
327
328    #[test]
329    fn field_def_write_only() {
330        let json = r#"{"name":"password","data_type":"string","meaning":"sensitive","readable":false,"writable":true}"#;
331        let parsed: FieldDef = serde_json::from_str(json).unwrap();
332        assert!(!parsed.readable);
333        assert!(parsed.writable);
334    }
335
336    #[test]
337    fn infer_meaning_exact_matches() {
338        assert_eq!(infer_meaning("id"), FieldMeaning::Identifier);
339        assert_eq!(infer_meaning("email"), FieldMeaning::Email);
340        assert_eq!(infer_meaning("created_at"), FieldMeaning::CreatedAt);
341        assert_eq!(infer_meaning("updated_at"), FieldMeaning::UpdatedAt);
342    }
343
344    #[test]
345    fn infer_meaning_suffix_patterns() {
346        assert_eq!(infer_meaning("user_id"), FieldMeaning::ForeignKey);
347        assert_eq!(infer_meaning("order_id"), FieldMeaning::ForeignKey);
348        assert_eq!(infer_meaning("deleted_at"), FieldMeaning::DateTime);
349        assert_eq!(infer_meaning("expires_at"), FieldMeaning::DateTime);
350    }
351
352    #[test]
353    fn infer_meaning_prefix_patterns() {
354        assert_eq!(infer_meaning("is_active"), FieldMeaning::Boolean);
355        assert_eq!(infer_meaning("has_premium"), FieldMeaning::Boolean);
356    }
357
358    #[test]
359    fn infer_meaning_sensitive_patterns() {
360        assert_eq!(infer_meaning("password"), FieldMeaning::Sensitive);
361        assert_eq!(infer_meaning("hashed_password"), FieldMeaning::Sensitive);
362        assert_eq!(infer_meaning("secret"), FieldMeaning::Sensitive);
363        assert_eq!(infer_meaning("api_key"), FieldMeaning::Sensitive);
364        assert_eq!(infer_meaning("hashed_key"), FieldMeaning::Sensitive);
365        assert_eq!(infer_meaning("remember_token"), FieldMeaning::Sensitive);
366    }
367
368    #[test]
369    fn infer_meaning_fallback_to_custom() {
370        assert_eq!(
371            infer_meaning("title"),
372            FieldMeaning::Custom("title".to_string())
373        );
374        assert_eq!(
375            infer_meaning("description"),
376            FieldMeaning::Custom("description".to_string())
377        );
378    }
379
380    #[test]
381    fn data_type_json_schema() {
382        let schema = schemars::schema_for!(DataType);
383        let value = schema.to_value();
384        let enum_values = value
385            .get("enum")
386            .expect("DataType schema must have enum key");
387        let arr = enum_values.as_array().unwrap();
388        let strings: Vec<&str> = arr.iter().map(|v| v.as_str().unwrap()).collect();
389        assert!(strings.contains(&"string"));
390        assert!(strings.contains(&"integer"));
391        assert!(strings.contains(&"float"));
392        assert!(strings.contains(&"boolean"));
393        assert!(strings.contains(&"date_time"));
394        assert!(strings.contains(&"uuid"));
395    }
396
397    #[test]
398    fn field_meaning_json_schema_has_description() {
399        let schema = schemars::schema_for!(FieldMeaning);
400        let value = schema.to_value();
401        let desc = value
402            .get("description")
403            .expect("FieldMeaning schema must have description");
404        let desc_str = desc.as_str().unwrap();
405        assert!(
406            desc_str.contains("Known variants"),
407            "description should document known variants, got: {desc_str}"
408        );
409    }
410
411    #[test]
412    fn field_def_json_schema() {
413        let schema = schemars::schema_for!(FieldDef);
414        let value = schema.to_value();
415        let props = value
416            .get("properties")
417            .expect("FieldDef schema must have properties");
418        let obj = props.as_object().unwrap();
419        assert!(obj.contains_key("name"), "missing 'name' property");
420        assert!(
421            obj.contains_key("data_type"),
422            "missing 'data_type' property"
423        );
424        assert!(obj.contains_key("meaning"), "missing 'meaning' property");
425    }
426
427    // -- Additional Phase 86-02 tests: readable/writable defaults --
428
429    #[test]
430    fn field_def_readable_writable_defaults_from_json() {
431        // Both readable and writable default to true when omitted — backward compatibility
432        let json = r#"{"name":"title","data_type":"string","meaning":"entity_name"}"#;
433        let parsed: FieldDef = serde_json::from_str(json).unwrap();
434        assert!(parsed.readable);
435        assert!(parsed.writable);
436    }
437
438    #[test]
439    fn field_def_readable_false_writable_true_round_trip() {
440        let field = FieldDef {
441            name: "password".to_string(),
442            data_type: DataType::String,
443            meaning: FieldMeaning::Sensitive,
444            required: true,
445            is_list: false,
446            readable: false,
447            writable: true,
448        };
449        let json = serde_json::to_string(&field).unwrap();
450        let parsed: FieldDef = serde_json::from_str(&json).unwrap();
451        assert_eq!(field, parsed);
452        assert!(!parsed.readable);
453        assert!(parsed.writable);
454    }
455
456    #[test]
457    fn field_def_readable_true_writable_false_round_trip() {
458        let field = FieldDef {
459            name: "id".to_string(),
460            data_type: DataType::Integer,
461            meaning: FieldMeaning::Identifier,
462            required: true,
463            is_list: false,
464            readable: true,
465            writable: false,
466        };
467        let json = serde_json::to_string(&field).unwrap();
468        let parsed: FieldDef = serde_json::from_str(&json).unwrap();
469        assert_eq!(field, parsed);
470        assert!(parsed.readable);
471        assert!(!parsed.writable);
472    }
473
474    #[test]
475    fn field_def_readable_false_writable_false_round_trip() {
476        let field = FieldDef {
477            name: "internal_hash".to_string(),
478            data_type: DataType::String,
479            meaning: FieldMeaning::Sensitive,
480            required: true,
481            is_list: false,
482            readable: false,
483            writable: false,
484        };
485        let json = serde_json::to_string(&field).unwrap();
486        let parsed: FieldDef = serde_json::from_str(&json).unwrap();
487        assert_eq!(field, parsed);
488        assert!(!parsed.readable);
489        assert!(!parsed.writable);
490    }
491}