cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! `QueryRunner` adapter on top of [`CozoAdapter`]. Runs the script
//! through Cozo and translates `cozo::NamedRows` into the domain's
//! neutral types.

use cozo::{DataValue, NamedRows, Num};

use crate::domain::model::query::{
    QueryColumn, QueryRelation, QueryResult, QuerySchema, QueryScript, QueryValue,
};
use crate::domain::usecases::query::QueryRunner;

use super::adapter::CozoAdapter;

impl QueryRunner for CozoAdapter {
    fn run(&self, script: &QueryScript) -> anyhow::Result<QueryResult> {
        let rows = CozoAdapter::run(self, script.as_str())?;
        Ok(into_query_result(rows))
    }

    fn schema(&self) -> anyhow::Result<QuerySchema> {
        let relations = CozoAdapter::run(self, "::relations")?;
        let name_idx = relations
            .headers
            .iter()
            .position(|h| h == "name")
            .unwrap_or(0);
        let mut out: Vec<QueryRelation> = Vec::new();
        for row in &relations.rows {
            let Some(DataValue::Str(name)) = row.get(name_idx) else {
                continue;
            };
            let name = name.to_string();
            let columns = CozoAdapter::run(self, &format!("::columns {name}"))?;
            out.push(QueryRelation {
                columns: parse_columns(&columns),
                name,
            });
        }
        Ok(QuerySchema::new(out))
    }
}

fn parse_columns(rows: &NamedRows) -> Vec<QueryColumn> {
    let col_idx = rows.headers.iter().position(|h| h == "column");
    let type_idx = rows.headers.iter().position(|h| h == "type");
    let key_idx = rows.headers.iter().position(|h| h == "is_key");
    rows.rows
        .iter()
        .filter_map(|c| {
            let name = match c.get(col_idx?) {
                Some(DataValue::Str(s)) => s.to_string(),
                _ => return None,
            };
            let type_ = match c.get(type_idx?) {
                Some(DataValue::Str(s)) => s.to_string(),
                _ => return None,
            };
            let is_key = matches!(c.get(key_idx?), Some(DataValue::Bool(true)));
            Some(QueryColumn {
                name,
                type_,
                is_key,
            })
        })
        .collect()
}

fn into_query_result(rows: NamedRows) -> QueryResult {
    let mapped: Vec<Vec<QueryValue>> = rows
        .rows
        .into_iter()
        .map(|row| row.iter().map(value_from_cozo).collect())
        .collect();
    QueryResult::new(rows.headers, mapped)
}

/// Cozo values without a clean primitive shape (`Bytes`, `List`,
/// `Validity`, `Uuid`, …) fall through to `Other` carrying their
/// `Debug` form. The CLI used the same fallback before the use case
/// was introduced.
fn value_from_cozo(v: &DataValue) -> QueryValue {
    match v {
        DataValue::Null => QueryValue::Null,
        DataValue::Bool(b) => QueryValue::Bool(*b),
        DataValue::Str(s) => QueryValue::Str(s.to_string()),
        DataValue::Num(Num::Int(i)) => QueryValue::Int(*i),
        DataValue::Num(Num::Float(f)) => QueryValue::Float(*f),
        other => QueryValue::Other(format!("{other:?}")),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::infra::driven::cozo::graph_facts::GraphFacts;

    fn runner() -> CozoAdapter {
        CozoAdapter::from_facts(GraphFacts::default()).expect("cozo init")
    }

    fn run(script: &str) -> QueryResult {
        let a = runner();
        let r: &dyn QueryRunner = &a;
        r.run(&QueryScript::new(script)).unwrap()
    }

    #[test]
    fn primitive_scalars_map_to_their_neutral_variants() {
        let r = run("?[n] := n = 42");
        assert_eq!(r.headers, vec!["n".to_owned()]);
        assert_eq!(r.rows, vec![vec![QueryValue::Int(42)]]);
    }

    #[test]
    fn string_values_round_trip_into_str() {
        let r = run("?[s] := s = 'hello'");
        assert_eq!(r.rows, vec![vec![QueryValue::Str("hello".to_owned())]]);
    }

    #[test]
    fn empty_result_keeps_headers_and_no_rows() {
        let r = run("?[n] := n = 1, n = 2");
        assert_eq!(r.headers, vec!["n".to_owned()]);
        assert!(r.is_empty());
    }

    #[test]
    fn schema_exposes_workspace_relations_with_their_columns() {
        let a = runner();
        let r: &dyn QueryRunner = &a;
        let s = r.schema().unwrap();
        let issue = s
            .relations
            .iter()
            .find(|r| r.name == "issue")
            .expect("issue relation present");
        let id = issue
            .columns
            .iter()
            .find(|c| c.name == "id")
            .expect("id column present");
        assert!(id.is_key);
        assert_eq!(id.type_, "String");
    }
}