Skip to main content

hayson_engine/schema/
parse.rs

1use std::collections::BTreeMap;
2
3use serde_json::{Map, Value};
4
5use crate::errors::HaysonSchemaError;
6
7use super::types::{DefaultValue, FieldSpec, FieldType, IndexKind, TableSchema, SCHEMA_VERSION};
8
9const FIELD_SPEC_KEYS: &[&str] = &[
10    "type",
11    "required",
12    "unique",
13    "index",
14    "fulltext",
15    "cypher",
16    "passwordHash",
17    "values",
18    "default",
19    "addressable",
20];
21const SCHEMA_KEYS: &[&str] = &[
22    "schemaVersion",
23    "table",
24    "revision",
25    "primaryKey",
26    "partitionBy",
27    "fields",
28];
29
30fn fail(message: impl Into<String>) -> HaysonSchemaError {
31    HaysonSchemaError(message.into())
32}
33
34fn is_table_name(name: &str) -> bool {
35    let mut chars = name.chars();
36    match chars.next() {
37        Some(c) if c.is_ascii_alphabetic() => {}
38        _ => return false,
39    }
40    chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
41}
42
43fn is_field_name(name: &str) -> bool {
44    let mut chars = name.chars();
45    match chars.next() {
46        Some(c) if c.is_ascii_alphabetic() => {}
47        _ => return false,
48    }
49    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
50}
51
52fn as_object<'a>(value: &'a Value, what: &str) -> Result<&'a Map<String, Value>, HaysonSchemaError> {
53    value
54        .as_object()
55        .ok_or_else(|| fail(format!("{what} must be an object")))
56}
57
58fn field_type_of(raw: &str) -> Option<FieldType> {
59    Some(match raw {
60        "string" => FieldType::String,
61        "int" => FieldType::Int,
62        "float" => FieldType::Float,
63        "bool" => FieldType::Bool,
64        "timestamp" => FieldType::Timestamp,
65        "enum" => FieldType::Enum,
66        "json" => FieldType::Json,
67        "string[]" => FieldType::StringArray,
68        "int[]" => FieldType::IntArray,
69        "float[]" => FieldType::FloatArray,
70        "bool[]" => FieldType::BoolArray,
71        "markdown" => FieldType::Markdown,
72        "image" => FieldType::Image,
73        _ => return None,
74    })
75}
76
77fn parse_flag(source: &Map<String, Value>, key: &str, what: &str) -> Result<bool, HaysonSchemaError> {
78    match source.get(key) {
79        None => Ok(false),
80        Some(Value::Bool(b)) => Ok(*b),
81        Some(_) => Err(fail(format!("{what} must be a boolean"))),
82    }
83}
84
85fn parse_default(
86    raw: Option<&Value>,
87    field_type: FieldType,
88    what: &str,
89) -> Result<Option<DefaultValue>, HaysonSchemaError> {
90    let Some(raw) = raw else { return Ok(None) };
91    if let Value::String(s) = raw {
92        if s == "now" {
93            if field_type != FieldType::Timestamp {
94                return Err(fail(format!("{what} may only be \"now\" on a timestamp field")));
95            }
96            return Ok(Some(DefaultValue::Now));
97        }
98    }
99    if raw.is_null() {
100        return Ok(Some(DefaultValue::Value(Value::Null)));
101    }
102    if raw.is_array() {
103        if !field_type.is_array() {
104            return Err(fail(format!(
105                "{what} must not be an array on a {} field",
106                field_type.as_str()
107            )));
108        }
109        return Ok(Some(DefaultValue::Value(raw.clone())));
110    }
111    if !(raw.is_string() || raw.is_number() || raw.is_boolean()) {
112        return Err(fail(format!("{what} must be a string, number, boolean, array or null")));
113    }
114    if field_type.is_array() {
115        return Err(fail(format!(
116            "{what} must be an array on a {} field",
117            field_type.as_str()
118        )));
119    }
120    Ok(Some(DefaultValue::Value(raw.clone())))
121}
122
123fn parse_field_spec(name: &str, raw: &Value) -> Result<FieldSpec, HaysonSchemaError> {
124    let source = as_object(raw, &format!("field \"{name}\""))?;
125    for key in source.keys() {
126        if !FIELD_SPEC_KEYS.contains(&key.as_str()) {
127            return Err(fail(format!("field \"{name}\" has an unknown option \"{key}\"")));
128        }
129    }
130
131    let type_raw = source
132        .get("type")
133        .and_then(Value::as_str)
134        .ok_or_else(|| fail(format!("field \"{name}\" has an unsupported type")))?;
135    let field_type = field_type_of(type_raw)
136        .ok_or_else(|| fail(format!("field \"{name}\" has an unsupported type {type_raw:?}")))?;
137
138    let index = match source.get("index") {
139        None => None,
140        Some(Value::String(s)) if s == "exact" => Some(IndexKind::Exact),
141        Some(Value::String(s)) if s == "sorted" => Some(IndexKind::Sorted),
142        Some(other) => return Err(fail(format!("field \"{name}\" has an unsupported index {other:?}"))),
143    };
144    if index == Some(IndexKind::Sorted) && !field_type.is_sortable() {
145        return Err(fail(format!(
146            "field \"{name}\" of type {} cannot carry a sorted index",
147            field_type.as_str()
148        )));
149    }
150
151    let fulltext = parse_flag(source, "fulltext", &format!("field \"{name}\" option \"fulltext\""))?;
152    if fulltext && field_type != FieldType::String && field_type != FieldType::StringArray {
153        return Err(fail(format!(
154            "field \"{name}\" of type {} cannot be indexed for full-text search",
155            field_type.as_str()
156        )));
157    }
158
159    let unique = parse_flag(source, "unique", &format!("field \"{name}\" option \"unique\""))?;
160    if unique && field_type.is_array() {
161        return Err(fail(format!(
162            "field \"{name}\" of type {} cannot be unique",
163            field_type.as_str()
164        )));
165    }
166
167    let cypher = parse_flag(source, "cypher", &format!("field \"{name}\" option \"cypher\""))?;
168    if cypher {
169        // NOTE: a random IV makes ciphertext non-comparable, so it can't feed an index or unique.
170        if index.is_some() {
171            return Err(fail(format!(
172                "encrypted field \"{name}\" cannot carry an index — a random IV makes two equal values differ"
173            )));
174        }
175        if unique {
176            return Err(fail(format!(
177                "encrypted field \"{name}\" cannot be unique — equal ciphertext is not equal values"
178            )));
179        }
180        if fulltext {
181            return Err(fail(format!(
182                "encrypted field \"{name}\" cannot be indexed for full-text search"
183            )));
184        }
185        if source.contains_key("default") {
186            return Err(fail(format!(
187                "encrypted field \"{name}\" cannot carry a default — it would sit in the schema in clear"
188            )));
189        }
190    }
191
192    let password_hash = parse_flag(
193        source,
194        "passwordHash",
195        &format!("field \"{name}\" option \"passwordHash\""),
196    )?;
197    if password_hash {
198        if field_type != FieldType::String {
199            return Err(fail(format!(
200                "field \"{name}\" of type {} cannot hold a password hash — only a string can",
201                field_type.as_str()
202            )));
203        }
204        // NOTE: a random salt makes two hashes of one password differ, so nothing can be looked up by it.
205        if index.is_some() || unique || fulltext {
206            return Err(fail(format!(
207                "password field \"{name}\" cannot be indexed, unique or searched — a random salt makes two equal passwords differ"
208            )));
209        }
210        if source.contains_key("default") {
211            return Err(fail(format!(
212                "password field \"{name}\" cannot carry a default — it would sit in the schema in clear"
213            )));
214        }
215    }
216
217    let addressable = match source.get("addressable") {
218        None => true,
219        Some(_) if field_type != FieldType::Markdown => {
220            return Err(fail(format!(
221                "field \"{name}\" of type {} must not declare \"addressable\" — only a markdown field can opt out of a virtual path of its own",
222                field_type.as_str()
223            )))
224        }
225        Some(Value::Bool(flag)) => *flag,
226        Some(_) => {
227            return Err(fail(format!(
228                "field \"{name}\" option \"addressable\" must be a boolean"
229            )))
230        }
231    };
232
233    if field_type.has_virtual_path() {
234        if addressable {
235            if index.is_some() {
236                return Err(fail(format!(
237                    "field \"{name}\" of type {} cannot carry an index — its virtual path is indexed by the engine",
238                    field_type.as_str()
239                )));
240            }
241            if unique {
242                return Err(fail(format!(
243                    "field \"{name}\" of type {} cannot declare \"unique\" — its virtual path is always unique",
244                    field_type.as_str()
245                )));
246            }
247        } else {
248            if index.is_some() {
249                return Err(fail(format!(
250                    "field \"{name}\" of type {} cannot carry an index — a whole markdown document is not a lookup key",
251                    field_type.as_str()
252                )));
253            }
254            if unique {
255                return Err(fail(format!(
256                    "field \"{name}\" of type {} cannot declare \"unique\" — a whole markdown document is not a key to be unique on",
257                    field_type.as_str()
258                )));
259            }
260        }
261        if fulltext {
262            return Err(fail(format!(
263                "field \"{name}\" of type {} cannot be indexed for full-text search",
264                field_type.as_str()
265            )));
266        }
267        if cypher {
268            return Err(fail(format!(
269                "field \"{name}\" of type {} cannot be encrypted",
270                field_type.as_str()
271            )));
272        }
273        if source.contains_key("default") {
274            return Err(fail(format!(
275                "field \"{name}\" of type {} cannot carry a default",
276                field_type.as_str()
277            )));
278        }
279    }
280
281    let values = if field_type == FieldType::Enum {
282        let raws = source
283            .get("values")
284            .and_then(Value::as_array)
285            .filter(|v| !v.is_empty())
286            .ok_or_else(|| fail(format!("enum field \"{name}\" must list its \"values\"")))?;
287        let mut values = Vec::with_capacity(raws.len());
288        for raw in raws {
289            let s = raw
290                .as_str()
291                .ok_or_else(|| fail(format!("enum field \"{name}\" must list string \"values\"")))?;
292            values.push(s.to_string());
293        }
294        let unique_count = values.iter().collect::<std::collections::HashSet<_>>().len();
295        if unique_count != values.len() {
296            return Err(fail(format!("enum field \"{name}\" has duplicate values")));
297        }
298        Some(values)
299    } else {
300        if source.contains_key("values") {
301            return Err(fail(format!(
302                "field \"{name}\" of type {} must not declare \"values\"",
303                field_type.as_str()
304            )));
305        }
306        None
307    };
308
309    let mut spec = FieldSpec::new(field_type);
310    spec.required = parse_flag(source, "required", &format!("field \"{name}\" option \"required\""))?;
311    spec.unique = unique;
312    spec.fulltext = fulltext;
313    spec.cypher = cypher;
314    spec.password_hash = password_hash;
315    spec.addressable = addressable;
316    // NOTE: unique needs the exact registry to answer "is this value taken" without touching disk.
317    spec.index = if unique {
318        Some(index.unwrap_or(IndexKind::Exact))
319    } else {
320        index
321    };
322    spec.values = values;
323
324    let default = parse_default(
325        source.get("default"),
326        field_type,
327        &format!("field \"{name}\" option \"default\""),
328    )?;
329    if let Some(DefaultValue::Value(ref value)) = default {
330        if let Some(values) = &spec.values {
331            if !value.is_null() {
332                let as_text = match value {
333                    Value::String(s) => s.clone(),
334                    other => other.to_string(),
335                };
336                if !values.contains(&as_text) {
337                    return Err(fail(format!(
338                        "enum field \"{name}\" has a default outside of its values"
339                    )));
340                }
341            }
342        }
343    }
344    spec.default = default;
345
346    if field_type.is_array() {
347        if let Some(DefaultValue::Value(Value::Array(items))) = &spec.default {
348            let element = field_type.element_type().expect("array type has an element type");
349            let bad = items.iter().any(|item| match element {
350                FieldType::String => !item.is_string(),
351                FieldType::Bool => !item.is_boolean(),
352                _ => !item.is_number(),
353            });
354            if bad {
355                return Err(fail(format!(
356                    "field \"{name}\" has a default that does not match {}",
357                    field_type.as_str()
358                )));
359            }
360        }
361    }
362
363    Ok(spec)
364}
365
366fn parse_partition_by(
367    raw: Option<&Value>,
368    table: &str,
369    primary_key: &str,
370    fields: &BTreeMap<String, FieldSpec>,
371) -> Result<Option<String>, HaysonSchemaError> {
372    let Some(raw) = raw else { return Ok(None) };
373    let name = raw
374        .as_str()
375        .filter(|s| !s.is_empty())
376        .ok_or_else(|| fail(format!("\"partitionBy\" of table \"{table}\" must name a field")))?;
377    let spec = fields.get(name).ok_or_else(|| {
378        fail(format!(
379            "\"partitionBy\" of table \"{table}\" names an unknown field \"{name}\""
380        ))
381    })?;
382    if name == primary_key {
383        return Err(fail(format!(
384            "\"partitionBy\" of table \"{table}\" cannot be its primary key \"{name}\" — that is a file per record"
385        )));
386    }
387    if spec.field_type != FieldType::String {
388        return Err(fail(format!(
389            "partition field \"{name}\" of table \"{table}\" must be a string, not {}",
390            spec.field_type.as_str()
391        )));
392    }
393    if !spec.required {
394        return Err(fail(format!(
395            "partition field \"{name}\" of table \"{table}\" must be required — every record needs a partition"
396        )));
397    }
398    if spec.unique {
399        return Err(fail(format!(
400            "partition field \"{name}\" of table \"{table}\" cannot be unique — that is a file per record"
401        )));
402    }
403    if spec.cypher || spec.password_hash {
404        return Err(fail(format!(
405            "partition field \"{name}\" of table \"{table}\" cannot be encrypted — its value is the file name"
406        )));
407    }
408    Ok(Some(name.to_string()))
409}
410
411/// Moves the marker of a schema document written before the `"schemaVersion"` rename — the
412/// historical `"hayson"` key — onto the current key, leaving an already-current document alone.
413/// This is for readers of files someone else wrote in the old format (the format-1
414/// `<table>.schema.json` the offline migration rewrites), never for an authoring path:
415/// [`parse_schema`] itself rejects the old key.
416pub(crate) fn with_current_schema_marker(mut document: Value) -> Value {
417    if let Some(object) = document.as_object_mut() {
418        if !object.contains_key("schemaVersion") {
419            if let Some(marker) = object.remove("hayson") {
420                object.insert("schemaVersion".to_string(), marker);
421            }
422        }
423    }
424    document
425}
426
427/// Parses and normalizes a table schema document (the contents of `<table>.schema.json`).
428pub fn parse_schema(input: &Value) -> Result<TableSchema, HaysonSchemaError> {
429    let source = as_object(input, "schema")?;
430
431    if source.get("schemaVersion") != Some(&Value::from(SCHEMA_VERSION)) {
432        return Err(fail(format!(
433            "schema must declare \"schemaVersion\": {SCHEMA_VERSION}, got {:?}",
434            source.get("schemaVersion")
435        )));
436    }
437
438    for key in source.keys() {
439        if !SCHEMA_KEYS.contains(&key.as_str()) {
440            return Err(fail(format!(
441                "schema of table {:?} has an unknown option \"{key}\" — it was most likely written by a newer version of hayson, and guessing what it means would read the table wrongly. Upgrade @theritual/hayson to open it.",
442                source.get("table").and_then(Value::as_str)
443            )));
444        }
445    }
446
447    let table = source
448        .get("table")
449        .and_then(Value::as_str)
450        .filter(|s| is_table_name(s))
451        .ok_or_else(|| fail(format!("schema has an unusable table name {:?}", source.get("table"))))?
452        .to_string();
453
454    let revision = match source.get("revision") {
455        None => 1,
456        Some(Value::Number(n)) if n.is_u64() && n.as_u64().unwrap() >= 1 => n.as_u64().unwrap() as u32,
457        _ => {
458            return Err(fail(format!(
459                "schema of table \"{table}\" must have a positive integer \"revision\""
460            )))
461        }
462    };
463
464    let primary_key = source
465        .get("primaryKey")
466        .and_then(Value::as_str)
467        .filter(|s| !s.is_empty())
468        .ok_or_else(|| fail(format!("schema of table \"{table}\" must name its \"primaryKey\"")))?
469        .to_string();
470
471    let raw_fields = as_object(
472        source.get("fields").unwrap_or(&Value::Null),
473        &format!("\"fields\" of table \"{table}\""),
474    )?;
475    if raw_fields.is_empty() {
476        return Err(fail(format!("schema of table \"{table}\" declares no fields")));
477    }
478
479    let mut fields = BTreeMap::new();
480    for (name, raw) in raw_fields {
481        if !is_field_name(name) {
482            return Err(fail(format!(
483                "field name {name:?} in table \"{table}\" is not usable (reserved or invalid)"
484            )));
485        }
486        fields.insert(name.clone(), parse_field_spec(name, raw)?);
487    }
488
489    {
490        let key = fields.get_mut(&primary_key).ok_or_else(|| {
491            fail(format!(
492                "primary key \"{primary_key}\" of table \"{table}\" is not among its fields"
493            ))
494        })?;
495        if key.field_type != FieldType::String && key.field_type != FieldType::Int {
496            return Err(fail(format!(
497                "primary key \"{primary_key}\" of table \"{table}\" must be a string or an int"
498            )));
499        }
500        if key.cypher || key.password_hash {
501            return Err(fail(format!(
502                "primary key \"{primary_key}\" of table \"{table}\" cannot be encrypted — it is indexed by definition"
503            )));
504        }
505        key.required = true;
506        key.unique = true;
507        key.index = Some(IndexKind::Exact);
508    }
509
510    let partition_by = parse_partition_by(source.get("partitionBy"), &table, &primary_key, &fields)?;
511
512    Ok(TableSchema {
513        table,
514        revision,
515        primary_key,
516        partition_by,
517        fields,
518    })
519}
520
521#[cfg(test)]
522mod tests {
523    use std::fs;
524    use std::path::PathBuf;
525
526    use super::*;
527
528    fn fixture(name: &str) -> Value {
529        let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
530        path.push(format!("../../packages/hayson/test/fixtures/v1/{name}"));
531        let text = fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path:?}: {e}"));
532        serde_json::from_str(&text).unwrap()
533    }
534
535    #[test]
536    fn parses_the_ts_reference_fixture_and_normalizes_the_primary_key() {
537        // NOTE: a v1 fixture is a frozen format-1 file — its marker still sits under "hayson".
538        let schema = parse_schema(&with_current_schema_marker(fixture("unique-field/users.schema.json"))).unwrap();
539        assert_eq!(schema.table, "users");
540        assert_eq!(schema.primary_key, "id");
541        let id = &schema.fields["id"];
542        assert!(id.required);
543        assert!(id.unique);
544        assert_eq!(id.index, Some(IndexKind::Exact));
545
546        let email = &schema.fields["email"];
547        assert!(email.unique);
548        assert_eq!(email.index, Some(IndexKind::Exact));
549
550        let age = &schema.fields["age"];
551        assert_eq!(age.field_type, FieldType::Int);
552        assert_eq!(age.index, Some(IndexKind::Sorted));
553
554        let tags = &schema.fields["tags"];
555        assert_eq!(tags.field_type, FieldType::StringArray);
556    }
557
558    /// 5.0.0 renamed the marker: a caller still passing the old key gets told which key to use,
559    /// not a schema parsed on a guess.
560    #[test]
561    fn rejects_a_schema_document_that_declares_the_pre_rename_marker() {
562        let doc = serde_json::json!({
563            "hayson": 1,
564            "table": "users",
565            "primaryKey": "id",
566            "fields": { "id": { "type": "string" } },
567        });
568        let failure = parse_schema(&doc).unwrap_err();
569        assert!(failure.0.contains("schemaVersion"), "{}", failure.0);
570    }
571
572    #[test]
573    fn moves_a_pre_rename_marker_onto_the_current_key_and_leaves_a_current_one_alone() {
574        let old = with_current_schema_marker(serde_json::json!({ "hayson": 1, "table": "users" }));
575        assert_eq!(old, serde_json::json!({ "table": "users", "schemaVersion": 1 }));
576
577        let current = serde_json::json!({ "schemaVersion": 1, "table": "users" });
578        assert_eq!(with_current_schema_marker(current.clone()), current);
579    }
580
581    #[test]
582    fn rejects_an_unknown_schema_option() {
583        let doc = serde_json::json!({
584            "schemaVersion": 1,
585            "table": "users",
586            "primaryKey": "id",
587            "fields": { "id": { "type": "string" } },
588            "somethingNew": true,
589        });
590        assert!(parse_schema(&doc).is_err());
591    }
592
593    #[test]
594    fn rejects_an_unknown_field_option() {
595        let doc = serde_json::json!({
596            "schemaVersion": 1,
597            "table": "users",
598            "primaryKey": "id",
599            "fields": { "id": { "type": "string", "wat": true } },
600        });
601        assert!(parse_schema(&doc).is_err());
602    }
603
604    #[test]
605    fn rejects_sorted_index_on_a_non_sortable_type() {
606        let doc = serde_json::json!({
607            "schemaVersion": 1,
608            "table": "users",
609            "primaryKey": "id",
610            "fields": {
611                "id": { "type": "string" },
612                "tags": { "type": "string[]", "index": "sorted" },
613            },
614        });
615        assert!(parse_schema(&doc).is_err());
616    }
617
618    #[test]
619    fn rejects_a_cypher_field_carrying_an_index() {
620        let doc = serde_json::json!({
621            "schemaVersion": 1,
622            "table": "users",
623            "primaryKey": "id",
624            "fields": {
625                "id": { "type": "string" },
626                "ssn": { "type": "string", "cypher": true, "index": "exact" },
627            },
628        });
629        assert!(parse_schema(&doc).is_err());
630    }
631
632    #[test]
633    fn rejects_partition_by_naming_the_primary_key() {
634        let doc = serde_json::json!({
635            "schemaVersion": 1,
636            "table": "notes",
637            "primaryKey": "id",
638            "partitionBy": "id",
639            "fields": {
640                "id": { "type": "string" },
641            },
642        });
643        assert!(parse_schema(&doc).is_err());
644    }
645
646    #[test]
647    fn parses_a_partitioned_schema() {
648        let doc = serde_json::json!({
649            "schemaVersion": 1,
650            "table": "notes",
651            "primaryKey": "id",
652            "partitionBy": "kind",
653            "fields": {
654                "id": { "type": "string" },
655                "kind": { "type": "string", "required": true, "index": "exact" },
656                "title": { "type": "string", "required": true, "fulltext": true },
657            },
658        });
659        let schema = parse_schema(&doc).unwrap();
660        assert_eq!(schema.partition_by, Some("kind".to_string()));
661    }
662
663    #[test]
664    fn accepts_markdown_and_image_field_declarations() {
665        let doc = serde_json::json!({
666            "schemaVersion": 1,
667            "table": "docs",
668            "primaryKey": "id",
669            "fields": {
670                "id": { "type": "string" },
671                "page": { "type": "markdown" },
672                "cover": { "type": "image" },
673            },
674        });
675        let schema = parse_schema(&doc).unwrap();
676        assert_eq!(schema.fields["page"].field_type, FieldType::Markdown);
677        assert_eq!(schema.fields["cover"].field_type, FieldType::Image);
678    }
679
680    #[test]
681    fn rejects_unique_declared_directly_on_a_markdown_field() {
682        let doc = serde_json::json!({
683            "schemaVersion": 1,
684            "table": "docs",
685            "primaryKey": "id",
686            "fields": {
687                "id": { "type": "string" },
688                "page": { "type": "markdown", "unique": true },
689            },
690        });
691        assert!(parse_schema(&doc).is_err());
692    }
693
694    #[test]
695    fn rejects_an_index_declared_directly_on_an_image_field() {
696        let doc = serde_json::json!({
697            "schemaVersion": 1,
698            "table": "docs",
699            "primaryKey": "id",
700            "fields": {
701                "id": { "type": "string" },
702                "cover": { "type": "image", "index": "exact" },
703            },
704        });
705        assert!(parse_schema(&doc).is_err());
706    }
707
708    #[test]
709    fn rejects_fulltext_declared_directly_on_a_markdown_field() {
710        let doc = serde_json::json!({
711            "schemaVersion": 1,
712            "table": "docs",
713            "primaryKey": "id",
714            "fields": {
715                "id": { "type": "string" },
716                "page": { "type": "markdown", "fulltext": true },
717            },
718        });
719        assert!(parse_schema(&doc).is_err());
720    }
721
722    fn docs_schema_with(page: serde_json::Value) -> Result<TableSchema, HaysonSchemaError> {
723        parse_schema(&serde_json::json!({
724            "schemaVersion": 1,
725            "table": "docs",
726            "primaryKey": "id",
727            "fields": {
728                "id": { "type": "string" },
729                "page": page,
730            },
731        }))
732    }
733
734    #[test]
735    fn a_markdown_field_is_addressable_unless_it_says_otherwise() {
736        let default = docs_schema_with(serde_json::json!({ "type": "markdown" })).unwrap();
737        let opted_out = docs_schema_with(serde_json::json!({ "type": "markdown", "addressable": false })).unwrap();
738        let opted_in = docs_schema_with(serde_json::json!({ "type": "markdown", "addressable": true })).unwrap();
739
740        assert!(default.fields["page"].addressable);
741        assert!(default.fields["page"].has_virtual_path());
742        assert!(!opted_out.fields["page"].addressable);
743        assert!(
744            !opted_out.fields["page"].has_virtual_path(),
745            "a field nothing can link to holds no virtual path of its own"
746        );
747        assert!(opted_in.fields["page"].has_virtual_path());
748    }
749
750    #[test]
751    fn only_a_markdown_field_may_declare_addressable() {
752        for field_type in ["image", "string", "json"] {
753            let refused = docs_schema_with(serde_json::json!({ "type": field_type, "addressable": false }))
754                .expect_err("only markdown opts out of a virtual path");
755
756            assert!(refused.0.contains("addressable"), "{}", refused.0);
757            assert!(refused.0.contains("markdown"), "{}", refused.0);
758        }
759    }
760
761    #[test]
762    fn addressable_has_to_be_a_boolean() {
763        let refused = docs_schema_with(serde_json::json!({ "type": "markdown", "addressable": "no" }))
764            .expect_err("\"no\" is not a boolean");
765
766        assert!(refused.0.contains("must be a boolean"), "{}", refused.0);
767    }
768
769    #[test]
770    fn a_non_addressable_markdown_field_is_refused_the_same_five_options_but_for_its_own_reason() {
771        let index = docs_schema_with(serde_json::json!({ "type": "markdown", "addressable": false, "index": "exact" }))
772            .expect_err("a whole markdown document is not a lookup key");
773        let unique = docs_schema_with(serde_json::json!({ "type": "markdown", "addressable": false, "unique": true }))
774            .expect_err("a whole markdown document is not a unique key");
775
776        assert!(
777            index.0.contains("not a lookup key"),
778            "the refusal must not still claim the field has an indexed virtual path: {}",
779            index.0
780        );
781        assert!(unique.0.contains("not a key to be unique on"), "{}", unique.0);
782        for option in [
783            serde_json::json!({ "type": "markdown", "addressable": false, "fulltext": true }),
784            serde_json::json!({ "type": "markdown", "addressable": false, "cypher": true }),
785            serde_json::json!({ "type": "markdown", "addressable": false, "default": "x" }),
786        ] {
787            assert!(
788                docs_schema_with(option.clone()).is_err(),
789                "{option} has to stay refused on a non-addressable field too"
790            );
791        }
792    }
793
794    #[test]
795    fn opting_out_survives_the_schema_document_it_is_rendered_into() {
796        let schema = docs_schema_with(serde_json::json!({ "type": "markdown", "addressable": false })).unwrap();
797
798        let again = parse_schema(&super::super::serialize::schema_document(&schema)).unwrap();
799
800        assert_eq!(again, schema);
801        assert!(!again.fields["page"].addressable);
802    }
803}