Skip to main content

hyle/
blueprint.rs

1use indexmap::{IndexMap, IndexSet};
2use serde::{Deserialize, Serialize};
3
4use crate::error::{Error, HyleResult};
5use crate::field::{Field, FieldType};
6use crate::query::{Manifest, Query};
7use crate::raw::{ModelRows, Outcome, Row, Source, value_to_lookup_key};
8use crate::view::{apply_view, derive_columns, Column};
9#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
10#[serde(rename_all = "camelCase")]
11pub struct Model {
12    #[serde(default, skip_serializing_if = "Option::is_none")]
13    pub label: Option<String>,
14    #[serde(default)]
15    pub fields: IndexMap<String, Field>,
16}
17
18impl Model {
19    pub fn new() -> Self {
20        Self::default()
21    }
22
23    pub fn with_label(label: impl Into<String>) -> Self {
24        Self {
25            label: Some(label.into()),
26            fields: IndexMap::new(),
27        }
28    }
29
30    pub fn field(mut self, name: impl Into<String>, field: Field) -> Self {
31        self.fields.insert(name.into(), field);
32        self
33    }
34}
35
36#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct Blueprint {
39    #[serde(default)]
40    pub models: IndexMap<String, Model>,
41}
42
43/// Output of [`Blueprint::resolve_and_view`].
44#[derive(Debug, Serialize)]
45#[serde(rename_all = "camelCase")]
46pub struct ResolvedView {
47    pub outcome: Outcome,
48    pub rows: Vec<Row>,
49    pub is_single: bool,
50    pub columns: Vec<Column>,
51}
52
53impl Blueprint {
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    pub fn model(mut self, name: impl Into<String>, model: Model) -> Self {
59        self.models.insert(name.into(), model);
60        self
61    }
62
63    pub fn manifest(&self, query: Query) -> HyleResult<Manifest> {
64        let model = self
65            .models
66            .get(&query.model)
67            .ok_or_else(|| Error::UnknownModel(query.model.clone()))?;
68
69        let mut fields = if query.select.is_empty() {
70            model.fields.keys().cloned().collect::<Vec<_>>()
71        } else {
72            query.select.clone()
73        };
74
75        if fields.is_empty() {
76            return Err(Error::EmptySelection);
77        }
78
79        for field in &fields {
80            if !model.fields.contains_key(field) {
81                return Err(Error::UnknownField {
82                    model: query.model.clone(),
83                    field: field.clone(),
84                });
85            }
86        }
87
88        let id = query.where_.get("id").cloned();
89        let filter = query
90            .where_
91            .iter()
92            .filter(|(key, _)| key.as_str() != "id")
93            .map(|(key, value)| (key.clone(), value.clone()))
94            .collect::<IndexMap<_, _>>();
95
96        let explicit_filter_fields = query
97            .filters
98            .iter()
99            .flatten()
100            .cloned()
101            .collect::<IndexSet<_>>();
102
103        for field in &explicit_filter_fields {
104            if !model.fields.contains_key(field) {
105                return Err(Error::UnknownField {
106                    model: query.model.clone(),
107                    field: field.clone(),
108                });
109            }
110        }
111
112        let mut lookups = IndexSet::new();
113        let mut inlines = IndexSet::new();
114
115        for field_name in &fields {
116            let field = &model.fields[field_name];
117            collect_references(
118                self,
119                &query.model,
120                field_name,
121                &field.field_type,
122                explicit_filter_fields.contains(field_name),
123                &mut lookups,
124                &mut inlines,
125            )?;
126        }
127
128        for field_name in &explicit_filter_fields {
129            if fields.contains(field_name) {
130                continue;
131            }
132
133            let field = &model.fields[field_name];
134            collect_references(
135                self,
136                &query.model,
137                field_name,
138                &field.field_type,
139                true,
140                &mut lookups,
141                &mut inlines,
142            )?;
143        }
144
145        fields.shrink_to_fit();
146
147        Ok(Manifest {
148            base: query.model,
149            id,
150            fields,
151            filter,
152            lookups: lookups.into_iter().collect(),
153            inlines: inlines.into_iter().collect(),
154            page: query.page,
155            per_page: query.per_page,
156            sort: query.sort,
157            method: query.method,
158            filter_fields: query.filters,
159        })
160    }
161
162    /// Convenience: manifest + resolve + normalise rows in one call.
163    pub fn resolve_query(&self, query: Query, source: &Source) -> HyleResult<(Manifest, Outcome, Vec<Row>)> {
164        let manifest = self.manifest(query)?;
165        let outcome = self.resolve(&manifest, source)?;
166        let rows = outcome.rows.rows();
167        Ok((manifest, outcome, rows))
168    }
169
170    /// resolve + apply_view + is_single + derive_columns in one call.
171    ///
172    /// Returns a [`ResolvedView`] containing the filtered/sorted/paginated rows,
173    /// whether the result represents a single record, and the column metadata —
174    /// collapsing what would otherwise be 4–5 separate WASM round-trips.
175    pub fn resolve_and_view(&self, manifest: &Manifest, source: &Source) -> HyleResult<ResolvedView> {
176        let outcome = self.resolve(manifest, source)?;
177        let all_rows = outcome.rows.rows();
178        let rows = apply_view(all_rows, manifest);
179        let is_single = crate::raw::is_single(manifest, &outcome);
180        let columns = derive_columns(self, manifest)?;
181        Ok(ResolvedView { outcome, rows, is_single, columns })
182    }
183
184    pub fn resolve(&self, manifest: &Manifest, source: &Source) -> HyleResult<Outcome> {
185        let base = source
186            .get(&manifest.base)
187            .ok_or_else(|| Error::MissingBaseModel(manifest.base.clone()))?;
188
189        let mut lookups = IndexMap::new();
190
191        for model_name in manifest.lookups.iter().chain(manifest.inlines.iter()) {
192            if let Some(result) = source.get(model_name) {
193                lookups.insert(model_name.clone(), rows_by_id(result.rows()));
194            }
195        }
196
197        Ok(Outcome {
198            rows: base.result.clone(),
199            total: base.total,
200            lookups,
201        })
202    }
203}
204
205fn collect_references(
206    blueprint: &Blueprint,
207    source_model: &str,
208    source_field: &str,
209    field_type: &FieldType,
210    explicit_need: bool,
211    lookups: &mut IndexSet<String>,
212    inlines: &mut IndexSet<String>,
213) -> HyleResult<()> {
214    match field_type {
215        FieldType::Primitive { .. } => Ok(()),
216        FieldType::Reference { reference } => {
217            if !blueprint.models.contains_key(&reference.entity) {
218                return Err(Error::UnknownReference {
219                    model: source_model.to_owned(),
220                    field: source_field.to_owned(),
221                    target: reference.entity.clone(),
222                });
223            }
224
225            if explicit_need {
226                lookups.insert(reference.entity.clone());
227            } else {
228                inlines.insert(reference.entity.clone());
229            }
230
231            Ok(())
232        }
233        FieldType::Array { item } => collect_references(
234            blueprint,
235            source_model,
236            source_field,
237            item,
238            explicit_need,
239            lookups,
240            inlines,
241        ),
242        FieldType::Shape { fields } => {
243            for (name, field) in fields {
244                collect_references(
245                    blueprint,
246                    source_model,
247                    name,
248                    &field.field_type,
249                    explicit_need,
250                    lookups,
251                    inlines,
252                )?;
253            }
254            Ok(())
255        }
256    }
257}
258
259fn rows_by_id(rows: Vec<Row>) -> IndexMap<String, Row> {
260    rows.into_iter()
261        .filter_map(|row| {
262            let id = row.get("id").and_then(value_to_lookup_key)?;
263            Some((id.clone(), row))
264        })
265        .collect()
266}
267
268#[allow(dead_code)]
269fn _assert_rows_send_sync(_: ModelRows) {}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::field::{Field, Reference};
275    use crate::raw::ModelResult;
276    use serde_json::json;
277
278    fn simple_blueprint() -> Blueprint {
279        Blueprint::new()
280            .model(
281                "user",
282                Model::new()
283                    .field("name", Field::string("Name"))
284                    .field("email", Field::string("Email"))
285                    .field("role", Field::reference("Role", "role")),
286            )
287            .model(
288                "role",
289                Model::new()
290                    .field("name", Field::string("Name")),
291            )
292    }
293
294    fn user_source() -> Source {
295        let mut src = Source::new();
296        src.insert(
297            "user".into(),
298            ModelResult::many(vec![
299                indexmap::indexmap! {
300                    "id".to_owned()   => json!(1),
301                    "name".to_owned() => json!("Alice"),
302                    "email".to_owned()=> json!("alice@example.test"),
303                    "role".to_owned() => json!("admin"),
304                },
305            ]),
306        );
307        src.insert(
308            "role".into(),
309            ModelResult::many(vec![
310                indexmap::indexmap! {
311                    "id".to_owned()   => json!("admin"),
312                    "name".to_owned() => json!("Admin"),
313                },
314            ]),
315        );
316        src
317    }
318
319    // ── manifest ──────────────────────────────────────────────────────────────
320
321    #[test]
322    fn manifest_happy_path() {
323        let bp = simple_blueprint();
324        let q = Query::new("user").select(["name", "email"]);
325        let m = bp.manifest(q).unwrap();
326        assert_eq!(m.base, "user");
327        assert_eq!(m.fields, vec!["name", "email"]);
328        assert!(m.lookups.is_empty());
329        assert!(m.inlines.is_empty());
330    }
331
332    #[test]
333    fn manifest_empty_select_uses_all_fields() {
334        let bp = simple_blueprint();
335        let q = Query::new("user");
336        let m = bp.manifest(q).unwrap();
337        assert!(m.fields.contains(&"name".to_owned()));
338        assert!(m.fields.contains(&"role".to_owned()));
339    }
340
341    #[test]
342    fn manifest_unknown_model_errors() {
343        let bp = simple_blueprint();
344        let q = Query::new("ghost");
345        assert!(matches!(bp.manifest(q), Err(Error::UnknownModel(_))));
346    }
347
348    #[test]
349    fn manifest_unknown_field_errors() {
350        let bp = simple_blueprint();
351        let q = Query::new("user").select(["ghost"]);
352        assert!(matches!(bp.manifest(q), Err(Error::UnknownField { .. })));
353    }
354
355    #[test]
356    fn manifest_reference_field_goes_in_inlines() {
357        let bp = simple_blueprint();
358        let q = Query::new("user").select(["name", "role"]);
359        let m = bp.manifest(q).unwrap();
360        assert!(m.inlines.contains(&"role".to_owned()));
361        assert!(m.lookups.is_empty());
362    }
363
364    #[test]
365    fn manifest_reference_field_in_filter_goes_in_lookups() {
366        let bp = simple_blueprint();
367        let q = Query::new("user")
368            .select(["name", "role"])
369            .filter_layout([["role"]]);
370        let m = bp.manifest(q).unwrap();
371        assert!(m.lookups.contains(&"role".to_owned()));
372        assert!(!m.inlines.contains(&"role".to_owned()));
373    }
374
375    #[test]
376    fn manifest_unknown_reference_errors() {
377        let bp = Blueprint::new().model(
378            "user",
379            Model::new().field("dept", Field::reference("Dept", "department")),
380        );
381        let q = Query::new("user").select(["dept"]);
382        assert!(matches!(bp.manifest(q), Err(Error::UnknownReference { .. })));
383    }
384
385    // ── resolve ───────────────────────────────────────────────────────────────
386
387    #[test]
388    fn resolve_happy_path() {
389        let bp = simple_blueprint();
390        let q = Query::new("user").select(["name", "role"]);
391        let m = bp.manifest(q).unwrap();
392        let src = user_source();
393        let outcome = bp.resolve(&m, &src).unwrap();
394        assert_eq!(outcome.rows.rows().len(), 1);
395        assert!(outcome.lookups.contains_key("role"));
396    }
397
398    #[test]
399    fn resolve_missing_base_model_errors() {
400        let bp = simple_blueprint();
401        let manifest = Manifest {
402            base: "ghost".into(),
403            id: None,
404            fields: vec![],
405            filter: Default::default(),
406            lookups: vec![],
407            inlines: vec![],
408            page: None,
409            per_page: None,
410            sort: None,
411            method: None,
412            filter_fields: vec![],
413        };
414        let src = user_source();
415        assert!(matches!(bp.resolve(&manifest, &src), Err(Error::MissingBaseModel(_))));
416    }
417
418    // ── resolve_and_view ──────────────────────────────────────────────────────
419
420    #[test]
421    fn resolve_and_view_end_to_end() {
422        let bp = simple_blueprint();
423        let q = Query::new("user").select(["name", "email"]);
424        let m = bp.manifest(q).unwrap();
425        let src = user_source();
426        let view = bp.resolve_and_view(&m, &src).unwrap();
427        assert_eq!(view.rows.len(), 1);
428        assert_eq!(view.rows[0]["name"], json!("Alice"));
429        assert_eq!(view.columns.len(), 2);
430        assert!(!view.is_single);
431    }
432
433    #[test]
434    fn resolve_and_view_single_record() {
435        let bp = simple_blueprint();
436        let q = Query {
437            model: "user".into(),
438            select: vec!["name".into()],
439            method: Some("one".into()),
440            ..Default::default()
441        };
442        let m = bp.manifest(q).unwrap();
443        let src = user_source();
444        let view = bp.resolve_and_view(&m, &src).unwrap();
445        assert!(view.is_single);
446    }
447}