hyle 0.2.0

Framework-agnostic schema and query planning core for hyle.
Documentation
use indexmap::{IndexMap, IndexSet};
use serde::{Deserialize, Serialize};

use crate::error::{Error, HyleResult};
use crate::field::{Field, FieldType};
use crate::query::{Manifest, Query};
use crate::raw::{ModelRows, Outcome, Row, Source, value_to_lookup_key};
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Model {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
    #[serde(default)]
    pub fields: IndexMap<String, Field>,
}

impl Model {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn with_label(label: impl Into<String>) -> Self {
        Self {
            label: Some(label.into()),
            fields: IndexMap::new(),
        }
    }

    pub fn field(mut self, name: impl Into<String>, field: Field) -> Self {
        self.fields.insert(name.into(), field);
        self
    }
}

#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Blueprint {
    #[serde(default)]
    pub models: IndexMap<String, Model>,
}

/// Output of [`Blueprint::resolve_query`].
impl Blueprint {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn model(mut self, name: impl Into<String>, model: Model) -> Self {
        self.models.insert(name.into(), model);
        self
    }

    pub fn manifest(&self, query: Query) -> HyleResult<Manifest> {
        let model = self
            .models
            .get(&query.model)
            .ok_or_else(|| Error::UnknownModel(query.model.clone()))?;

        let mut fields = if query.select.is_empty() {
            model.fields.keys().cloned().collect::<Vec<_>>()
        } else {
            query.select.clone()
        };

        if fields.is_empty() {
            return Err(Error::EmptySelection);
        }

        for field in &fields {
            if !model.fields.contains_key(field) {
                return Err(Error::UnknownField {
                    model: query.model.clone(),
                    field: field.clone(),
                });
            }
        }

        let id = query.where_.get("id").cloned();
        let filter = query
            .where_
            .iter()
            .filter(|(key, _)| key.as_str() != "id")
            .map(|(key, value)| (key.clone(), value.clone()))
            .collect::<IndexMap<_, _>>();

        let explicit_filter_fields = query
            .filters
            .iter()
            .flatten()
            .cloned()
            .collect::<IndexSet<_>>();

        for field in &explicit_filter_fields {
            if !model.fields.contains_key(field) {
                return Err(Error::UnknownField {
                    model: query.model.clone(),
                    field: field.clone(),
                });
            }
        }

        let mut lookups = IndexSet::new();
        let mut inlines = IndexSet::new();

        for field_name in &fields {
            let field = &model.fields[field_name];
            collect_references(
                self,
                &query.model,
                field_name,
                &field.field_type,
                explicit_filter_fields.contains(field_name),
                &mut lookups,
                &mut inlines,
            )?;
        }

        for field_name in &explicit_filter_fields {
            if fields.contains(field_name) {
                continue;
            }

            let field = &model.fields[field_name];
            collect_references(
                self,
                &query.model,
                field_name,
                &field.field_type,
                true,
                &mut lookups,
                &mut inlines,
            )?;
        }

        fields.shrink_to_fit();

        Ok(Manifest {
            base: query.model,
            id,
            fields,
            filter,
            lookups: lookups.into_iter().collect(),
            inlines: inlines.into_iter().collect(),
            page: query.page,
            per_page: query.per_page,
            sort: query.sort,
            method: query.method,
            filter_fields: query.filters,
        })
    }

    /// Convenience: manifest + resolve + normalise rows in one call.
    pub fn resolve_query(&self, query: Query, source: &Source) -> HyleResult<(Manifest, Outcome, Vec<Row>)> {
        let manifest = self.manifest(query)?;
        let outcome = self.resolve(&manifest, source)?;
        let rows = outcome.rows.rows();
        Ok((manifest, outcome, rows))
    }

    pub fn resolve(&self, manifest: &Manifest, source: &Source) -> HyleResult<Outcome> {
        let base = source
            .get(&manifest.base)
            .ok_or_else(|| Error::MissingBaseModel(manifest.base.clone()))?;

        let mut lookups = IndexMap::new();

        for model_name in manifest.lookups.iter().chain(manifest.inlines.iter()) {
            if let Some(result) = source.get(model_name) {
                lookups.insert(model_name.clone(), rows_by_id(result.rows()));
            }
        }

        Ok(Outcome {
            rows: base.result.clone(),
            total: base.total,
            lookups,
        })
    }
}

fn collect_references(
    blueprint: &Blueprint,
    source_model: &str,
    source_field: &str,
    field_type: &FieldType,
    explicit_need: bool,
    lookups: &mut IndexSet<String>,
    inlines: &mut IndexSet<String>,
) -> HyleResult<()> {
    match field_type {
        FieldType::Primitive { .. } => Ok(()),
        FieldType::Reference { reference } => {
            if !blueprint.models.contains_key(&reference.entity) {
                return Err(Error::UnknownReference {
                    model: source_model.to_owned(),
                    field: source_field.to_owned(),
                    target: reference.entity.clone(),
                });
            }

            if explicit_need {
                lookups.insert(reference.entity.clone());
            } else {
                inlines.insert(reference.entity.clone());
            }

            Ok(())
        }
        FieldType::Array { item } => collect_references(
            blueprint,
            source_model,
            source_field,
            item,
            true, // Array<Reference> always needs lookup resolution
            lookups,
            inlines,
        ),
        FieldType::Shape { fields } => {
            for (name, field) in fields {
                collect_references(
                    blueprint,
                    source_model,
                    name,
                    &field.field_type,
                    explicit_need,
                    lookups,
                    inlines,
                )?;
            }
            Ok(())
        }
    }
}

fn rows_by_id(rows: Vec<Row>) -> IndexMap<String, Row> {
    rows.into_iter()
        .filter_map(|row| {
            let id = row.get("id").and_then(value_to_lookup_key)?;
            Some((id.clone(), row))
        })
        .collect()
}

#[allow(dead_code)]
fn _assert_rows_send_sync(_: ModelRows) {}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::Value;
    use crate::field::Field;
    use crate::raw::ModelResult;
    fn simple_blueprint() -> Blueprint {
        Blueprint::new()
            .model(
                "user",
                Model::new()
                    .field("name", Field::string("Name"))
                    .field("email", Field::string("Email"))
                    .field("role", Field::reference("Role", "role")),
            )
            .model(
                "role",
                Model::new()
                    .field("name", Field::string("Name")),
            )
    }

    fn user_source() -> Source {
        let mut src = Source::new();
        src.insert(
            "user".into(),
            ModelResult::many(vec![
                indexmap::indexmap! {
                    "id".to_owned()   => Value::Int(1),
                    "name".to_owned() => Value::String("Alice".to_owned()),
                    "email".to_owned()=> Value::String("alice@example.test".to_owned()),
                    "role".to_owned() => Value::String("admin".to_owned()),
                },
            ]),
        );
        src.insert(
            "role".into(),
            ModelResult::many(vec![
                indexmap::indexmap! {
                    "id".to_owned()   => Value::String("admin".to_owned()),
                    "name".to_owned() => Value::String("Admin".to_owned()),
                },
            ]),
        );
        src
    }

    // ── manifest ──────────────────────────────────────────────────────────────

    #[test]
    fn manifest_happy_path() {
        let bp = simple_blueprint();
        let q = Query::new("user").select(["name", "email"]);
        let m = bp.manifest(q).unwrap();
        assert_eq!(m.base, "user");
        assert_eq!(m.fields, vec!["name", "email"]);
        assert!(m.lookups.is_empty());
        assert!(m.inlines.is_empty());
    }

    #[test]
    fn manifest_empty_select_uses_all_fields() {
        let bp = simple_blueprint();
        let q = Query::new("user");
        let m = bp.manifest(q).unwrap();
        assert!(m.fields.contains(&"name".to_owned()));
        assert!(m.fields.contains(&"role".to_owned()));
    }

    #[test]
    fn manifest_unknown_model_errors() {
        let bp = simple_blueprint();
        let q = Query::new("ghost");
        assert!(matches!(bp.manifest(q), Err(Error::UnknownModel(_))));
    }

    #[test]
    fn manifest_unknown_field_errors() {
        let bp = simple_blueprint();
        let q = Query::new("user").select(["ghost"]);
        assert!(matches!(bp.manifest(q), Err(Error::UnknownField { .. })));
    }

    #[test]
    fn manifest_reference_field_goes_in_inlines() {
        let bp = simple_blueprint();
        let q = Query::new("user").select(["name", "role"]);
        let m = bp.manifest(q).unwrap();
        assert!(m.inlines.contains(&"role".to_owned()));
        assert!(m.lookups.is_empty());
    }

    #[test]
    fn manifest_reference_field_in_filter_goes_in_lookups() {
        let bp = simple_blueprint();
        let q = Query::new("user")
            .select(["name", "role"])
            .filter_layout([["role"]]);
        let m = bp.manifest(q).unwrap();
        assert!(m.lookups.contains(&"role".to_owned()));
        assert!(!m.inlines.contains(&"role".to_owned()));
    }

    #[test]
    fn manifest_unknown_reference_errors() {
        let bp = Blueprint::new().model(
            "user",
            Model::new().field("dept", Field::reference("Dept", "department")),
        );
        let q = Query::new("user").select(["dept"]);
        assert!(matches!(bp.manifest(q), Err(Error::UnknownReference { .. })));
    }

    // ── resolve ───────────────────────────────────────────────────────────────

    #[test]
    fn resolve_happy_path() {
        let bp = simple_blueprint();
        let q = Query::new("user").select(["name", "role"]);
        let m = bp.manifest(q).unwrap();
        let src = user_source();
        let outcome = bp.resolve(&m, &src).unwrap();
        assert_eq!(outcome.rows.rows().len(), 1);
        assert!(outcome.lookups.contains_key("role"));
    }

    #[test]
    fn resolve_missing_base_model_errors() {
        let bp = simple_blueprint();
        let manifest = Manifest {
            base: "ghost".into(),
            id: None,
            fields: vec![],
            filter: Default::default(),
            lookups: vec![],
            inlines: vec![],
            page: None,
            per_page: None,
            sort: None,
            method: None,
            filter_fields: vec![],
        };
        let src = user_source();
        assert!(matches!(bp.resolve(&manifest, &src), Err(Error::MissingBaseModel(_))));
    }
}