Skip to main content

hyle_dioxus/
adapter.rs

1//! Framework-agnostic adapter types and pure helper functions.
2//!
3//! This module contains two categories of public items:
4//!
5//! ## End-user types
6//!
7//! State types and option structs used directly when working with hooks:
8//! [`HyleManifestState`], [`HyleDataState`], [`HyleDataField`],
9//! [`HyleFilterField`], [`FieldChange`], [`FieldChangeFn`], [`FieldChangeMap`],
10//! [`FormErrors`], [`AdapterFiltersOptions`], [`AdapterFormOptions`],
11//! [`UseFormaOptions`].
12//!
13//! ## For framework adapter authors
14//!
15//! Pure computation helpers that implement the core hook algorithms. These are
16//! public so that adapter crates (e.g. `hyle-dioxus`, a future `hyle-leptos`)
17//! can wrap them with their own reactive primitives.
18//!
19//! **End-users building UIs should not call these directly** — use the hooks
20//! provided by `hyle-dioxus` or `@tty-pt/hyle-react` instead:
21//! [`compute_manifest`], [`compute_data`], [`build_effective_query`],
22//! [`run_purify`], [`build_filter_fields`], [`apply_change`],
23//! [`compute_forma_result`].
24
25use std::collections::HashMap;
26use std::rc::Rc;
27use std::sync::Arc;
28
29use indexmap::IndexMap;
30use hyle::{
31    Blueprint, Column, Field, FieldType, Forma, FormaContext, Manifest, Outcome,
32    PurifyError, Query, Row, Source, Value,
33    display_value, forma_to_query, rows_from_outcome, is_single,
34};
35use hyle::view::derive_columns;
36
37// ── Field change ──────────────────────────────────────────────────────────────
38
39/// The result of a per-field `change` transform.
40///
41/// `R` is the per-field render override type supplied by framework adapters.
42/// Defaults to `()` for adapters that do not use per-field render overrides.
43pub struct FieldChange<R = ()> {
44    pub field: Field,
45    /// Optional per-field render closure supplied by the adapter.
46    pub render: Option<R>,
47}
48
49impl<R> FieldChange<R> {
50    /// Convenience constructor: override only the label, keep all other field
51    /// metadata and no custom render.
52    pub fn label(f: &Field, label: impl Into<String>) -> Option<FieldChange<R>> {
53        Some(FieldChange { field: Field { label: label.into(), ..f.clone() }, render: None })
54    }
55}
56
57/// A single per-field transform function (render type erased to `()`).
58pub type FieldChangeFn = Rc<dyn Fn(&Field) -> Option<FieldChange>>;
59
60/// A map of per-field transform functions keyed by field name.
61///
62/// Return `Some(FieldChange { .. })` to override a field's metadata.
63/// Return `None` to exclude the field from the filter/form and from validation.
64pub type FieldChangeMap = HashMap<String, FieldChangeFn>;
65
66// ── Manifest state ────────────────────────────────────────────────────────────
67
68/// State returned by a manifest hook (e.g. `use_manifest`).
69#[derive(Clone, PartialEq)]
70pub enum HyleManifestState {
71    Ready { manifest: Manifest },
72    Error { error: String },
73}
74
75// ── Data state ────────────────────────────────────────────────────────────────
76
77/// A single resolved field value with a pre-built display renderer.
78pub struct HyleDataField {
79    pub key: String,
80    pub label: String,
81    pub field: Field,
82    /// The raw JSON value for this field in the current row.
83    pub raw: Value,
84    /// Calls `hyle::display_value` with the captured blueprint/outcome/row context.
85    pub render: Rc<dyn Fn() -> String>,
86}
87
88impl Clone for HyleDataField {
89    fn clone(&self) -> Self {
90        Self {
91            key: self.key.clone(),
92            label: self.label.clone(),
93            field: self.field.clone(),
94            raw: self.raw.clone(),
95            render: self.render.clone(),
96        }
97    }
98}
99
100impl PartialEq for HyleDataField {
101    fn eq(&self, other: &Self) -> bool {
102        self.key == other.key
103            && self.label == other.label
104            && self.field == other.field
105            && self.raw == other.raw
106    }
107}
108
109/// State returned by a data hook (e.g. `use_data`).
110#[derive(Clone, PartialEq)]
111pub enum HyleDataState {
112    /// Waiting for the source adapter to return data.
113    Loading { manifest: Option<Manifest> },
114    /// The manifest derivation or data resolution failed.
115    Error {
116        error: String,
117        manifest: Option<Manifest>,
118    },
119    /// Data resolved successfully.
120    Ready {
121        manifest: Manifest,
122        outcome: Outcome,
123        rows: Vec<Row>,
124        /// Resolved columns (field key, label, field metadata).
125        columns: Vec<Column>,
126        /// Set when the manifest method is `"one"` or the result is a single record.
127        row: Option<Row>,
128        /// Per-field accessors; only populated when `row` is `Some`.
129        fields: Vec<HyleDataField>,
130    },
131}
132
133// ── Filter field ──────────────────────────────────────────────────────────────
134
135/// Metadata for a single filter input, pre-computed by a filters hook.
136///
137/// For `Reference` fields, `options` is pre-resolved from the outcome lookups
138/// so the filter component does not need access to the outcome.
139///
140/// The `R` type parameter is a per-field render override supplied by framework
141/// adapters (e.g. `Rc<dyn Fn(Props) -> Element>` in `hyle-dioxus`).  It
142/// defaults to `()` so callers that do not need per-field render overrides can
143/// use `HyleFilterField` without a type argument.
144pub struct HyleFilterField<R = ()> {
145    pub key: String,
146    pub label: String,
147    pub field: Field,
148    /// Pre-resolved `(id, display_label)` pairs for `Reference` fields.
149    pub options: Option<Vec<(String, String)>>,
150    /// The `FieldType` of the referenced entity's `display_field`, when this
151    /// field is a `Reference` or `Array<Reference>`.  Used by framework adapters
152    /// to look up a registered value/filter renderer for the display field.
153    pub display_field_type: Option<FieldType>,
154    /// Optional per-field render override installed by the `change` map.
155    pub render: Option<R>,
156}
157
158impl<R: Clone> Clone for HyleFilterField<R> {
159    fn clone(&self) -> Self {
160        Self {
161            key: self.key.clone(),
162            label: self.label.clone(),
163            field: self.field.clone(),
164            options: self.options.clone(),
165            display_field_type: self.display_field_type.clone(),
166            render: self.render.clone(),
167        }
168    }
169}
170
171impl<R> PartialEq for HyleFilterField<R> {
172    fn eq(&self, other: &Self) -> bool {
173        self.key == other.key
174            && self.label == other.label
175            && self.field == other.field
176            && self.options == other.options
177            && self.display_field_type == other.display_field_type
178        // render intentionally excluded (opaque closure)
179    }
180}
181
182// ── Filter/form options ───────────────────────────────────────────────────────
183
184/// Options for a filters hook (e.g. `use_filters`) — framework-agnostic base.
185///
186/// Framework adapters define their own options type (e.g.
187/// `hyle_dioxus::UseFiltersOptions`) that wraps this with a render-typed
188/// `change` map.  Use this type when building a custom adapter without a
189/// Dioxus or React render override.
190#[derive(Default)]
191pub struct AdapterFiltersOptions {
192    /// Pre-seed the committed filter values (e.g. from URL query params on SSR).
193    pub initial_committed: IndexMap<String, String>,
194    /// Per-field transform map.
195    pub change: Option<FieldChangeMap>,
196}
197
198/// Options for a forma hook (e.g. `use_forma`).
199pub struct UseFormaOptions {
200    /// Which field subset to use when deriving the query. Defaults to `Column`.
201    pub context: FormaContext,
202}
203
204impl Default for UseFormaOptions {
205    fn default() -> Self {
206        Self { context: FormaContext::Column }
207    }
208}
209
210/// Options for a form hook (e.g. `use_form`) — framework-agnostic base.
211///
212/// Framework adapters define their own options type (e.g.
213/// `hyle_dioxus::UseFormOptions`) that wraps this with a render-typed
214/// `change` map.
215#[derive(Default)]
216pub struct AdapterFormOptions {
217    /// Pre-seed the committed form values (e.g. from URL params on SSR).
218    pub initial_committed: IndexMap<String, String>,
219    /// Per-field transform map — same semantics as `AdapterFiltersOptions.change`.
220    pub change: Option<FieldChangeMap>,
221}
222
223impl AdapterFormOptions {
224    /// Add a single field transform, building the change map lazily.
225    pub fn with_change(
226        mut self,
227        key: &str,
228        f: impl Fn(&Field) -> Option<FieldChange> + 'static,
229    ) -> Self {
230        self.change
231            .get_or_insert_with(HashMap::new)
232            .insert(key.to_owned(), Rc::new(f));
233        self
234    }
235}
236
237// ── Form errors ───────────────────────────────────────────────────────────────
238
239/// Pre-rendered validation errors injected by a POST handler into the server
240/// context so that form pages can display them without JS.
241#[derive(Clone, PartialEq, Default, Debug, serde::Serialize, serde::Deserialize)]
242pub struct FormErrors(pub IndexMap<String, String>);
243
244impl FormErrors {
245    pub fn is_empty(&self) -> bool {
246        self.0.is_empty()
247    }
248}
249
250// ── Pure helpers (for framework adapter authors) ──────────────────────────────
251//
252// End-users building UIs should use the hooks from `hyle-dioxus` or
253// `@tty-pt/hyle-react` rather than calling these functions directly.
254
255/// Derive a `HyleManifestState` from a blueprint and query.
256///
257/// *For framework adapter authors.* Wraps `Blueprint::manifest` into the
258/// standard loading/ready/error state enum used by manifest hooks.
259#[doc(hidden)]
260pub fn compute_manifest(blueprint: &Blueprint, query: &Query) -> HyleManifestState {
261    match blueprint.manifest(query.clone()) {
262        Ok(m) => HyleManifestState::Ready { manifest: m },
263        Err(e) => HyleManifestState::Error { error: e.to_string() },
264    }
265}
266
267/// Resolve a `HyleDataState` given a manifest and source.
268///
269/// *For framework adapter authors.* Core algorithm for data hooks
270/// (`use_data`, `useData`): runs `Blueprint::resolve`, applies the view, and
271/// builds the `fields` list from the columns.
272#[doc(hidden)]
273pub fn compute_data(
274    blueprint: Arc<Blueprint>,
275    manifest: Manifest,
276    source: Source,
277) -> HyleDataState {
278    match blueprint.resolve(&manifest, &source) {
279        Err(e) => HyleDataState::Error {
280            error: e.to_string(),
281            manifest: Some(manifest),
282        },
283        Ok(outcome) => {
284            let rows = outcome.rows.rows();
285            // server (C libhyle) handles filter/sort/paginate — use rows as-is
286            let single = is_single(&manifest, &outcome);
287            let columns = match derive_columns(&blueprint, &manifest) {
288                Ok(c) => c,
289                Err(e) => return HyleDataState::Error { error: e.to_string(), manifest: Some(manifest) },
290            };
291            let row = if single { rows.first().cloned() } else { None };
292            let fields = build_fields(&blueprint, &manifest, &outcome, &columns, row.as_ref());
293            HyleDataState::Ready {
294                manifest,
295                outcome,
296                rows,
297                columns,
298                row,
299                fields,
300            }
301        }
302    }
303}
304
305/// Build per-field display accessors for a single resolved row.
306fn build_fields(
307    blueprint: &Blueprint,
308    manifest: &Manifest,
309    outcome: &Outcome,
310    columns: &[Column],
311    row: Option<&Row>,
312) -> Vec<HyleDataField> {
313    let arc_bp = Arc::new(blueprint.clone());
314    let arc_oc = Arc::new(outcome.clone());
315    let base = manifest.base.clone();
316
317    columns
318        .iter()
319        .map(|col| {
320            let raw = row
321                .and_then(|r| r.get(&col.key))
322                .cloned()
323                .unwrap_or(Value::Null);
324
325            let bp2 = arc_bp.clone();
326            let oc2 = arc_oc.clone();
327            let base2 = base.clone();
328            let key2 = col.key.clone();
329            let raw2 = raw.clone();
330
331            HyleDataField {
332                key: col.key.clone(),
333                label: col.label.clone(),
334                field: col.field.clone(),
335                raw,
336                render: Rc::new(move || display_value(&bp2, &oc2, &base2, &key2, &raw2)),
337            }
338        })
339        .collect()
340}
341
342/// Apply committed filter values and optional sort/pagination to produce the
343/// effective query passed to the source adapter and resolver.
344///
345/// *For framework adapter authors.* Used by filter/list hooks to merge
346/// user-committed filter state into the base query before fetching.
347#[doc(hidden)]
348pub fn build_effective_query(
349    base: &Query,
350    committed: &IndexMap<String, String>,
351    page: usize,
352    per_page: usize,
353    sort_field: Option<&str>,
354    sort_ascending: bool,
355) -> Query {
356    let mut where_ = base.where_.clone();
357    for (k, v) in committed {
358        if !v.is_empty() {
359            where_.insert(k.clone(), Value::String(v.clone()));
360        }
361    }
362
363    let sort = sort_field
364        .map(|f| crate::Sort { field: f.to_owned(), ascending: sort_ascending })
365        .or_else(|| base.sort.clone());
366
367    Query {
368        where_,
369        page: Some(page),
370        per_page: Some(per_page),
371        sort,
372        ..base.clone()
373    }
374}
375
376/// Returns `None` — validation is handled server-side by C libhyle.
377#[doc(hidden)]
378pub fn run_purify(
379    _blueprint: &Blueprint,
380    _model_name: &str,
381    _form_data: &IndexMap<String, String>,
382) -> Option<Vec<PurifyError>> {
383    None
384}
385
386/// Build filter field metadata from a manifest and outcome.
387///
388/// *For framework adapter authors.* For `Reference` fields the lookup table
389/// from the outcome is pre-resolved
390/// into `(id, display_label)` pairs so filter components need no access to
391/// the outcome at render time.
392///
393/// The returned fields carry `render: None` (type `()`).  Framework adapters
394/// that need per-field render overrides should call `apply_change` with a
395/// render-typed `FieldChangeMap<R>` afterwards.
396#[doc(hidden)]
397pub fn build_filter_fields(
398    blueprint: &Blueprint,
399    manifest: &Manifest,
400    outcome: &Outcome,
401) -> Vec<HyleFilterField> {
402    let columns = match derive_columns(blueprint, manifest) {
403        Ok(c) => c,
404        Err(_) => return vec![],
405    };
406
407    columns
408        .into_iter()
409        .map(|col| {
410            let mut options: Option<Vec<(String, String)>> = None;
411            let mut display_field_type: Option<FieldType> = None;
412
413            match &col.field.field_type {
414                FieldType::Reference { reference } => {
415                    let pairs = outcome
416                        .lookups
417                        .get(&reference.entity)
418                        .map(|lookup| {
419                            lookup
420                                .iter()
421                                .map(|(id, row)| {
422                                    let label = row
423                                        .get(&reference.display_field)
424                                        .and_then(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
425                                        .unwrap_or(id.as_str())
426                                        .to_owned();
427                                    (id.clone(), label)
428                                })
429                                .collect::<Vec<_>>()
430                        })
431                        .unwrap_or_default();
432                    options = Some(pairs);
433                    display_field_type = blueprint
434                        .models
435                        .get(&reference.entity)
436                        .and_then(|m| m.fields.get(&reference.display_field))
437                        .map(|f| f.field_type.clone());
438                }
439                FieldType::Array { item } => {
440                    if let FieldType::Reference { reference } = item.as_ref() {
441                        let pairs = outcome
442                            .lookups
443                            .get(&reference.entity)
444                            .map(|lookup| {
445                                lookup
446                                    .iter()
447                                    .map(|(id, row)| {
448                                        let label = row
449                                            .get(&reference.display_field)
450                                            .and_then(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
451                                            .unwrap_or(id.as_str())
452                                            .to_owned();
453                                        (id.clone(), label)
454                                    })
455                                    .collect::<Vec<_>>()
456                            })
457                            .unwrap_or_default();
458                        options = Some(pairs);
459                        display_field_type = blueprint
460                            .models
461                            .get(&reference.entity)
462                            .and_then(|m| m.fields.get(&reference.display_field))
463                            .map(|f| f.field_type.clone());
464                    } else {
465                        // Scan rows for distinct primitive values of this field.
466                        let mut seen = std::collections::HashSet::new();
467                        let mut pairs = vec![];
468                        for row in rows_from_outcome(outcome) {
469                            if let Some(Value::Array(arr)) = row.get(col.key.as_str()) {
470                                for item in arr {
471                                    let s = match item {
472                                        Value::String(s) => s.clone(),
473                                        other => other.to_string(),
474                                    };
475                                    if seen.insert(s.clone()) {
476                                        pairs.push((s.clone(), s));
477                                    }
478                                }
479                            }
480                        }
481                        if !pairs.is_empty() {
482                            options = Some(pairs);
483                        }
484                    }
485                }
486                _ => {}
487            };
488
489            HyleFilterField { key: col.key, label: col.label, field: col.field, options, display_field_type, render: None }
490        })
491        .collect()
492}
493
494/// Apply a `FieldChangeMap` to a list of filter fields.
495///
496/// *For framework adapter authors.* Processes the per-field `change` map
497/// from options structs (e.g. `UseFiltersOptions::change`) against the
498/// list produced by `build_filter_fields`.
499///
500/// - Key absent from `change` → keep as-is (render stays `None`).
501/// - `change[key](&field)` returns `None` → exclude the field entirely.
502/// - Returns `Some(FieldChange { field, render })` → replace field metadata
503///   and attach the optional render closure.
504///
505/// `R` is the render override type carried by the adapter's `FieldChange` /
506/// `HyleFilterField`.  Use `FieldChangeMap` (which erases `R` to `()`) when
507/// you do not need per-field render overrides.
508#[doc(hidden)]
509pub fn apply_change<R>(
510    fields: Vec<HyleFilterField<R>>,
511    change: &HashMap<String, Rc<dyn Fn(&Field) -> Option<FieldChange<R>>>>,
512) -> Vec<HyleFilterField<R>> {
513    fields
514        .into_iter()
515        .filter_map(|mut ff| {
516            if let Some(change_fn) = change.get(&ff.key) {
517                match change_fn(&ff.field) {
518                    None => return None,
519                    Some(FieldChange { field, render }) => {
520                        ff.label = field.label.clone();
521                        ff.field = field;
522                        ff.render = render;
523                    }
524                }
525            }
526            Some(ff)
527        })
528        .collect()
529}
530
531/// Derive `(Option<Query>, Option<Forma>)` from a resolved "forma" data row.
532///
533/// *For framework adapter authors.* Core algorithm for `use_forma` /
534/// `useForma`: parses the forma definition stored in the resolved data row
535/// and converts it to a query for the actual data fetch.
536#[doc(hidden)]
537pub fn compute_forma_result(
538    data: &HyleDataState,
539    table_name: &str,
540    id: Option<Value>,
541    context: &FormaContext,
542) -> (Option<Query>, Option<Forma>) {
543    let row = match data {
544        HyleDataState::Ready { row: Some(r), .. } => r,
545        _ => return (None, None),
546    };
547
548    let forma: Forma = match hyle::deser_row(row) {
549        Ok(f) => f,
550        Err(_) => return (None, None),
551    };
552
553    if forma.fields.is_empty() {
554        return (None, Some(forma));
555    }
556
557    let derived = forma_to_query(&forma, table_name, context, id.as_ref());
558    (Some(derived), Some(forma))
559}
560
561// ── Tests ─────────────────────────────────────────────────────────────────────
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566    use crate::{Field, Model};
567    use hyle::ModelRows;
568    use indexmap::IndexMap;
569    fn user_blueprint() -> Blueprint {
570        Blueprint::new()
571            .model(
572                "user",
573                Model::new()
574                    .field("name", Field::string("Name").with_metadata("required", Value::Bool(true)))
575                    .field("email", Field::string("Email"))
576                    .field("active", Field::boolean("Active")),
577            )
578    }
579
580    fn user_query() -> Query {
581        Query::new("user").select(["name", "email", "active"])
582    }
583
584    fn user_source(rows: Vec<Row>) -> Source {
585        let mut src = Source::new();
586        src.insert("user".to_owned(), hyle::ModelResult::many(rows));
587        src
588    }
589
590    fn alice() -> Row {
591        indexmap::indexmap! {
592            "id".to_owned()     => Value::Int(1),
593            "name".to_owned()   => Value::String("Alice".to_owned()),
594            "email".to_owned()  => Value::String("alice@example.test".to_owned()),
595            "active".to_owned() => Value::Bool(true),
596        }
597    }
598
599    // ── compute_manifest ──────────────────────────────────────────────────────
600
601    #[test]
602    fn manifest_ok() {
603        let bp = user_blueprint();
604        let state = compute_manifest(&bp, &user_query());
605        assert!(matches!(state, HyleManifestState::Ready { .. }));
606        if let HyleManifestState::Ready { manifest } = state {
607            assert_eq!(manifest.base, "user");
608            assert!(manifest.fields.contains(&"name".to_owned()));
609        }
610    }
611
612    #[test]
613    fn manifest_unknown_model() {
614        let bp = user_blueprint();
615        let state = compute_manifest(&bp, &Query::new("ghost"));
616        assert!(matches!(state, HyleManifestState::Error { .. }));
617    }
618
619    // ── compute_data ──────────────────────────────────────────────────────────
620
621    #[test]
622    fn data_ready_with_rows() {
623        let bp = Arc::new(user_blueprint());
624        let manifest = bp.manifest(user_query()).unwrap();
625        let src = user_source(vec![alice()]);
626        let state = compute_data(bp, manifest, src);
627        assert!(matches!(state, HyleDataState::Ready { .. }));
628        if let HyleDataState::Ready { rows, row, .. } = state {
629            assert_eq!(rows.len(), 1);
630            assert!(row.is_none());
631        }
632    }
633
634    #[test]
635    fn data_ready_single_record() {
636        let bp = Arc::new(user_blueprint());
637        let q = Query {
638            model: "user".to_owned(),
639            select: vec!["name".to_owned(), "email".to_owned(), "active".to_owned()],
640            method: Some("one".to_owned()),
641            ..Default::default()
642        };
643        let manifest = bp.manifest(q).unwrap();
644        let state = compute_data(bp, manifest, user_source(vec![alice()]));
645        if let HyleDataState::Ready { row, .. } = state {
646            assert!(row.is_some());
647        } else {
648            panic!("expected Ready");
649        }
650    }
651
652    #[test]
653    fn data_fields_built_for_single_record() {
654        let bp = Arc::new(user_blueprint());
655        let q = Query {
656            model: "user".to_owned(),
657            select: vec!["name".to_owned()],
658            method: Some("one".to_owned()),
659            ..Default::default()
660        };
661        let manifest = bp.manifest(q).unwrap();
662        let state = compute_data(bp, manifest, user_source(vec![alice()]));
663        if let HyleDataState::Ready { fields, .. } = state {
664            assert!(!fields.is_empty());
665            let name_field = fields.iter().find(|f| f.key == "name").unwrap();
666            assert_eq!(name_field.render.as_ref()(), "Alice");
667        } else {
668            panic!("expected Ready");
669        }
670    }
671
672    // ── build_effective_query ─────────────────────────────────────────────────
673
674    #[test]
675    fn effective_query_merges_committed() {
676        let mut committed = IndexMap::new();
677        committed.insert("name".to_owned(), "Bob".to_owned());
678        let q = build_effective_query(&user_query(), &committed, 1, 20, None, true);
679        assert_eq!(q.where_.get("name"), Some(&Value::String("Bob".to_owned())));
680    }
681
682    #[test]
683    fn effective_query_skips_empty_values() {
684        let mut committed = IndexMap::new();
685        committed.insert("name".to_owned(), String::new());
686        let q = build_effective_query(&user_query(), &committed, 1, 20, None, true);
687        assert!(!q.where_.contains_key("name"));
688    }
689
690    #[test]
691    fn effective_query_applies_sort() {
692        let q = build_effective_query(&user_query(), &IndexMap::new(), 2, 50, Some("name"), false);
693        assert_eq!(q.page, Some(2));
694        assert_eq!(q.per_page, Some(50));
695        let sort = q.sort.unwrap();
696        assert_eq!(sort.field, "name");
697        assert!(!sort.ascending);
698    }
699
700    // ── apply_change ──────────────────────────────────────────────────────────
701
702    fn make_filter_field(key: &str, label: &str) -> HyleFilterField {
703        HyleFilterField {
704            key: key.to_owned(),
705            label: label.to_owned(),
706            field: Field {
707                label: label.to_owned(),
708                field_type: crate::FieldType::Primitive { primitive: crate::Primitive::String },
709                options: Default::default(),
710            },
711            options: None,
712            display_field_type: None,
713            render: None,
714        }
715    }
716
717    #[test]
718    fn apply_change_identity_when_no_map() {
719        let fields = vec![make_filter_field("name", "Name"), make_filter_field("email", "Email")];
720        let result = apply_change(fields, &HashMap::new());
721        assert_eq!(result.len(), 2);
722    }
723
724    #[test]
725    fn apply_change_modifies_label() {
726        let fields = vec![make_filter_field("name", "Name")];
727        let mut change: FieldChangeMap = HashMap::new();
728        change.insert(
729            "name".to_owned(),
730            Rc::new(|f: &Field| Some(FieldChange { field: Field { label: "Full Name".to_owned(), ..f.clone() }, render: None })),
731        );
732        let result = apply_change(fields, &change);
733        assert_eq!(result.len(), 1);
734        assert_eq!(result[0].label, "Full Name");
735    }
736
737    #[test]
738    fn apply_change_none_removes_field() {
739        let fields = vec![make_filter_field("name", "Name"), make_filter_field("email", "Email")];
740        let mut change: FieldChangeMap = HashMap::new();
741        change.insert("name".to_owned(), Rc::new(|_: &Field| None));
742        let result = apply_change(fields, &change);
743        assert_eq!(result.len(), 1);
744        assert_eq!(result[0].key, "email");
745    }
746
747    #[test]
748    fn apply_change_unknown_key_passes_through() {
749        let fields = vec![make_filter_field("name", "Name")];
750        let mut change: FieldChangeMap = HashMap::new();
751        change.insert("ghost".to_owned(), Rc::new(|_: &Field| None));
752        let result = apply_change(fields, &change);
753        assert_eq!(result.len(), 1);
754        assert_eq!(result[0].key, "name");
755    }
756
757    // ── compute_forma_result ──────────────────────────────────────────────────
758
759    fn make_manifest(base: &str) -> Manifest {
760        Manifest {
761            base: base.to_owned(),
762            id: None,
763            fields: vec![],
764            filter: Default::default(),
765            lookups: vec![],
766            inlines: vec![],
767            page: None,
768            per_page: None,
769            sort: None,
770            method: None,
771            filter_fields: vec![],
772        }
773    }
774
775    fn make_outcome() -> Outcome {
776        Outcome { rows: ModelRows::Many(vec![]), total: 0, lookups: Default::default() }
777    }
778
779    #[test]
780    fn forma_returns_none_while_loading() {
781        let state = HyleDataState::Loading { manifest: None };
782        let (q, f) = compute_forma_result(&state, "user", None, &FormaContext::Column);
783        assert!(q.is_none());
784        assert!(f.is_none());
785    }
786
787    #[test]
788    fn forma_returns_none_query_when_no_fields() {
789        let forma = crate::Forma { fields: vec![], ..Default::default() };
790        let row: Row = serde_json::from_value(serde_json::to_value(&forma).unwrap()).unwrap();
791        let state = HyleDataState::Ready {
792            manifest: make_manifest("forma"),
793            outcome: make_outcome(),
794            rows: vec![row.clone()],
795            columns: vec![],
796            row: Some(row),
797            fields: vec![],
798        };
799        let (q, f) = compute_forma_result(&state, "user", None, &FormaContext::Column);
800        assert!(q.is_none());
801        assert!(f.is_some());
802    }
803
804    #[test]
805    fn forma_derives_query_with_fields() {
806        use crate::{Forma, FormaField, FormaFieldType};
807        let forma = Forma {
808            fields: vec![FormaField {
809                name: "name".to_owned(),
810                label: "Name".to_owned(),
811                field_type: FormaFieldType::Named("string".to_owned()),
812                ..Default::default()
813            }],
814            column: Some(vec!["name".to_owned()]),
815            ..Default::default()
816        };
817        let row: Row = serde_json::from_value(serde_json::to_value(&forma).unwrap()).unwrap();
818        let state = HyleDataState::Ready {
819            manifest: make_manifest("forma"),
820            outcome: make_outcome(),
821            rows: vec![row.clone()],
822            columns: vec![],
823            row: Some(row),
824            fields: vec![],
825        };
826        let (q, f) = compute_forma_result(&state, "user", None, &FormaContext::Column);
827        assert!(f.is_some());
828        let q = q.expect("should have derived query");
829        assert_eq!(q.model, "user");
830        assert!(q.select.contains(&"name".to_owned()));
831    }
832}