hayson-engine 0.16.0

Storage engine for hayson document databases — the embeddable core behind hayson-server, hayson-cli and hayson-browser
Documentation
use serde_json::Value;

use crate::catalog::indexes::sugar_index_name;
use crate::catalog::{CatalogIndexKind, ExpandedIndex};
use crate::schema::{schema_document, IndexKind, TableSchema};

/// The kind part of the name `sugar_indexes_of` gives the index a field modifier expands to, for
/// the three modifiers a schema document can hold. A TTL index has no modifier and no sugar name.
fn sugar_kind_of(kind: CatalogIndexKind) -> Option<&'static str> {
    match kind {
        CatalogIndexKind::Exact => Some("exact"),
        CatalogIndexKind::Sorted => Some("sorted"),
        CatalogIndexKind::Fulltext => Some("fulltext"),
        CatalogIndexKind::Ttl => None,
    }
}

/// Whether `index` is the sugar the field modifiers of `table` expand to, rather than an index
/// declared under a name of its own: one field, no `where`, no TTL, and the name
/// `sugar_index_name` builds. An index of any other shape has no field modifier to fold back into,
/// and a schema document is exactly the set of modifiers.
fn sugar_of<'a>(table: &str, index: &'a ExpandedIndex) -> Option<(&'a str, CatalogIndexKind)> {
    let [field] = index.fields.as_slice() else { return None };
    if index.where_.is_some() || index.expire_after_seconds.is_some() {
        return None;
    }
    let kind = sugar_kind_of(index.kind)?;
    (index.name == sugar_index_name(table, field, kind)).then_some((field.as_str(), index.kind))
}

/// `schema` with the three field modifiers a schema document can hold reconciled against `served`,
/// the index set the handle actually serves — the union of the two, the schema's own modifier
/// winning where they disagree on a kind.
///
/// The two only come apart where something rewrote `hayson.json` under an open handle: a live table
/// is brought up from one catalog entry, and its `index`/`fulltext` modifiers are the very thing
/// `sugar_indexes_of` expanded the served sugar from.
fn served_schema(schema: &TableSchema, served: &[ExpandedIndex]) -> TableSchema {
    let table = schema.table.clone();
    let mut reconciled = schema.clone();
    for index in served {
        let Some((field, kind)) = sugar_of(&table, index) else {
            continue;
        };
        let Some(spec) = reconciled.fields.get_mut(field) else {
            continue;
        };
        match kind {
            CatalogIndexKind::Exact | CatalogIndexKind::Sorted => {
                let expanded = if kind == CatalogIndexKind::Exact {
                    IndexKind::Exact
                } else {
                    IndexKind::Sorted
                };
                spec.index = Some(spec.index.unwrap_or(expanded));
                spec.unique = spec.unique || index.unique;
            }
            CatalogIndexKind::Fulltext => spec.fulltext = true,
            CatalogIndexKind::Ttl => {}
        }
    }
    reconciled
}

/// The schema of `table` as a standalone document, describing what this handle serves rather than
/// what the catalog on disk now declares — [`super::Table::schema`] and
/// [`super::Table::declared_indexes`], never a catalog entry.
///
/// The same shape the TS `catalog/describe.ts` returns, so it carries only what a schema document
/// can hold: the field-level `index`, `unique` and `fulltext` modifiers. An index of the served set
/// that no modifier can express — multi-field, partial, TTL, or simply named by hand — is left out,
/// exactly as it is there; `catalog::catalog_document` is what reports those.
pub fn describe_table_document(table: &super::Table) -> Value {
    schema_document(&served_schema(table.schema(), table.declared_indexes()))
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use crate::schema::parse_schema;

    use super::*;

    fn pairs_schema() -> TableSchema {
        parse_schema(&json!({
            "schemaVersion": 1,
            "table": "pairs",
            "primaryKey": "id",
            "fields": {
                "id": { "type": "string" },
                "a": { "type": "string" },
                "b": { "type": "int" },
            },
        }))
        .expect("the fixture is a usable schema")
    }

    fn index(name: &str, fields: &[&str], kind: CatalogIndexKind) -> ExpandedIndex {
        ExpandedIndex {
            name: name.to_string(),
            fields: fields.iter().map(|field| (*field).to_string()).collect(),
            kind,
            unique: false,
            where_: None,
            expire_after_seconds: None,
        }
    }

    #[test]
    fn folds_a_served_sugar_index_back_into_the_field_modifier() {
        let served = vec![
            index("pairs_a_exact", &["a"], CatalogIndexKind::Exact),
            index("pairs_b_sorted", &["b"], CatalogIndexKind::Sorted),
            index("pairs_a_fulltext", &["a"], CatalogIndexKind::Fulltext),
        ];

        let document = schema_document(&served_schema(&pairs_schema(), &served));

        assert_eq!(
            document["fields"]["a"],
            json!({ "type": "string", "fulltext": true, "index": "exact" })
        );
        assert_eq!(document["fields"]["b"], json!({ "type": "int", "index": "sorted" }));
    }

    #[test]
    fn leaves_an_index_named_by_hand_out_of_the_modifiers() {
        let served = vec![
            index("t_ab", &["a", "b"], CatalogIndexKind::Sorted),
            index("by_a", &["a"], CatalogIndexKind::Exact),
        ];

        let document = schema_document(&served_schema(&pairs_schema(), &served));

        assert_eq!(document["fields"]["a"], json!({ "type": "string" }));
        assert_eq!(document["fields"]["b"], json!({ "type": "int" }));
        assert!(document.as_object().unwrap().get("indexes").is_none());
    }

    #[test]
    fn leaves_a_partial_or_ttl_index_out_even_under_a_sugar_name() {
        let mut partial = index("pairs_a_exact", &["a"], CatalogIndexKind::Exact);
        partial.where_ = Some(vec![crate::catalog::CatalogPredicate {
            field: "b".to_string(),
            op: ">".to_string(),
            value: json!(0),
        }]);
        let mut expiring = index("pairs_b_sorted", &["b"], CatalogIndexKind::Sorted);
        expiring.expire_after_seconds = Some(60);

        let document = schema_document(&served_schema(&pairs_schema(), &[partial, expiring]));

        assert_eq!(document["fields"]["a"], json!({ "type": "string" }));
        assert_eq!(document["fields"]["b"], json!({ "type": "int" }));
    }

    #[test]
    fn keeps_the_kind_the_schema_declares_where_the_served_sugar_disagrees() {
        let schema = parse_schema(&json!({
            "schemaVersion": 1,
            "table": "pairs",
            "primaryKey": "id",
            "fields": {
                "id": { "type": "string" },
                "a": { "type": "string", "index": "sorted" },
            },
        }))
        .expect("the fixture is a usable schema");
        let mut unique_sugar = index("pairs_a_exact", &["a"], CatalogIndexKind::Exact);
        unique_sugar.unique = true;

        let document = schema_document(&served_schema(&schema, &[unique_sugar]));

        assert_eq!(
            document["fields"]["a"],
            json!({ "type": "string", "unique": true, "index": "sorted" })
        );
    }
}