1use std::collections::BTreeMap;
15
16use serde::Serialize;
17
18#[derive(Debug, Clone, PartialEq, Serialize)]
25pub struct Schema {
26 table: String,
27 title: String,
28 #[serde(skip_serializing_if = "is_false")]
29 sortable: bool,
30 columns: Vec<Column>,
31 new_row: NewRow,
32 datalists: BTreeMap<String, Datalist>,
33}
34
35impl Schema {
36 pub fn new(columns: impl IntoIterator<Item = Column>) -> Self {
37 Self {
38 table: String::new(),
39 title: String::new(),
40 sortable: false,
41 columns: columns.into_iter().collect(),
42 new_row: NewRow::default(),
43 datalists: BTreeMap::new(),
44 }
45 }
46
47 pub fn sortable(mut self) -> Self {
52 self.sortable = true;
53 self
54 }
55
56 pub fn new_row(mut self, new_row: NewRow) -> Self {
57 self.new_row = new_row;
58 self
59 }
60
61 pub fn datalist(mut self, name: impl Into<String>, list: Datalist) -> Self {
62 self.datalists.insert(name.into(), list);
63 self
64 }
65
66 pub(crate) fn identify(&mut self, table: &str, title: &str) {
68 self.table.clear();
69 self.table.push_str(table);
70 self.title.clear();
71 self.title.push_str(title);
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
77#[serde(rename_all = "kebab-case")]
78pub enum ColumnType {
79 String,
81 Text,
83 SpacedString,
85 Number,
87 Boolean,
92 Select,
95 Computed,
97 Map,
99}
100
101#[derive(Debug, Clone, PartialEq, Serialize)]
103pub struct Column {
104 field: String,
105 label: String,
106 #[serde(rename = "type")]
107 kind: ColumnType,
108 #[serde(skip_serializing_if = "is_false")]
109 allow_empty: bool,
110 #[serde(skip_serializing_if = "is_false")]
111 wide: bool,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 width_ch: Option<u16>,
114 #[serde(skip_serializing_if = "Vec::is_empty")]
115 options: Vec<SelectOption>,
116 #[serde(skip_serializing_if = "Option::is_none")]
117 options_by: Option<OptionsBy>,
118 #[serde(skip_serializing_if = "Vec::is_empty")]
119 cascades_to: Vec<String>,
120 #[serde(skip_serializing_if = "is_false")]
121 numeric_value: bool,
122 #[serde(skip_serializing_if = "is_false")]
123 int_only: bool,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 datalist: Option<String>,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 from: Option<String>,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 speak: Option<Speak>,
130 #[serde(skip_serializing_if = "Option::is_none")]
131 href: Option<String>,
132 #[serde(flatten)]
133 map: Option<MapSpec>,
134}
135
136impl Column {
137 fn base(field: impl Into<String>, label: impl Into<String>, kind: ColumnType) -> Self {
138 Self {
139 field: field.into(),
140 label: label.into(),
141 kind,
142 allow_empty: false,
143 wide: false,
144 width_ch: None,
145 options: Vec::new(),
146 options_by: None,
147 cascades_to: Vec::new(),
148 numeric_value: false,
149 int_only: false,
150 datalist: None,
151 from: None,
152 speak: None,
153 href: None,
154 map: None,
155 }
156 }
157
158 pub fn string(field: impl Into<String>, label: impl Into<String>) -> Self {
159 Self::base(field, label, ColumnType::String)
160 }
161
162 pub fn text(field: impl Into<String>, label: impl Into<String>) -> Self {
163 Self::base(field, label, ColumnType::Text)
164 }
165
166 pub fn spaced_string(field: impl Into<String>, label: impl Into<String>) -> Self {
167 Self::base(field, label, ColumnType::SpacedString)
168 }
169
170 pub fn number(field: impl Into<String>, label: impl Into<String>) -> Self {
171 Self::base(field, label, ColumnType::Number)
172 }
173
174 pub fn boolean(field: impl Into<String>, label: impl Into<String>) -> Self {
177 Self::base(field, label, ColumnType::Boolean)
178 }
179
180 pub fn select(
182 field: impl Into<String>,
183 label: impl Into<String>,
184 options: impl IntoIterator<Item = impl Into<SelectOption>>,
185 ) -> Self {
186 Self {
187 options: options.into_iter().map(Into::into).collect(),
188 ..Self::base(field, label, ColumnType::Select)
189 }
190 }
191
192 pub fn select_by(
194 field: impl Into<String>,
195 label: impl Into<String>,
196 options_by: OptionsBy,
197 ) -> Self {
198 Self {
199 options_by: Some(options_by),
200 ..Self::base(field, label, ColumnType::Select)
201 }
202 }
203
204 pub fn computed(
206 field: impl Into<String>,
207 label: impl Into<String>,
208 from: impl Into<String>,
209 ) -> Self {
210 Self {
211 from: Some(from.into()),
212 ..Self::base(field, label, ColumnType::Computed)
213 }
214 }
215
216 pub fn map(field: impl Into<String>, label: impl Into<String>, spec: MapSpec) -> Self {
218 Self {
219 map: Some(spec),
220 ..Self::base(field, label, ColumnType::Map)
221 }
222 }
223
224 pub fn allow_empty(mut self) -> Self {
226 self.allow_empty = true;
227 self
228 }
229
230 pub fn wide(mut self) -> Self {
232 self.wide = true;
233 self
234 }
235
236 pub fn numeric_value(mut self) -> Self {
238 self.numeric_value = true;
239 self
240 }
241
242 pub fn int_only(mut self) -> Self {
245 self.int_only = true;
246 self
247 }
248
249 pub fn width_ch(mut self, width_ch: u16) -> Self {
252 self.width_ch = Some(width_ch);
253 self
254 }
255
256 pub fn cascades_to(mut self, fields: impl IntoIterator<Item = impl Into<String>>) -> Self {
259 self.cascades_to = fields.into_iter().map(Into::into).collect();
260 self
261 }
262
263 pub fn datalist(mut self, name: impl Into<String>) -> Self {
266 self.datalist = Some(name.into());
267 self
268 }
269
270 pub fn speak(mut self, speak: Speak) -> Self {
272 self.speak = Some(speak);
273 self
274 }
275
276 pub fn href(mut self, field: impl Into<String>) -> Self {
285 self.href = Some(field.into());
286 self
287 }
288}
289
290#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
293pub struct SelectOption {
294 pub value: String,
295 #[serde(skip_serializing_if = "Option::is_none")]
296 pub label: Option<String>,
297}
298
299impl SelectOption {
300 pub fn new(value: impl Into<String>) -> Self {
301 Self {
302 value: value.into(),
303 label: None,
304 }
305 }
306
307 pub fn labelled(value: impl Into<String>, label: impl Into<String>) -> Self {
308 Self {
309 value: value.into(),
310 label: Some(label.into()),
311 }
312 }
313}
314
315impl From<&str> for SelectOption {
316 fn from(value: &str) -> Self {
317 Self::new(value)
318 }
319}
320
321impl From<String> for SelectOption {
322 fn from(value: String) -> Self {
323 Self::new(value)
324 }
325}
326
327impl From<&String> for SelectOption {
328 fn from(value: &String) -> Self {
329 Self::new(value.as_str())
330 }
331}
332
333#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
336pub struct OptionsBy {
337 pub field: String,
338 pub options: BTreeMap<String, Vec<SelectOption>>,
339}
340
341impl OptionsBy {
342 pub fn new(field: impl Into<String>) -> Self {
343 Self {
344 field: field.into(),
345 options: BTreeMap::new(),
346 }
347 }
348
349 pub fn with(
350 mut self,
351 value: impl Into<String>,
352 options: impl IntoIterator<Item = impl Into<SelectOption>>,
353 ) -> Self {
354 self.insert(value, options);
355 self
356 }
357
358 pub fn insert(
359 &mut self,
360 value: impl Into<String>,
361 options: impl IntoIterator<Item = impl Into<SelectOption>>,
362 ) {
363 self.options
364 .insert(value.into(), options.into_iter().map(Into::into).collect());
365 }
366}
367
368#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
372pub struct Speak {
373 pub url: String,
374 pub storage_key: String,
375}
376
377impl Speak {
378 pub fn new(url: impl Into<String>, storage_key: impl Into<String>) -> Self {
379 Self {
380 url: url.into(),
381 storage_key: storage_key.into(),
382 }
383 }
384}
385
386#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
393#[serde(rename_all = "lowercase")]
394pub enum ChipContent {
395 #[default]
397 Label,
398 Key,
400}
401
402impl ChipContent {
403 fn is_label(&self) -> bool {
406 matches!(self, ChipContent::Label)
407 }
408}
409
410#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
424pub struct MapSpec {
425 pub key_label: String,
426 pub value_label: String,
427 pub key_options: Vec<SelectOption>,
428 pub value_options: Vec<SelectOption>,
429 pub allow_new_keys: bool,
431 pub allow_new_values: bool,
434 #[serde(skip_serializing_if = "ChipContent::is_label")]
438 pub chip: ChipContent,
439}
440
441impl MapSpec {
442 pub fn new(key_label: impl Into<String>, value_label: impl Into<String>) -> Self {
443 Self {
444 key_label: key_label.into(),
445 value_label: value_label.into(),
446 key_options: Vec::new(),
447 value_options: Vec::new(),
448 allow_new_keys: false,
449 allow_new_values: false,
450 chip: ChipContent::Label,
451 }
452 }
453
454 pub fn key_options(mut self, keys: impl IntoIterator<Item = impl Into<SelectOption>>) -> Self {
458 self.key_options = keys.into_iter().map(Into::into).collect();
459 self
460 }
461
462 pub fn value_options(
463 mut self,
464 values: impl IntoIterator<Item = impl Into<SelectOption>>,
465 ) -> Self {
466 self.value_options = values.into_iter().map(Into::into).collect();
467 self
468 }
469
470 pub fn allow_new_keys(mut self) -> Self {
471 self.allow_new_keys = true;
472 self
473 }
474
475 pub fn allow_new_values(mut self) -> Self {
476 self.allow_new_values = true;
477 self
478 }
479
480 pub fn chips_show_key(mut self) -> Self {
484 self.chip = ChipContent::Key;
485 self
486 }
487}
488
489#[derive(Debug, Clone, PartialEq, Default, Serialize)]
493pub struct NewRow {
494 pub defaults: serde_json::Map<String, serde_json::Value>,
495 pub carry_forward: Vec<String>,
496}
497
498impl NewRow {
499 pub fn new() -> Self {
500 Self::default()
501 }
502
503 pub fn with(mut self, field: impl Into<String>, value: impl Into<serde_json::Value>) -> Self {
504 self.defaults.insert(field.into(), value.into());
505 self
506 }
507
508 pub fn carry_forward(mut self, fields: impl IntoIterator<Item = impl Into<String>>) -> Self {
509 self.carry_forward = fields.into_iter().map(Into::into).collect();
510 self
511 }
512}
513
514#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
518#[serde(untagged)]
519pub enum Datalist {
520 Fixed { options: Vec<String> },
522 Live { from_rows: FromRows },
524}
525
526impl Datalist {
527 pub fn fixed(options: impl IntoIterator<Item = impl Into<String>>) -> Self {
528 Self::Fixed {
529 options: options.into_iter().map(Into::into).collect(),
530 }
531 }
532
533 pub fn from_rows(
534 fields: impl IntoIterator<Item = impl Into<String>>,
535 separator: impl Into<String>,
536 ) -> Self {
537 Self::Live {
538 from_rows: FromRows {
539 fields: fields.into_iter().map(Into::into).collect(),
540 separator: separator.into(),
541 },
542 }
543 }
544}
545
546#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
550pub struct FromRows {
551 pub fields: Vec<String>,
552 pub separator: String,
553}
554
555fn is_false(value: &bool) -> bool {
556 !*value
557}
558
559#[cfg(test)]
560mod tests {
561 use serde_json::json;
562
563 use super::*;
564
565 fn books_schema() -> Schema {
568 let mut schema = Schema::new([
569 Column::string("title", "Title"),
570 Column::select("genre", "Genre", ["Reference", "Travel"])
571 .allow_empty()
572 .cascades_to(["subgenre"]),
573 Column::select_by(
574 "subgenre",
575 "Subgenre",
576 OptionsBy::new("genre")
577 .with("Reference", ["Natural History"])
578 .with("Travel", ["Field Guides"]),
579 )
580 .allow_empty()
581 .width_ch(18),
582 Column::select("format", "Format", ["Hardcover", "Paperback", "Folio"]),
583 Column::number("copies", "Copies").int_only(),
584 Column::boolean("lent", "Lent"),
585 Column::text("comment", "Comment").wide(),
586 ])
587 .new_row(
588 NewRow::new()
589 .with("title", "")
590 .with("genre", "")
591 .with("subgenre", "")
592 .with("format", "Paperback")
593 .with("copies", 1)
594 .with("comment", ""),
595 );
596 schema.identify("books", "Books");
597 schema
598 }
599
600 #[test]
601 fn schema_serializes_to_the_documented_shape() {
602 assert_eq!(
603 serde_json::to_value(books_schema()).unwrap(),
604 json!({
605 "table": "books",
606 "title": "Books",
607 "columns": [
608 { "field": "title", "label": "Title", "type": "string" },
609 { "field": "genre", "label": "Genre", "type": "select", "allow_empty": true,
610 "options": [{ "value": "Reference" }, { "value": "Travel" }],
611 "cascades_to": ["subgenre"] },
612 { "field": "subgenre", "label": "Subgenre", "type": "select", "allow_empty": true,
613 "width_ch": 18,
614 "options_by": { "field": "genre",
615 "options": { "Reference": [{ "value": "Natural History" }],
616 "Travel": [{ "value": "Field Guides" }] } } },
617 { "field": "format", "label": "Format", "type": "select",
618 "options": [{ "value": "Hardcover" }, { "value": "Paperback" }, { "value": "Folio" }] },
619 { "field": "copies", "label": "Copies", "type": "number", "int_only": true },
620 { "field": "lent", "label": "Lent", "type": "boolean" },
621 { "field": "comment", "label": "Comment", "type": "text", "wide": true }
622 ],
623 "new_row": { "defaults": { "title": "", "genre": "", "subgenre": "", "format": "Paperback",
624 "copies": 1, "comment": "" },
625 "carry_forward": [] },
626 "datalists": {}
627 })
628 );
629 }
630
631 #[test]
632 fn identify_replaces_whatever_was_there() {
633 let mut schema = Schema::new([]);
634 schema.identify("first", "First");
635 schema.identify("second", "Second");
636 let json = serde_json::to_value(schema).unwrap();
637 assert_eq!(json["table"], "second");
638 assert_eq!(json["title"], "Second");
639 }
640
641 #[test]
642 fn map_column_serializes_to_the_documented_shape() {
643 let column = Column::map(
644 "shelved",
645 "Shelved",
646 MapSpec::new("Branch", "Count")
647 .key_options(["Central", "Eastside", "Harbour"])
648 .value_options(["None", "One", "Several"]),
649 );
650
651 assert_eq!(
652 serde_json::to_value(column).unwrap(),
653 json!({ "field": "shelved", "label": "Shelved", "type": "map",
654 "key_label": "Branch", "value_label": "Count",
655 "key_options": [{ "value": "Central" }, { "value": "Eastside" },
656 { "value": "Harbour" }],
657 "value_options": [{ "value": "None" }, { "value": "One" },
658 { "value": "Several" }],
659 "allow_new_keys": false, "allow_new_values": false })
660 );
661 }
662
663 #[test]
664 fn a_chip_shows_the_key_only_where_a_table_asks_for_it() {
665 let by_label = serde_json::to_value(MapSpec::new("Branch", "Count")).unwrap();
666 assert!(by_label.get("chip").is_none());
667
668 let by_key =
669 serde_json::to_value(MapSpec::new("Branch", "Count").chips_show_key()).unwrap();
670 assert_eq!(by_key["chip"], "key");
671 }
672
673 #[test]
674 fn a_maps_option_lists_are_written_even_when_empty() {
675 assert_eq!(
676 serde_json::to_value(MapSpec::new("Branch", "Count")).unwrap(),
677 json!({ "key_label": "Branch", "value_label": "Count",
678 "key_options": [], "value_options": [],
679 "allow_new_keys": false, "allow_new_values": false })
680 );
681
682 let select = serde_json::to_value(Column::select_by(
684 "subgenre",
685 "Subgenre",
686 OptionsBy::new("genre"),
687 ))
688 .unwrap();
689 assert!(select.get("options").is_none());
690 }
691
692 #[test]
693 fn a_map_key_can_show_a_label_beside_the_stored_value() {
694 let spec = MapSpec::new("Branch", "Count")
695 .key_options([SelectOption::labelled("hb", "Harbour"), "Central".into()]);
696
697 assert_eq!(
698 serde_json::to_value(spec).unwrap()["key_options"],
699 json!([{ "value": "hb", "label": "Harbour" }, { "value": "Central" }])
700 );
701 }
702
703 #[test]
704 fn a_map_can_take_values_its_options_do_not_list() {
705 let open = serde_json::to_value(
706 MapSpec::new("Branch", "Note")
707 .value_options(["None"])
708 .allow_new_values(),
709 )
710 .unwrap();
711 assert_eq!(open["allow_new_values"], true);
712 assert_eq!(open["value_options"], json!([{ "value": "None" }]));
713 }
714
715 #[test]
716 fn column_types_serialize_in_kebab_case() {
717 for (column, name) in [
718 (Column::string("f", "F"), "string"),
719 (Column::text("f", "F"), "text"),
720 (Column::spaced_string("f", "F"), "spaced-string"),
721 (Column::number("f", "F"), "number"),
722 (Column::boolean("f", "F"), "boolean"),
723 (Column::select("f", "F", ["a"]), "select"),
724 (Column::select_by("f", "F", OptionsBy::new("g")), "select"),
725 (Column::computed("f", "F", "k"), "computed"),
726 (Column::map("f", "F", MapSpec::new("K", "V")), "map"),
727 ] {
728 assert_eq!(serde_json::to_value(column).unwrap()["type"], name);
729 }
730 }
731
732 #[test]
733 fn int_only_is_omitted_unless_set() {
734 let plain = serde_json::to_value(Column::number("copies", "Copies")).unwrap();
735 assert!(plain.get("int_only").is_none());
736
737 let whole = serde_json::to_value(Column::number("copies", "Copies").int_only()).unwrap();
738 assert_eq!(whole["int_only"], true);
739 }
740
741 #[test]
742 fn a_select_always_carries_one_source_of_options() {
743 let fixed = serde_json::to_value(Column::select("f", "F", ["a"])).unwrap();
744 assert!(fixed.get("options").is_some());
745 assert!(fixed.get("options_by").is_none());
746
747 let dependent =
748 serde_json::to_value(Column::select_by("f", "F", OptionsBy::new("g"))).unwrap();
749 assert!(dependent.get("options").is_none());
750 assert!(dependent.get("options_by").is_some());
751 }
752
753 #[test]
754 fn absent_options_are_omitted_rather_than_null() {
755 let json = serde_json::to_value(Column::string("title", "Title")).unwrap();
756 let object = json.as_object().unwrap();
757 assert_eq!(
758 object.keys().map(String::as_str).collect::<Vec<_>>(),
759 ["field", "label", "type"]
760 );
761 }
762
763 #[test]
764 fn computed_column_names_its_derived_key() {
765 let json = serde_json::to_value(Column::computed("age", "Age", "age_years")).unwrap();
766 assert_eq!(json["type"], "computed");
767 assert_eq!(json["from"], "age_years");
768 }
769
770 #[test]
771 fn a_column_names_the_datalist_its_input_offers() {
772 let column = Column::string("author_last", "Author").datalist("author-names");
773 assert_eq!(
774 serde_json::to_value(column).unwrap()["datalist"],
775 "author-names"
776 );
777 }
778
779 #[test]
780 fn labelled_and_numeric_options_carry_both_halves() {
781 let column = Column::select(
782 "month",
783 "Month",
784 [SelectOption::labelled("1", "January (1)")],
785 )
786 .numeric_value();
787 let json = serde_json::to_value(column).unwrap();
788 assert_eq!(
789 json["options"][0],
790 json!({ "value": "1", "label": "January (1)" })
791 );
792 assert_eq!(json["numeric_value"], true);
793 }
794
795 #[test]
796 fn speak_carries_the_url_template_and_storage_key() {
797 let column = Column::string("pronunciation", "Pronunciation").speak(Speak::new(
798 "http://127.0.0.1:8765/say?text={value}",
799 "speech-service-url",
800 ));
801 assert_eq!(
802 serde_json::to_value(column).unwrap()["speak"],
803 json!({ "url": "http://127.0.0.1:8765/say?text={value}",
804 "storage_key": "speech-service-url" })
805 );
806 }
807
808 #[test]
809 fn both_datalist_forms_serialize_by_their_own_key() {
810 let schema = Schema::new([])
811 .datalist("genre-names", Datalist::fixed(["Reference", "Travel"]))
812 .datalist(
813 "author-names",
814 Datalist::from_rows(["author_first", "author_last"], " "),
815 );
816
817 assert_eq!(
818 serde_json::to_value(schema).unwrap()["datalists"],
819 json!({
820 "genre-names": { "options": ["Reference", "Travel"] },
821 "author-names": { "from_rows": { "fields": ["author_first", "author_last"],
822 "separator": " " } }
823 })
824 );
825 }
826
827 #[test]
828 fn sortable_is_omitted_unless_set() {
829 let plain = serde_json::to_value(Schema::new([])).unwrap();
830 assert!(plain.get("sortable").is_none());
831
832 let sorted = serde_json::to_value(Schema::new([]).sortable()).unwrap();
833 assert_eq!(sorted["sortable"], true);
834 }
835
836 #[test]
837 fn new_row_carries_defaults_and_carried_fields() {
838 let new_row = NewRow::new()
839 .with("genre", "Reference")
840 .with("copies", 1)
841 .carry_forward(["genre", "subgenre", "format"]);
842 assert_eq!(
843 serde_json::to_value(new_row).unwrap(),
844 json!({ "defaults": { "genre": "Reference", "copies": 1 },
845 "carry_forward": ["genre", "subgenre", "format"] })
846 );
847 }
848}