hayson-engine 0.26.0

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

use super::types::{DefaultValue, FieldSpec, IndexKind, TableSchema, SCHEMA_VERSION};

fn index_kind_str(kind: IndexKind) -> &'static str {
    match kind {
        IndexKind::Exact => "exact",
        IndexKind::Sorted => "sorted",
    }
}

/// One field of a schema document, in the key order and with the omissions the TS
/// `schema/parse.ts` builds a `FieldSpec` with: every modifier only where it holds, so a field
/// carrying nothing but a type renders as `{ "type": … }` alone.
///
/// This is not `catalog::serialize::field_document`, which always spells the four flags out — that
/// one renders a catalog entry, which also carries `description`/`deprecated`; this one renders the
/// document `createTable` takes.
pub fn field_document(spec: &FieldSpec) -> Value {
    let mut doc = Map::new();
    doc.insert("type".to_string(), Value::from(spec.field_type.as_str()));
    if spec.required {
        doc.insert("required".to_string(), Value::Bool(true));
    }
    if spec.unique {
        doc.insert("unique".to_string(), Value::Bool(true));
    }
    if spec.fulltext {
        doc.insert("fulltext".to_string(), Value::Bool(true));
    }
    if spec.cypher {
        doc.insert("cypher".to_string(), Value::Bool(true));
    }
    if spec.password_hash {
        doc.insert("passwordHash".to_string(), Value::Bool(true));
    }
    // NOTE: only when opted out -- rendering the default would change every existing markdown field.
    if !spec.addressable {
        doc.insert("addressable".to_string(), Value::Bool(false));
    }
    if let Some(index) = spec.index {
        doc.insert("index".to_string(), Value::from(index_kind_str(index)));
    }
    if let Some(values) = &spec.values {
        doc.insert("values".to_string(), Value::from(values.clone()));
    }
    match &spec.default {
        Some(DefaultValue::Now) => {
            doc.insert("default".to_string(), Value::from("now"));
        }
        Some(DefaultValue::Value(value)) => {
            doc.insert("default".to_string(), value.clone());
        }
        None => {}
    }
    Value::Object(doc)
}

/// A schema as the standalone document `schema::parse_schema` reads back and `createTable` takes —
/// the same shape, key order and omissions as the TS `serializeSchema`/`describeTable` pair.
/// Fields come out in `BTreeMap` order, which is the alphabetical order `hayson.json` holds them in.
pub fn schema_document(schema: &TableSchema) -> Value {
    let mut doc = Map::new();
    doc.insert("schemaVersion".to_string(), Value::from(SCHEMA_VERSION));
    doc.insert("table".to_string(), Value::from(schema.table.clone()));
    doc.insert("revision".to_string(), Value::from(schema.revision));
    doc.insert("primaryKey".to_string(), Value::from(schema.primary_key.clone()));
    if let Some(partition_by) = &schema.partition_by {
        doc.insert("partitionBy".to_string(), Value::from(partition_by.clone()));
    }
    let mut fields = Map::new();
    for (name, spec) in &schema.fields {
        fields.insert(name.clone(), field_document(spec));
    }
    doc.insert("fields".to_string(), Value::Object(fields));
    Value::Object(doc)
}

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

    use super::super::parse::parse_schema;
    use super::*;

    fn parsed(document: Value) -> TableSchema {
        parse_schema(&document).expect("the fixture is a usable schema")
    }

    #[test]
    fn renders_only_the_modifiers_a_field_actually_carries() {
        let schema = parsed(json!({
            "schemaVersion": 1,
            "table": "users",
            "primaryKey": "id",
            "fields": {
                "id": { "type": "string" },
                "bio": { "type": "string" },
            },
        }));

        let document = schema_document(&schema);

        assert_eq!(document["fields"]["bio"], json!({ "type": "string" }));
        assert_eq!(
            document["fields"]["id"],
            json!({ "type": "string", "required": true, "unique": true, "index": "exact" })
        );
    }

    #[test]
    fn keeps_the_key_order_the_ts_document_has() {
        let schema = parsed(json!({
            "schemaVersion": 1,
            "table": "users",
            "revision": 4,
            "primaryKey": "id",
            "partitionBy": "tenant",
            "fields": {
                "id": { "type": "string" },
                "tenant": { "type": "string", "required": true },
                "role": { "type": "enum", "values": ["admin"], "index": "exact", "default": "admin" },
            },
        }));

        let document = schema_document(&schema);

        let keys: Vec<&String> = document.as_object().unwrap().keys().collect();
        assert_eq!(
            keys,
            vec![
                "schemaVersion",
                "table",
                "revision",
                "primaryKey",
                "partitionBy",
                "fields"
            ]
        );
        let role: Vec<&String> = document["fields"]["role"].as_object().unwrap().keys().collect();
        assert_eq!(role, vec!["type", "index", "values", "default"]);
        let fields: Vec<&String> = document["fields"].as_object().unwrap().keys().collect();
        assert_eq!(fields, vec!["id", "role", "tenant"]);
    }

    #[test]
    fn leaves_partition_by_out_of_a_table_that_declares_none() {
        let schema = parsed(json!({
            "schemaVersion": 1,
            "table": "users",
            "primaryKey": "id",
            "fields": { "id": { "type": "string" } },
        }));

        let document = schema_document(&schema);

        assert!(document.as_object().unwrap().get("partitionBy").is_none());
        assert_eq!(document["revision"], json!(1));
    }

    #[test]
    fn renders_a_cypher_field_as_the_bare_fact_of_it() {
        let schema = parsed(json!({
            "schemaVersion": 1,
            "table": "secrets",
            "primaryKey": "id",
            "fields": {
                "id": { "type": "string" },
                "token": { "type": "string", "cypher": true },
            },
        }));

        let document = schema_document(&schema);

        assert_eq!(document["fields"]["token"], json!({ "type": "string", "cypher": true }));
    }

    #[test]
    fn round_trips_through_parse_schema() {
        let schema = parsed(json!({
            "schemaVersion": 1,
            "table": "everything",
            "revision": 2,
            "primaryKey": "id",
            "partitionBy": "tenant",
            "fields": {
                "id": { "type": "string" },
                "tenant": { "type": "string", "required": true },
                "page": { "type": "markdown" },
                "cover": { "type": "image" },
                "tags": { "type": "string[]", "index": "exact", "default": ["new"] },
                "seenAt": { "type": "timestamp", "default": "now", "index": "sorted" },
                "live": { "type": "bool", "default": false },
                "notes": { "type": "string", "fulltext": true },
                "slug": { "type": "string", "unique": true },
            },
        }));

        let again = parse_schema(&schema_document(&schema)).expect("the rendered document parses");

        assert_eq!(again, schema);
    }
}