Skip to main content

hayson_engine/schema/
serialize.rs

1use serde_json::{Map, Value};
2
3use super::types::{DefaultValue, FieldSpec, IndexKind, TableSchema, SCHEMA_VERSION};
4
5fn index_kind_str(kind: IndexKind) -> &'static str {
6    match kind {
7        IndexKind::Exact => "exact",
8        IndexKind::Sorted => "sorted",
9    }
10}
11
12/// One field of a schema document, in the key order and with the omissions the TS
13/// `schema/parse.ts` builds a `FieldSpec` with: every modifier only where it holds, so a field
14/// carrying nothing but a type renders as `{ "type": … }` alone.
15///
16/// This is not `catalog::serialize::field_document`, which always spells the four flags out — that
17/// one renders a catalog entry, which also carries `description`/`deprecated`; this one renders the
18/// document `createTable` takes.
19pub fn field_document(spec: &FieldSpec) -> Value {
20    let mut doc = Map::new();
21    doc.insert("type".to_string(), Value::from(spec.field_type.as_str()));
22    if spec.required {
23        doc.insert("required".to_string(), Value::Bool(true));
24    }
25    if spec.unique {
26        doc.insert("unique".to_string(), Value::Bool(true));
27    }
28    if spec.fulltext {
29        doc.insert("fulltext".to_string(), Value::Bool(true));
30    }
31    if spec.cypher {
32        doc.insert("cypher".to_string(), Value::Bool(true));
33    }
34    // NOTE: only when opted out -- rendering the default would change every existing markdown field.
35    if !spec.addressable {
36        doc.insert("addressable".to_string(), Value::Bool(false));
37    }
38    if let Some(index) = spec.index {
39        doc.insert("index".to_string(), Value::from(index_kind_str(index)));
40    }
41    if let Some(values) = &spec.values {
42        doc.insert("values".to_string(), Value::from(values.clone()));
43    }
44    match &spec.default {
45        Some(DefaultValue::Now) => {
46            doc.insert("default".to_string(), Value::from("now"));
47        }
48        Some(DefaultValue::Value(value)) => {
49            doc.insert("default".to_string(), value.clone());
50        }
51        None => {}
52    }
53    Value::Object(doc)
54}
55
56/// A schema as the standalone document `schema::parse_schema` reads back and `createTable` takes —
57/// the same shape, key order and omissions as the TS `serializeSchema`/`describeTable` pair.
58/// Fields come out in `BTreeMap` order, which is the alphabetical order `hayson.json` holds them in.
59pub fn schema_document(schema: &TableSchema) -> Value {
60    let mut doc = Map::new();
61    doc.insert("schemaVersion".to_string(), Value::from(SCHEMA_VERSION));
62    doc.insert("table".to_string(), Value::from(schema.table.clone()));
63    doc.insert("revision".to_string(), Value::from(schema.revision));
64    doc.insert("primaryKey".to_string(), Value::from(schema.primary_key.clone()));
65    if let Some(partition_by) = &schema.partition_by {
66        doc.insert("partitionBy".to_string(), Value::from(partition_by.clone()));
67    }
68    let mut fields = Map::new();
69    for (name, spec) in &schema.fields {
70        fields.insert(name.clone(), field_document(spec));
71    }
72    doc.insert("fields".to_string(), Value::Object(fields));
73    Value::Object(doc)
74}
75
76#[cfg(test)]
77mod tests {
78    use serde_json::json;
79
80    use super::super::parse::parse_schema;
81    use super::*;
82
83    fn parsed(document: Value) -> TableSchema {
84        parse_schema(&document).expect("the fixture is a usable schema")
85    }
86
87    #[test]
88    fn renders_only_the_modifiers_a_field_actually_carries() {
89        let schema = parsed(json!({
90            "schemaVersion": 1,
91            "table": "users",
92            "primaryKey": "id",
93            "fields": {
94                "id": { "type": "string" },
95                "bio": { "type": "string" },
96            },
97        }));
98
99        let document = schema_document(&schema);
100
101        assert_eq!(document["fields"]["bio"], json!({ "type": "string" }));
102        assert_eq!(
103            document["fields"]["id"],
104            json!({ "type": "string", "required": true, "unique": true, "index": "exact" })
105        );
106    }
107
108    #[test]
109    fn keeps_the_key_order_the_ts_document_has() {
110        let schema = parsed(json!({
111            "schemaVersion": 1,
112            "table": "users",
113            "revision": 4,
114            "primaryKey": "id",
115            "partitionBy": "tenant",
116            "fields": {
117                "id": { "type": "string" },
118                "tenant": { "type": "string", "required": true },
119                "role": { "type": "enum", "values": ["admin"], "index": "exact", "default": "admin" },
120            },
121        }));
122
123        let document = schema_document(&schema);
124
125        let keys: Vec<&String> = document.as_object().unwrap().keys().collect();
126        assert_eq!(
127            keys,
128            vec![
129                "schemaVersion",
130                "table",
131                "revision",
132                "primaryKey",
133                "partitionBy",
134                "fields"
135            ]
136        );
137        let role: Vec<&String> = document["fields"]["role"].as_object().unwrap().keys().collect();
138        assert_eq!(role, vec!["type", "index", "values", "default"]);
139        let fields: Vec<&String> = document["fields"].as_object().unwrap().keys().collect();
140        assert_eq!(fields, vec!["id", "role", "tenant"]);
141    }
142
143    #[test]
144    fn leaves_partition_by_out_of_a_table_that_declares_none() {
145        let schema = parsed(json!({
146            "schemaVersion": 1,
147            "table": "users",
148            "primaryKey": "id",
149            "fields": { "id": { "type": "string" } },
150        }));
151
152        let document = schema_document(&schema);
153
154        assert!(document.as_object().unwrap().get("partitionBy").is_none());
155        assert_eq!(document["revision"], json!(1));
156    }
157
158    #[test]
159    fn renders_a_cypher_field_as_the_bare_fact_of_it() {
160        let schema = parsed(json!({
161            "schemaVersion": 1,
162            "table": "secrets",
163            "primaryKey": "id",
164            "fields": {
165                "id": { "type": "string" },
166                "token": { "type": "string", "cypher": true },
167            },
168        }));
169
170        let document = schema_document(&schema);
171
172        assert_eq!(document["fields"]["token"], json!({ "type": "string", "cypher": true }));
173    }
174
175    #[test]
176    fn round_trips_through_parse_schema() {
177        let schema = parsed(json!({
178            "schemaVersion": 1,
179            "table": "everything",
180            "revision": 2,
181            "primaryKey": "id",
182            "partitionBy": "tenant",
183            "fields": {
184                "id": { "type": "string" },
185                "tenant": { "type": "string", "required": true },
186                "page": { "type": "markdown" },
187                "cover": { "type": "image" },
188                "tags": { "type": "string[]", "index": "exact", "default": ["new"] },
189                "seenAt": { "type": "timestamp", "default": "now", "index": "sorted" },
190                "live": { "type": "bool", "default": false },
191                "notes": { "type": "string", "fulltext": true },
192                "slug": { "type": "string", "unique": true },
193            },
194        }));
195
196        let again = parse_schema(&schema_document(&schema)).expect("the rendered document parses");
197
198        assert_eq!(again, schema);
199    }
200}