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