1use crate::edit::{ExprSpec, FunctionSpec, StepSpec, TypeDefSpec};
26use crate::node::{BinOp, Param, Produces};
27use crate::ty::{Confidence, Effect, Type};
28use serde::{Deserialize, Serialize};
29use std::collections::BTreeSet;
30
31#[derive(Clone, Debug, Serialize, Deserialize)]
35pub enum FieldKind {
36 Num,
39 Text,
42 Decimal,
46 Variant { decode: String, encode: String },
51}
52
53#[derive(Clone, Debug, Serialize, Deserialize)]
58pub struct FieldSpec {
59 pub field: String,
60 pub column: String,
61 pub kind: FieldKind,
62}
63
64impl FieldSpec {
65 pub fn new(field: &str, kind: FieldKind) -> Self {
67 FieldSpec {
68 field: field.into(),
69 column: field.into(),
70 kind,
71 }
72 }
73 pub fn col(field: &str, column: &str, kind: FieldKind) -> Self {
75 FieldSpec {
76 field: field.into(),
77 column: column.into(),
78 kind,
79 }
80 }
81}
82
83#[derive(Clone, Debug, Serialize, Deserialize)]
88pub struct EntitySpec {
89 pub record: String,
90 pub table: String,
91 pub rows_fn: String,
92 pub save_fn: String,
93 pub save_param: String,
94 pub fields: Vec<FieldSpec>,
95}
96
97const RESERVED_SAVE_PARAMS: &[&str] = &["i", "n", "ins"];
102
103impl EntitySpec {
104 pub fn validate(&self) -> Result<(), String> {
113 if self.fields.is_empty() {
114 return Err(
115 "EntitySpec.fields is empty: an entity needs at \
116 least an `id` column"
117 .into(),
118 );
119 }
120 if self.fields[0].column != "id" {
126 return Err(format!(
127 "EntitySpec.fields[0] column is `{}`, must be `id`: \
128 the first field is the INTEGER PRIMARY KEY and the \
129 generated SELECT is `ORDER BY id`. A FieldSpec is \
130 [field_name, sql_column_name, kind] — the middle \
131 element is the column name, NOT a Cairn type.",
132 self.fields[0].column
133 ));
134 }
135 if RESERVED_SAVE_PARAMS.contains(&self.save_param.as_str()) {
136 return Err(format!(
137 "EntitySpec.save_param `{}` collides with a binding \
138 the generated save/save_step use internally \
139 ({:?}); pick another name (the CRM uses \
140 `cs`/`js`/`xs`).",
141 self.save_param, RESERVED_SAVE_PARAMS
142 ));
143 }
144 Ok(())
145 }
146}
147
148fn rref(n: &str) -> ExprSpec {
152 ExprSpec::Ref(n.into())
153}
154fn call(func: &str, args: Vec<ExprSpec>) -> ExprSpec {
155 ExprSpec::Call {
156 func: func.into(),
157 args,
158 }
159}
160fn bin(op: BinOp, a: ExprSpec, b: ExprSpec) -> ExprSpec {
161 ExprSpec::BinOp {
162 op,
163 lhs: Box::new(a),
164 rhs: Box::new(b),
165 }
166}
167fn s2n(e: ExprSpec) -> ExprSpec {
168 ExprSpec::StrToNumber(Box::new(e))
169}
170fn n2s(e: ExprSpec) -> ExprSpec {
171 ExprSpec::NumberToStr(Box::new(e))
172}
173fn p(name: &str, ty: Type) -> Param {
174 Param {
175 name: name.into(),
176 ty,
177 min_confidence: Confidence::External,
178 }
179}
180fn ext(ty: Type) -> Produces {
181 Produces {
182 ty,
183 confidence: Confidence::External,
184 }
185}
186fn db_eff() -> BTreeSet<Effect> {
187 [Effect::Db].into_iter().collect()
188}
189fn field_at(k: i64) -> ExprSpec {
191 call(
192 "field",
193 vec![
194 ExprSpec::ListGet {
195 list: Box::new(rref("rows")),
196 index: Box::new(rref("i")),
197 },
198 ExprSpec::Lit(k),
199 ],
200 )
201}
202fn item_field(param: &str, record: &str, field: &str) -> ExprSpec {
204 ExprSpec::Field {
205 base: Box::new(ExprSpec::ListGet {
206 list: Box::new(rref(param)),
207 index: Box::new(rref("i")),
208 }),
209 type_name: record.into(),
210 field: field.into(),
211 }
212}
213
214fn decode(kind: &FieldKind, k: i64) -> ExprSpec {
217 let f = field_at(k);
218 match kind {
219 FieldKind::Num => s2n(f),
220 FieldKind::Text => f,
221 FieldKind::Decimal => ExprSpec::DecimalOp {
222 op: BinOp::Div,
223 lhs: Box::new(ExprSpec::IntToDecimal(Box::new(s2n(f)))),
224 rhs: Box::new(ExprSpec::Decimal(10000.0)),
225 },
226 FieldKind::Variant { decode, .. } => call(decode, vec![f]),
227 }
228}
229
230fn encode(fs: &FieldSpec, param: &str, record: &str) -> ExprSpec {
233 let v = item_field(param, record, &fs.field);
234 match &fs.kind {
235 FieldKind::Num => n2s(v),
236 FieldKind::Text => v,
237 FieldKind::Decimal => n2s(ExprSpec::DecimalRaw(Box::new(v))),
238 FieldKind::Variant { encode, .. } => call(encode, vec![v]),
239 }
240}
241
242fn sql_type(k: &FieldKind) -> &'static str {
247 match k {
248 FieldKind::Num | FieldKind::Decimal => "INTEGER",
249 FieldKind::Text | FieldKind::Variant { .. } => "TEXT",
250 }
251}
252
253pub fn create_table(s: &EntitySpec) -> String {
257 let cols: Vec<String> = s
258 .fields
259 .iter()
260 .enumerate()
261 .map(|(i, f)| {
262 if i == 0 {
263 format!("{} INTEGER PRIMARY KEY", f.column)
264 } else {
265 format!("{} {}", f.column, sql_type(&f.kind))
266 }
267 })
268 .collect();
269 format!(
270 "CREATE TABLE IF NOT EXISTS {} ({})",
271 s.table,
272 cols.join(", ")
273 )
274}
275
276pub fn select_all(s: &EntitySpec) -> String {
280 let cols: Vec<&str> =
281 s.fields.iter().map(|f| f.column.as_str()).collect();
282 format!(
283 "SELECT {} FROM {} ORDER BY id",
284 cols.join(", "),
285 s.table
286 )
287}
288
289pub fn from_rows(s: &EntitySpec) -> FunctionSpec {
291 let elem = Type::Named(s.record.clone());
292 let fields: Vec<(String, ExprSpec)> = s
293 .fields
294 .iter()
295 .enumerate()
296 .map(|(k, fs)| (fs.field.clone(), decode(&fs.kind, k as i64)))
297 .collect();
298 FunctionSpec {
299 name: s.rows_fn.clone(),
300 type_params: vec![],
301 params: vec![
302 p("rows", Type::List(Box::new(Type::String))),
303 p("i", Type::Number),
304 ],
305 produces: ext(Type::List(Box::new(elem.clone()))),
306 requires: BTreeSet::new(),
307 on_failure: vec![],
308 steps: vec![StepSpec {
309 binding: "n".into(),
310 value: ExprSpec::ListLen(Box::new(rref("rows"))),
311 }],
312 result: ExprSpec::If {
313 cond: Box::new(bin(BinOp::Eq, rref("i"), rref("n"))),
314 then_branch: Box::new(ExprSpec::ListEmpty {
315 elem: elem.clone(),
316 }),
317 else_branch: Box::new(ExprSpec::ListCons {
318 head: Box::new(ExprSpec::Record {
319 type_name: s.record.clone(),
320 fields,
321 }),
322 tail: Box::new(call(
323 &s.rows_fn,
324 vec![
325 rref("rows"),
326 bin(BinOp::Add, rref("i"), ExprSpec::Lit(1)),
327 ],
328 )),
329 }),
330 },
331 }
332}
333
334pub fn save_pair(s: &EntitySpec) -> (FunctionSpec, FunctionSpec) {
339 let list_ty = Type::List(Box::new(Type::Named(s.record.clone())));
340 let step_fn = format!("{}_step", s.save_fn);
341 let prm = s.save_param.as_str();
342
343 let save = FunctionSpec {
344 name: s.save_fn.clone(),
345 type_params: vec![],
346 params: vec![p(prm, list_ty.clone()), p("i", Type::Number)],
347 produces: ext(Type::Number),
348 requires: db_eff(),
349 on_failure: vec![],
350 steps: vec![StepSpec {
351 binding: "n".into(),
352 value: ExprSpec::ListLen(Box::new(rref(prm))),
353 }],
354 result: ExprSpec::If {
355 cond: Box::new(bin(BinOp::Eq, rref("i"), rref("n"))),
356 then_branch: Box::new(ExprSpec::Lit(0)),
357 else_branch: Box::new(call(
358 &step_fn,
359 vec![rref(prm), rref("i")],
360 )),
361 },
362 };
363
364 let cols: Vec<&str> =
365 s.fields.iter().map(|f| f.column.as_str()).collect();
366 let qs: Vec<&str> = s.fields.iter().map(|_| "?").collect();
367 let sql = format!(
368 "INSERT INTO {} ({}) VALUES ({})",
369 s.table,
370 cols.join(", "),
371 qs.join(", "),
372 );
373 let params: Vec<ExprSpec> = s
374 .fields
375 .iter()
376 .map(|fs| encode(fs, prm, &s.record))
377 .collect();
378
379 let step = FunctionSpec {
380 name: step_fn,
381 type_params: vec![],
382 params: vec![p(prm, list_ty), p("i", Type::Number)],
383 produces: ext(Type::Number),
384 requires: db_eff(),
385 on_failure: vec![],
386 steps: vec![StepSpec {
387 binding: "ins".into(),
388 value: ExprSpec::DbQuery {
389 sql: Box::new(ExprSpec::Str(sql)),
390 params: Box::new(ExprSpec::List(params)),
391 },
392 }],
393 result: call(
394 &s.save_fn,
395 vec![
396 rref(prm),
397 bin(BinOp::Add, rref("i"), ExprSpec::Lit(1)),
398 ],
399 ),
400 };
401 (save, step)
402}
403
404#[derive(Clone, Debug, Serialize, Deserialize)]
417pub struct AppSpec {
418 pub app: String,
423}
424
425fn named(n: &str) -> Type {
426 Type::Named(n.into())
427}
428fn no_eff() -> BTreeSet<Effect> {
429 BTreeSet::new()
430}
431fn db_time() -> BTreeSet<Effect> {
432 [Effect::Db, Effect::Time].into_iter().collect()
433}
434fn func(
435 name: &str,
436 params: Vec<Param>,
437 out: Type,
438 requires: BTreeSet<Effect>,
439 result: ExprSpec,
440) -> FunctionSpec {
441 FunctionSpec {
442 name: name.into(),
443 type_params: vec![],
444 params,
445 produces: ext(out),
446 requires,
447 on_failure: vec![],
448 steps: vec![],
449 result,
450 }
451}
452
453pub fn app_skeleton(spec: &AppSpec) -> (Vec<TypeDefSpec>, Vec<FunctionSpec>) {
468 let app = spec.app.as_str();
469 let msg = format!("{app}Msg");
470 let pfx = app.to_lowercase();
471
472 let types = vec![
473 TypeDefSpec::Record {
474 name: app.into(),
475 fields: vec![("count".into(), Type::Number)],
476 },
477 TypeDefSpec::Variant {
478 name: msg.clone(),
479 cases: vec![("Touch".into(), vec![])],
480 },
481 ];
482
483 let model = || ExprSpec::Record {
484 type_name: app.into(),
485 fields: vec![("count".into(), ExprSpec::Lit(0))],
486 };
487
488 let route_msg = func(
492 &format!("{pfx}_route_msg"),
493 vec![p("req", named("Request"))],
494 named(&msg),
495 no_eff(),
496 ExprSpec::Variant {
497 type_name: msg.clone(),
498 case: "Touch".into(),
499 fields: vec![],
500 },
501 );
502 let load = func(
505 &format!("{pfx}_load"),
506 vec![],
507 named(app),
508 no_eff(),
509 model(),
510 );
511 let update = func(
514 &format!("{pfx}_update"),
515 vec![p("m", named(app)), p("msg", named(&msg))],
516 named(app),
517 no_eff(),
518 ExprSpec::Match {
519 scrutinee: Box::new(rref("msg")),
520 type_name: msg.clone(),
521 arms: vec![("Touch".into(), vec![], rref("m"))],
522 },
523 );
524 let view = func(
527 &format!("{pfx}_view"),
528 vec![p("m", named(app))],
529 named("Element"),
530 no_eff(),
531 ExprSpec::Variant {
532 type_name: "Element".into(),
533 case: "El".into(),
534 fields: vec![
535 ("tag".into(), ExprSpec::Str("main".into())),
536 (
537 "kids".into(),
538 ExprSpec::List(vec![ExprSpec::Variant {
539 type_name: "Element".into(),
540 case: "Text".into(),
541 fields: vec![(
542 "content".into(),
543 ExprSpec::StrConcat(
544 Box::new(ExprSpec::Str(format!(
545 "{app} skeleton — fill in view; count="
546 ))),
547 Box::new(ExprSpec::NumberToStr(Box::new(
548 ExprSpec::Field {
549 base: Box::new(rref("m")),
550 type_name: app.into(),
551 field: "count".into(),
552 },
553 ))),
554 ),
555 )],
556 }]),
557 ),
558 ],
559 },
560 );
561 let persist = func(
565 &format!("{pfx}_persist"),
566 vec![p("m", named(app))],
567 Type::Number,
568 no_eff(),
569 ExprSpec::Lit(0),
570 );
571 let route = func(
577 "route",
578 vec![p("req", named("Request"))],
579 named("Response"),
580 db_time(),
581 ExprSpec::Call {
582 func: "run_app".into(),
583 args: vec![
584 rref("req"),
585 ExprSpec::FuncRef(format!("{pfx}_route_msg")),
586 ExprSpec::FuncRef(format!("{pfx}_load")),
587 ExprSpec::FuncRef(format!("{pfx}_update")),
588 ExprSpec::FuncRef(format!("{pfx}_view")),
589 ExprSpec::FuncRef(format!("{pfx}_persist")),
590 ],
591 },
592 );
593
594 (
595 types,
596 vec![route_msg, load, update, view, persist, route],
597 )
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603
604 fn contact() -> EntitySpec {
605 EntitySpec {
606 record: "Contact".into(),
607 table: "contacts".into(),
608 rows_fn: "contacts_from_rows".into(),
609 save_fn: "crm_save_contacts".into(),
610 save_param: "cs".into(),
611 fields: vec![
612 FieldSpec::new("id", FieldKind::Num),
613 FieldSpec::new("name", FieldKind::Text),
614 FieldSpec::new("phone", FieldKind::Text),
615 FieldSpec::new("kind", FieldKind::Text),
616 ],
617 }
618 }
619
620 #[test]
622 fn from_rows_is_byte_identical_to_the_hand_form() {
623 let gen = from_rows(&contact());
624 let hand = FunctionSpec {
625 name: "contacts_from_rows".into(),
626 type_params: vec![],
627 params: vec![
628 p("rows", Type::List(Box::new(Type::String))),
629 p("i", Type::Number),
630 ],
631 produces: ext(Type::List(Box::new(Type::Named(
632 "Contact".into(),
633 )))),
634 requires: BTreeSet::new(),
635 on_failure: vec![],
636 steps: vec![StepSpec {
637 binding: "n".into(),
638 value: ExprSpec::ListLen(Box::new(rref("rows"))),
639 }],
640 result: ExprSpec::If {
641 cond: Box::new(bin(BinOp::Eq, rref("i"), rref("n"))),
642 then_branch: Box::new(ExprSpec::ListEmpty {
643 elem: Type::Named("Contact".into()),
644 }),
645 else_branch: Box::new(ExprSpec::ListCons {
646 head: Box::new(ExprSpec::Record {
647 type_name: "Contact".into(),
648 fields: vec![
649 ("id".into(), s2n(field_at(0))),
650 ("name".into(), field_at(1)),
651 ("phone".into(), field_at(2)),
652 ("kind".into(), field_at(3)),
653 ],
654 }),
655 tail: Box::new(call(
656 "contacts_from_rows",
657 vec![
658 rref("rows"),
659 bin(BinOp::Add, rref("i"), ExprSpec::Lit(1)),
660 ],
661 )),
662 }),
663 },
664 };
665 assert_eq!(
666 serde_json::to_string(&gen).unwrap(),
667 serde_json::to_string(&hand).unwrap(),
668 "generated *_from_rows must be byte-identical to hand"
669 );
670 }
671
672 #[test]
675 fn save_pair_is_byte_identical_to_the_hand_form() {
676 let (save, step) = save_pair(&contact());
677
678 let hand_save = FunctionSpec {
679 name: "crm_save_contacts".into(),
680 type_params: vec![],
681 params: vec![
682 p(
683 "cs",
684 Type::List(Box::new(Type::Named("Contact".into()))),
685 ),
686 p("i", Type::Number),
687 ],
688 produces: ext(Type::Number),
689 requires: db_eff(),
690 on_failure: vec![],
691 steps: vec![StepSpec {
692 binding: "n".into(),
693 value: ExprSpec::ListLen(Box::new(rref("cs"))),
694 }],
695 result: ExprSpec::If {
696 cond: Box::new(bin(BinOp::Eq, rref("i"), rref("n"))),
697 then_branch: Box::new(ExprSpec::Lit(0)),
698 else_branch: Box::new(call(
699 "crm_save_contacts_step",
700 vec![rref("cs"), rref("i")],
701 )),
702 },
703 };
704 let hand_step = FunctionSpec {
705 name: "crm_save_contacts_step".into(),
706 type_params: vec![],
707 params: vec![
708 p(
709 "cs",
710 Type::List(Box::new(Type::Named("Contact".into()))),
711 ),
712 p("i", Type::Number),
713 ],
714 produces: ext(Type::Number),
715 requires: db_eff(),
716 on_failure: vec![],
717 steps: vec![StepSpec {
718 binding: "ins".into(),
719 value: ExprSpec::DbQuery {
720 sql: Box::new(ExprSpec::Str(
721 "INSERT INTO contacts (id, name, phone, kind) \
722 VALUES (?, ?, ?, ?)"
723 .into(),
724 )),
725 params: Box::new(ExprSpec::List(vec![
726 n2s(item_field("cs", "Contact", "id")),
727 item_field("cs", "Contact", "name"),
728 item_field("cs", "Contact", "phone"),
729 item_field("cs", "Contact", "kind"),
730 ])),
731 },
732 }],
733 result: call(
734 "crm_save_contacts",
735 vec![
736 rref("cs"),
737 bin(BinOp::Add, rref("i"), ExprSpec::Lit(1)),
738 ],
739 ),
740 };
741 assert_eq!(
742 serde_json::to_string(&save).unwrap(),
743 serde_json::to_string(&hand_save).unwrap(),
744 "generated save must be byte-identical to hand"
745 );
746 assert_eq!(
747 serde_json::to_string(&step).unwrap(),
748 serde_json::to_string(&hand_step).unwrap(),
749 "generated save_step must be byte-identical to hand"
750 );
751 }
752
753 #[test]
754 fn ddl_and_select_are_byte_identical_to_the_hand_constants() {
755 assert_eq!(
757 create_table(&contact()),
758 "CREATE TABLE IF NOT EXISTS contacts \
759 (id INTEGER PRIMARY KEY, name TEXT, phone TEXT, kind TEXT)"
760 );
761 assert_eq!(
762 select_all(&contact()),
763 "SELECT id, name, phone, kind FROM contacts ORDER BY id"
764 );
765 let li = EntitySpec {
768 record: "LineItem".into(),
769 table: "line_items".into(),
770 rows_fn: "lineitems_from_rows".into(),
771 save_fn: "crm_save_lines".into(),
772 save_param: "xs".into(),
773 fields: vec![
774 FieldSpec::new("id", FieldKind::Num),
775 FieldSpec::new("estimate_id", FieldKind::Num),
776 FieldSpec::new("description", FieldKind::Text),
777 FieldSpec::new("qty", FieldKind::Num),
778 FieldSpec::col(
779 "unit_price",
780 "price_raw",
781 FieldKind::Decimal,
782 ),
783 ],
784 };
785 assert_eq!(
786 create_table(&li),
787 "CREATE TABLE IF NOT EXISTS line_items \
788 (id INTEGER PRIMARY KEY, estimate_id INTEGER, \
789 description TEXT, qty INTEGER, price_raw INTEGER)"
790 );
791 assert_eq!(
792 select_all(&li),
793 "SELECT id, estimate_id, description, qty, price_raw \
794 FROM line_items ORDER BY id"
795 );
796 }
797
798 #[test]
799 fn decimal_and_variant_round_trip_match_documented_forms() {
800 let d = decode(&FieldKind::Decimal, 4);
802 let want = ExprSpec::DecimalOp {
803 op: BinOp::Div,
804 lhs: Box::new(ExprSpec::IntToDecimal(Box::new(s2n(
805 field_at(4),
806 )))),
807 rhs: Box::new(ExprSpec::Decimal(10000.0)),
808 };
809 assert_eq!(
810 serde_json::to_string(&d).unwrap(),
811 serde_json::to_string(&want).unwrap()
812 );
813 let fs = FieldSpec::col(
815 "unit_price",
816 "price_raw",
817 FieldKind::Decimal,
818 );
819 let e = encode(&fs, "xs", "LineItem");
820 let want_e = n2s(ExprSpec::DecimalRaw(Box::new(item_field(
821 "xs",
822 "LineItem",
823 "unit_price",
824 ))));
825 assert_eq!(
826 serde_json::to_string(&e).unwrap(),
827 serde_json::to_string(&want_e).unwrap()
828 );
829 let vk = FieldKind::Variant {
831 decode: "status_of_str".into(),
832 encode: "status_to_str".into(),
833 };
834 assert_eq!(
835 serde_json::to_string(&decode(&vk, 3)).unwrap(),
836 serde_json::to_string(&call(
837 "status_of_str",
838 vec![field_at(3)]
839 ))
840 .unwrap()
841 );
842 let vfs = FieldSpec::new("status", vk);
843 assert_eq!(
844 serde_json::to_string(&encode(&vfs, "js", "Job")).unwrap(),
845 serde_json::to_string(&call(
846 "status_to_str",
847 vec![item_field("js", "Job", "status")]
848 ))
849 .unwrap()
850 );
851 }
852
853 #[test]
860 fn app_skeleton_emits_the_canonical_tea_shape() {
861 let (types, fns) = app_skeleton(&AppSpec {
862 app: "Counter".into(),
863 });
864
865 match &types[0] {
867 TypeDefSpec::Record { name, fields } => {
868 assert_eq!(name, "Counter");
869 assert_eq!(fields[0].0, "count");
870 }
871 _ => panic!("first type is the model record"),
872 }
873 match &types[1] {
874 TypeDefSpec::Variant { name, cases } => {
875 assert_eq!(name, "CounterMsg");
876 assert_eq!(cases[0].0, "Touch");
877 }
878 _ => panic!("second type is the Msg variant"),
879 }
880
881 let names: Vec<&str> =
882 fns.iter().map(|f| f.name.as_str()).collect();
883 assert_eq!(
884 names,
885 vec![
886 "counter_route_msg",
887 "counter_load",
888 "counter_update",
889 "counter_view",
890 "counter_persist",
891 "route",
892 ],
893 "the five TEA functions (prefixed) + the `route` entry"
894 );
895
896 let by = |n: &str| fns.iter().find(|f| f.name == n).unwrap();
897 let dt: BTreeSet<Effect> =
900 [Effect::Db, Effect::Time].into_iter().collect();
901 assert_eq!(by("route").requires, dt);
902 for n in [
903 "counter_route_msg",
904 "counter_load",
905 "counter_update",
906 "counter_view",
907 "counter_persist",
908 ] {
909 assert!(
910 by(n).requires.is_empty(),
911 "{n} is pure in the skeleton"
912 );
913 }
914 match &by("route").result {
917 ExprSpec::Call { func, args } => {
918 assert_eq!(func, "run_app");
919 assert!(matches!(&args[0], ExprSpec::Ref(r) if r == "req"));
920 let refs: Vec<&str> = args[1..]
921 .iter()
922 .map(|a| match a {
923 ExprSpec::FuncRef(n) => n.as_str(),
924 _ => panic!("run_app args are FuncRefs"),
925 })
926 .collect();
927 assert_eq!(
928 refs,
929 vec![
930 "counter_route_msg",
931 "counter_load",
932 "counter_update",
933 "counter_view",
934 "counter_persist"
935 ]
936 );
937 }
938 _ => panic!("route composes run_app"),
939 }
940 }
941
942 #[test]
945 fn entity_spec_validate_rejects_malformed_specs() {
946 assert!(contact().validate().is_ok());
948
949 let mut e = contact();
951 e.fields = vec![];
952 assert!(e.validate().is_err());
953
954 let bad = EntitySpec {
957 record: "Note".into(),
958 table: "notes".into(),
959 rows_fn: "note_from_rows".into(),
960 save_fn: "note_save".into(),
961 save_param: "notes".into(),
962 fields: vec![
963 FieldSpec::col("id", "Number", FieldKind::Num),
964 FieldSpec::col("body", "String", FieldKind::Text),
965 ],
966 };
967 let err = bad.validate().unwrap_err();
968 assert!(
969 err.contains("must be `id`") && err.contains("NOT a Cairn type"),
970 "names the fix: {err}"
971 );
972
973 let mut collide = contact();
975 collide.save_param = "n".into();
976 assert!(collide.validate().unwrap_err().contains("collides"));
977 for r in ["i", "ins"] {
979 let mut c = contact();
980 c.save_param = r.into();
981 assert!(c.validate().is_err(), "{r} is reserved");
982 }
983 }
984}