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