Skip to main content

hyle/
forma.rs

1use indexmap::IndexMap;
2use serde::{Deserialize, Serialize};
3use serde_json::Value as JsonValue;
4
5use crate::field::{Field, ShapeField};
6use crate::query::Query;
7
8/// How a forma field type is described in a runtime forma definition.
9/// Strings correspond to primitive names or entity names (for references).
10#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
11#[serde(untagged)]
12pub enum FormaFieldType {
13    /// "string" | "number" | "boolean" | "file" | "<entity-name>"
14    Named(String),
15    Array {
16        array: Box<FormaFieldType>,
17    },
18    Shape {
19        shape: Vec<FormaField>,
20    },
21}
22
23impl Default for FormaFieldType {
24    fn default() -> Self {
25        FormaFieldType::Named("string".to_owned())
26    }
27}
28
29#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct FormaField {
32    pub name: String,
33    pub label: String,
34    #[serde(default)]
35    pub field_type: FormaFieldType,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub detail_type: Option<FormaFieldType>,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub form_type: Option<FormaFieldType>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub column_type: Option<FormaFieldType>,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub fixed_value: Option<JsonValue>,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub rule: Option<String>,
46}
47
48#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
49#[serde(rename_all = "camelCase")]
50pub struct Forma {
51    #[serde(default)]
52    pub fields: Vec<FormaField>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub detail: Option<Vec<String>>,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub form: Option<Vec<String>>,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub column: Option<Vec<String>>,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub filters: Option<Vec<Vec<String>>>,
61}
62
63/// Which rendering context to use when deriving a query from a forma.
64#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub enum FormaContext {
67    Detail,
68    Form,
69    Column,
70}
71
72impl Default for FormaContext {
73    fn default() -> Self {
74        FormaContext::Column
75    }
76}
77
78/// Derive a `Query` from a `Forma` definition.
79///
80/// - `context` selects which field subset list to use (`detail`, `form`, or `column`).
81/// - `id` is placed in `where.id` when provided (single-record fetch).
82pub fn forma_to_query(
83    forma: &Forma,
84    table_name: &str,
85    context: &FormaContext,
86    id: Option<&JsonValue>,
87) -> Query {
88    // Pick which field names are active for this context
89    let context_names: Option<&Vec<String>> = match context {
90        FormaContext::Detail => forma.detail.as_ref(),
91        FormaContext::Form => forma.form.as_ref(),
92        FormaContext::Column => forma.column.as_ref(),
93    };
94
95    let active_fields: Vec<&FormaField> = if let Some(names) = context_names {
96        names
97            .iter()
98            .filter_map(|n| forma.fields.iter().find(|f| &f.name == n))
99            .collect()
100    } else {
101        forma.fields.iter().collect()
102    };
103
104    let select: Vec<String> = active_fields.iter().map(|f| f.name.clone()).collect();
105
106    let mut where_ = IndexMap::new();
107    if let Some(id_val) = id {
108        where_.insert("id".to_owned(), id_val.clone());
109    }
110
111    Query {
112        model: table_name.to_owned(),
113        select,
114        where_,
115        filters: forma.filters.clone().unwrap_or_default(),
116        page: None,
117        per_page: None,
118        sort: None,
119        method: id.map(|_| "one".to_owned()),
120    }
121}
122
123/// Map a `FormaField` to a hyle `Field`.
124#[allow(dead_code)]
125pub(crate) fn forma_field_to_field(sf: &FormaField, context: &FormaContext) -> Field {
126    // Pick context-specific type override if present
127    let ftype = match context {
128        FormaContext::Detail => sf.detail_type.as_ref().unwrap_or(&sf.field_type),
129        FormaContext::Form => sf.form_type.as_ref().unwrap_or(&sf.field_type),
130        FormaContext::Column => sf.column_type.as_ref().unwrap_or(&sf.field_type),
131    };
132
133    let mut field = forma_field_type_to_field(&sf.label, ftype);
134
135    if let Some(fixed) = &sf.fixed_value {
136        field.options.fixed_value = Some(fixed.clone());
137    }
138    if let Some(rule) = &sf.rule {
139        field.options.rule = Some(rule.clone());
140    }
141
142    field
143}
144
145#[allow(dead_code)]
146fn forma_field_type_to_field(label: &str, ftype: &FormaFieldType) -> Field {
147    match ftype {
148        FormaFieldType::Named(name) => match name.as_str() {
149            "string" => Field::string(label),
150            "number" => Field::number(label),
151            "boolean" => Field::boolean(label),
152            "file" => Field::file(label),
153            entity => Field::reference(label, entity),
154        },
155        FormaFieldType::Array { array } => {
156            let item_field = forma_field_type_to_field(label, array);
157            Field::array(label, item_field.field_type)
158        }
159        FormaFieldType::Shape { shape } => {
160            let mut shape_fields = IndexMap::new();
161            for sf in shape {
162                let f = forma_field_type_to_field(&sf.label, &sf.field_type);
163                shape_fields.insert(
164                    sf.name.clone(),
165                    ShapeField::new(&sf.label, f.field_type),
166                );
167            }
168            Field::shape(label, shape_fields)
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use serde_json::json;
177
178    #[test]
179    fn forma_to_query_column_context() {
180        let forma = Forma {
181            fields: vec![
182                FormaField {
183                    name: "name".to_owned(),
184                    label: "Name".to_owned(),
185                    field_type: FormaFieldType::Named("string".to_owned()),
186                    ..Default::default()
187                },
188                FormaField {
189                    name: "role".to_owned(),
190                    label: "Role".to_owned(),
191                    field_type: FormaFieldType::Named("role".to_owned()),
192                    ..Default::default()
193                },
194            ],
195            column: Some(vec!["name".to_owned()]),
196            ..Default::default()
197        };
198
199        let query = forma_to_query(&forma, "user", &FormaContext::Column, None);
200        assert_eq!(query.model, "user");
201        assert_eq!(query.select, vec!["name"]);
202        assert!(query.where_.is_empty());
203        assert!(query.method.is_none());
204    }
205
206    #[test]
207    fn forma_to_query_with_id() {
208        let forma = Forma {
209            fields: vec![FormaField {
210                name: "name".to_owned(),
211                label: "Name".to_owned(),
212                field_type: FormaFieldType::Named("string".to_owned()),
213                ..Default::default()
214            }],
215            ..Default::default()
216        };
217
218        let query = forma_to_query(&forma, "user", &FormaContext::Form, Some(&json!(42)));
219        assert_eq!(query.where_.get("id"), Some(&json!(42)));
220        assert_eq!(query.method, Some("one".to_owned()));
221    }
222
223    #[test]
224    fn forma_field_to_field_maps_primitive_and_reference_kinds() {
225        use crate::field::FieldType;
226        let ctx = FormaContext::Column;
227        let cases: &[(&str, bool)] = &[
228            ("string",  false),
229            ("number",  false),
230            ("boolean", false),
231            ("file",    false),
232            ("role",    true),
233        ];
234        for (kind, is_ref) in cases {
235            let sf = FormaField {
236                name: "f".into(),
237                label: "F".into(),
238                field_type: FormaFieldType::Named(kind.to_string()),
239                ..Default::default()
240            };
241            let field = forma_field_to_field(&sf, &ctx);
242            match &field.field_type {
243                FieldType::Reference { reference } => {
244                    assert!(is_ref, "expected primitive for kind={kind}");
245                    assert_eq!(reference.entity, *kind);
246                }
247                FieldType::Primitive { .. } => {
248                    assert!(!is_ref, "expected reference for kind={kind}");
249                }
250                other => panic!("unexpected field type for kind={kind}: {other:?}"),
251            }
252        }
253    }
254}
255
256impl Default for FormaField {
257    fn default() -> Self {
258        Self {
259            name: String::new(),
260            label: String::new(),
261            field_type: FormaFieldType::default(),
262            detail_type: None,
263            form_type: None,
264            column_type: None,
265            fixed_value: None,
266            rule: None,
267        }
268    }
269}