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 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 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 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
411pub(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
427pub 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 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 #[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}