use crate::edit::{ExprSpec, FunctionSpec, StepSpec, TypeDefSpec};
use crate::node::{BinOp, Param, Produces};
use crate::ty::{Confidence, Effect, Type};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum FieldKind {
Num,
Text,
Decimal,
Variant { decode: String, encode: String },
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FieldSpec {
pub field: String,
pub column: String,
pub kind: FieldKind,
}
impl FieldSpec {
pub fn new(field: &str, kind: FieldKind) -> Self {
FieldSpec {
field: field.into(),
column: field.into(),
kind,
}
}
pub fn col(field: &str, column: &str, kind: FieldKind) -> Self {
FieldSpec {
field: field.into(),
column: column.into(),
kind,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EntitySpec {
pub record: String,
pub table: String,
pub rows_fn: String,
pub save_fn: String,
pub save_param: String,
pub fields: Vec<FieldSpec>,
}
const RESERVED_SAVE_PARAMS: &[&str] = &["i", "n", "ins"];
impl EntitySpec {
pub fn validate(&self) -> Result<(), String> {
if self.fields.is_empty() {
return Err(
"EntitySpec.fields is empty: an entity needs at \
least an `id` column"
.into(),
);
}
if self.fields[0].column != "id" {
return Err(format!(
"EntitySpec.fields[0] column is `{}`, must be `id`: \
the first field is the INTEGER PRIMARY KEY and the \
generated SELECT is `ORDER BY id`. A FieldSpec is \
[field_name, sql_column_name, kind] — the middle \
element is the column name, NOT a Cairn type.",
self.fields[0].column
));
}
if RESERVED_SAVE_PARAMS.contains(&self.save_param.as_str()) {
return Err(format!(
"EntitySpec.save_param `{}` collides with a binding \
the generated save/save_step use internally \
({:?}); pick another name (the CRM uses \
`cs`/`js`/`xs`).",
self.save_param, RESERVED_SAVE_PARAMS
));
}
Ok(())
}
}
fn rref(n: &str) -> ExprSpec {
ExprSpec::Ref(n.into())
}
fn call(func: &str, args: Vec<ExprSpec>) -> ExprSpec {
ExprSpec::Call {
func: func.into(),
args,
}
}
fn bin(op: BinOp, a: ExprSpec, b: ExprSpec) -> ExprSpec {
ExprSpec::BinOp {
op,
lhs: Box::new(a),
rhs: Box::new(b),
}
}
fn s2n(e: ExprSpec) -> ExprSpec {
ExprSpec::StrToNumber(Box::new(e))
}
fn n2s(e: ExprSpec) -> ExprSpec {
ExprSpec::NumberToStr(Box::new(e))
}
fn p(name: &str, ty: Type) -> Param {
Param {
name: name.into(),
ty,
min_confidence: Confidence::External,
}
}
fn ext(ty: Type) -> Produces {
Produces {
ty,
confidence: Confidence::External,
}
}
fn db_eff() -> BTreeSet<Effect> {
[Effect::Db].into_iter().collect()
}
fn field_at(k: i64) -> ExprSpec {
call(
"field",
vec![
ExprSpec::ListGet {
list: Box::new(rref("rows")),
index: Box::new(rref("i")),
},
ExprSpec::Lit(k),
],
)
}
fn item_field(param: &str, record: &str, field: &str) -> ExprSpec {
ExprSpec::Field {
base: Box::new(ExprSpec::ListGet {
list: Box::new(rref(param)),
index: Box::new(rref("i")),
}),
type_name: record.into(),
field: field.into(),
}
}
fn decode(kind: &FieldKind, k: i64) -> ExprSpec {
let f = field_at(k);
match kind {
FieldKind::Num => s2n(f),
FieldKind::Text => f,
FieldKind::Decimal => ExprSpec::DecimalOp {
op: BinOp::Div,
lhs: Box::new(ExprSpec::IntToDecimal(Box::new(s2n(f)))),
rhs: Box::new(ExprSpec::Decimal(10000.0)),
},
FieldKind::Variant { decode, .. } => call(decode, vec![f]),
}
}
fn encode(fs: &FieldSpec, param: &str, record: &str) -> ExprSpec {
let v = item_field(param, record, &fs.field);
match &fs.kind {
FieldKind::Num => n2s(v),
FieldKind::Text => v,
FieldKind::Decimal => n2s(ExprSpec::DecimalRaw(Box::new(v))),
FieldKind::Variant { encode, .. } => call(encode, vec![v]),
}
}
fn sql_type(k: &FieldKind) -> &'static str {
match k {
FieldKind::Num | FieldKind::Decimal => "INTEGER",
FieldKind::Text | FieldKind::Variant { .. } => "TEXT",
}
}
pub fn create_table(s: &EntitySpec) -> String {
let cols: Vec<String> = s
.fields
.iter()
.enumerate()
.map(|(i, f)| {
if i == 0 {
format!("{} INTEGER PRIMARY KEY", f.column)
} else {
format!("{} {}", f.column, sql_type(&f.kind))
}
})
.collect();
format!(
"CREATE TABLE IF NOT EXISTS {} ({})",
s.table,
cols.join(", ")
)
}
pub fn select_all(s: &EntitySpec) -> String {
let cols: Vec<&str> =
s.fields.iter().map(|f| f.column.as_str()).collect();
format!(
"SELECT {} FROM {} ORDER BY id",
cols.join(", "),
s.table
)
}
pub fn from_rows(s: &EntitySpec) -> FunctionSpec {
let elem = Type::Named(s.record.clone());
let fields: Vec<(String, ExprSpec)> = s
.fields
.iter()
.enumerate()
.map(|(k, fs)| (fs.field.clone(), decode(&fs.kind, k as i64)))
.collect();
FunctionSpec {
name: s.rows_fn.clone(),
type_params: vec![],
params: vec![
p("rows", Type::List(Box::new(Type::String))),
p("i", Type::Number),
],
produces: ext(Type::List(Box::new(elem.clone()))),
requires: BTreeSet::new(),
on_failure: vec![],
steps: vec![StepSpec {
binding: "n".into(),
value: ExprSpec::ListLen(Box::new(rref("rows"))),
}],
result: ExprSpec::If {
cond: Box::new(bin(BinOp::Eq, rref("i"), rref("n"))),
then_branch: Box::new(ExprSpec::ListEmpty {
elem: elem.clone(),
}),
else_branch: Box::new(ExprSpec::ListCons {
head: Box::new(ExprSpec::Record {
type_name: s.record.clone(),
fields,
}),
tail: Box::new(call(
&s.rows_fn,
vec![
rref("rows"),
bin(BinOp::Add, rref("i"), ExprSpec::Lit(1)),
],
)),
}),
},
}
}
pub fn save_pair(s: &EntitySpec) -> (FunctionSpec, FunctionSpec) {
let list_ty = Type::List(Box::new(Type::Named(s.record.clone())));
let step_fn = format!("{}_step", s.save_fn);
let prm = s.save_param.as_str();
let save = FunctionSpec {
name: s.save_fn.clone(),
type_params: vec![],
params: vec![p(prm, list_ty.clone()), p("i", Type::Number)],
produces: ext(Type::Number),
requires: db_eff(),
on_failure: vec![],
steps: vec![StepSpec {
binding: "n".into(),
value: ExprSpec::ListLen(Box::new(rref(prm))),
}],
result: ExprSpec::If {
cond: Box::new(bin(BinOp::Eq, rref("i"), rref("n"))),
then_branch: Box::new(ExprSpec::Lit(0)),
else_branch: Box::new(call(
&step_fn,
vec![rref(prm), rref("i")],
)),
},
};
let cols: Vec<&str> =
s.fields.iter().map(|f| f.column.as_str()).collect();
let qs: Vec<&str> = s.fields.iter().map(|_| "?").collect();
let sql = format!(
"INSERT INTO {} ({}) VALUES ({})",
s.table,
cols.join(", "),
qs.join(", "),
);
let params: Vec<ExprSpec> = s
.fields
.iter()
.map(|fs| encode(fs, prm, &s.record))
.collect();
let step = FunctionSpec {
name: step_fn,
type_params: vec![],
params: vec![p(prm, list_ty), p("i", Type::Number)],
produces: ext(Type::Number),
requires: db_eff(),
on_failure: vec![],
steps: vec![StepSpec {
binding: "ins".into(),
value: ExprSpec::DbQuery {
sql: Box::new(ExprSpec::Str(sql)),
params: Box::new(ExprSpec::List(params)),
},
}],
result: call(
&s.save_fn,
vec![
rref(prm),
bin(BinOp::Add, rref("i"), ExprSpec::Lit(1)),
],
),
};
(save, step)
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AppSpec {
pub app: String,
}
fn named(n: &str) -> Type {
Type::Named(n.into())
}
fn no_eff() -> BTreeSet<Effect> {
BTreeSet::new()
}
fn db_time() -> BTreeSet<Effect> {
[Effect::Db, Effect::Time].into_iter().collect()
}
fn func(
name: &str,
params: Vec<Param>,
out: Type,
requires: BTreeSet<Effect>,
result: ExprSpec,
) -> FunctionSpec {
FunctionSpec {
name: name.into(),
type_params: vec![],
params,
produces: ext(out),
requires,
on_failure: vec![],
steps: vec![],
result,
}
}
pub fn app_skeleton(spec: &AppSpec) -> (Vec<TypeDefSpec>, Vec<FunctionSpec>) {
let app = spec.app.as_str();
let msg = format!("{app}Msg");
let pfx = app.to_lowercase();
let types = vec![
TypeDefSpec::Record {
name: app.into(),
fields: vec![("count".into(), Type::Number)],
},
TypeDefSpec::Variant {
name: msg.clone(),
cases: vec![("Touch".into(), vec![])],
},
];
let model = || ExprSpec::Record {
type_name: app.into(),
fields: vec![("count".into(), ExprSpec::Lit(0))],
};
let route_msg = func(
&format!("{pfx}_route_msg"),
vec![p("req", named("Request"))],
named(&msg),
no_eff(),
ExprSpec::Variant {
type_name: msg.clone(),
case: "Touch".into(),
fields: vec![],
},
);
let load = func(
&format!("{pfx}_load"),
vec![],
named(app),
no_eff(),
model(),
);
let update = func(
&format!("{pfx}_update"),
vec![p("m", named(app)), p("msg", named(&msg))],
named(app),
no_eff(),
ExprSpec::Match {
scrutinee: Box::new(rref("msg")),
type_name: msg.clone(),
arms: vec![("Touch".into(), vec![], rref("m"))],
},
);
let view = func(
&format!("{pfx}_view"),
vec![p("m", named(app))],
named("Element"),
no_eff(),
ExprSpec::Variant {
type_name: "Element".into(),
case: "El".into(),
fields: vec![
("tag".into(), ExprSpec::Str("main".into())),
(
"kids".into(),
ExprSpec::List(vec![ExprSpec::Variant {
type_name: "Element".into(),
case: "Text".into(),
fields: vec![(
"content".into(),
ExprSpec::StrConcat(
Box::new(ExprSpec::Str(format!(
"{app} skeleton — fill in view; count="
))),
Box::new(ExprSpec::NumberToStr(Box::new(
ExprSpec::Field {
base: Box::new(rref("m")),
type_name: app.into(),
field: "count".into(),
},
))),
),
)],
}]),
),
],
},
);
let persist = func(
&format!("{pfx}_persist"),
vec![p("m", named(app))],
Type::Number,
no_eff(),
ExprSpec::Lit(0),
);
let route = func(
"route",
vec![p("req", named("Request"))],
named("Response"),
db_time(),
ExprSpec::Call {
func: "run_app".into(),
args: vec![
rref("req"),
ExprSpec::FuncRef(format!("{pfx}_route_msg")),
ExprSpec::FuncRef(format!("{pfx}_load")),
ExprSpec::FuncRef(format!("{pfx}_update")),
ExprSpec::FuncRef(format!("{pfx}_view")),
ExprSpec::FuncRef(format!("{pfx}_persist")),
],
},
);
(
types,
vec![route_msg, load, update, view, persist, route],
)
}
#[cfg(test)]
mod tests {
use super::*;
fn contact() -> EntitySpec {
EntitySpec {
record: "Contact".into(),
table: "contacts".into(),
rows_fn: "contacts_from_rows".into(),
save_fn: "crm_save_contacts".into(),
save_param: "cs".into(),
fields: vec![
FieldSpec::new("id", FieldKind::Num),
FieldSpec::new("name", FieldKind::Text),
FieldSpec::new("phone", FieldKind::Text),
FieldSpec::new("kind", FieldKind::Text),
],
}
}
#[test]
fn from_rows_is_byte_identical_to_the_hand_form() {
let gen = from_rows(&contact());
let hand = FunctionSpec {
name: "contacts_from_rows".into(),
type_params: vec![],
params: vec![
p("rows", Type::List(Box::new(Type::String))),
p("i", Type::Number),
],
produces: ext(Type::List(Box::new(Type::Named(
"Contact".into(),
)))),
requires: BTreeSet::new(),
on_failure: vec![],
steps: vec![StepSpec {
binding: "n".into(),
value: ExprSpec::ListLen(Box::new(rref("rows"))),
}],
result: ExprSpec::If {
cond: Box::new(bin(BinOp::Eq, rref("i"), rref("n"))),
then_branch: Box::new(ExprSpec::ListEmpty {
elem: Type::Named("Contact".into()),
}),
else_branch: Box::new(ExprSpec::ListCons {
head: Box::new(ExprSpec::Record {
type_name: "Contact".into(),
fields: vec![
("id".into(), s2n(field_at(0))),
("name".into(), field_at(1)),
("phone".into(), field_at(2)),
("kind".into(), field_at(3)),
],
}),
tail: Box::new(call(
"contacts_from_rows",
vec![
rref("rows"),
bin(BinOp::Add, rref("i"), ExprSpec::Lit(1)),
],
)),
}),
},
};
assert_eq!(
serde_json::to_string(&gen).unwrap(),
serde_json::to_string(&hand).unwrap(),
"generated *_from_rows must be byte-identical to hand"
);
}
#[test]
fn save_pair_is_byte_identical_to_the_hand_form() {
let (save, step) = save_pair(&contact());
let hand_save = FunctionSpec {
name: "crm_save_contacts".into(),
type_params: vec![],
params: vec![
p(
"cs",
Type::List(Box::new(Type::Named("Contact".into()))),
),
p("i", Type::Number),
],
produces: ext(Type::Number),
requires: db_eff(),
on_failure: vec![],
steps: vec![StepSpec {
binding: "n".into(),
value: ExprSpec::ListLen(Box::new(rref("cs"))),
}],
result: ExprSpec::If {
cond: Box::new(bin(BinOp::Eq, rref("i"), rref("n"))),
then_branch: Box::new(ExprSpec::Lit(0)),
else_branch: Box::new(call(
"crm_save_contacts_step",
vec![rref("cs"), rref("i")],
)),
},
};
let hand_step = FunctionSpec {
name: "crm_save_contacts_step".into(),
type_params: vec![],
params: vec![
p(
"cs",
Type::List(Box::new(Type::Named("Contact".into()))),
),
p("i", Type::Number),
],
produces: ext(Type::Number),
requires: db_eff(),
on_failure: vec![],
steps: vec![StepSpec {
binding: "ins".into(),
value: ExprSpec::DbQuery {
sql: Box::new(ExprSpec::Str(
"INSERT INTO contacts (id, name, phone, kind) \
VALUES (?, ?, ?, ?)"
.into(),
)),
params: Box::new(ExprSpec::List(vec![
n2s(item_field("cs", "Contact", "id")),
item_field("cs", "Contact", "name"),
item_field("cs", "Contact", "phone"),
item_field("cs", "Contact", "kind"),
])),
},
}],
result: call(
"crm_save_contacts",
vec![
rref("cs"),
bin(BinOp::Add, rref("i"), ExprSpec::Lit(1)),
],
),
};
assert_eq!(
serde_json::to_string(&save).unwrap(),
serde_json::to_string(&hand_save).unwrap(),
"generated save must be byte-identical to hand"
);
assert_eq!(
serde_json::to_string(&step).unwrap(),
serde_json::to_string(&hand_step).unwrap(),
"generated save_step must be byte-identical to hand"
);
}
#[test]
fn ddl_and_select_are_byte_identical_to_the_hand_constants() {
assert_eq!(
create_table(&contact()),
"CREATE TABLE IF NOT EXISTS contacts \
(id INTEGER PRIMARY KEY, name TEXT, phone TEXT, kind TEXT)"
);
assert_eq!(
select_all(&contact()),
"SELECT id, name, phone, kind FROM contacts ORDER BY id"
);
let li = EntitySpec {
record: "LineItem".into(),
table: "line_items".into(),
rows_fn: "lineitems_from_rows".into(),
save_fn: "crm_save_lines".into(),
save_param: "xs".into(),
fields: vec![
FieldSpec::new("id", FieldKind::Num),
FieldSpec::new("estimate_id", FieldKind::Num),
FieldSpec::new("description", FieldKind::Text),
FieldSpec::new("qty", FieldKind::Num),
FieldSpec::col(
"unit_price",
"price_raw",
FieldKind::Decimal,
),
],
};
assert_eq!(
create_table(&li),
"CREATE TABLE IF NOT EXISTS line_items \
(id INTEGER PRIMARY KEY, estimate_id INTEGER, \
description TEXT, qty INTEGER, price_raw INTEGER)"
);
assert_eq!(
select_all(&li),
"SELECT id, estimate_id, description, qty, price_raw \
FROM line_items ORDER BY id"
);
}
#[test]
fn decimal_and_variant_round_trip_match_documented_forms() {
let d = decode(&FieldKind::Decimal, 4);
let want = ExprSpec::DecimalOp {
op: BinOp::Div,
lhs: Box::new(ExprSpec::IntToDecimal(Box::new(s2n(
field_at(4),
)))),
rhs: Box::new(ExprSpec::Decimal(10000.0)),
};
assert_eq!(
serde_json::to_string(&d).unwrap(),
serde_json::to_string(&want).unwrap()
);
let fs = FieldSpec::col(
"unit_price",
"price_raw",
FieldKind::Decimal,
);
let e = encode(&fs, "xs", "LineItem");
let want_e = n2s(ExprSpec::DecimalRaw(Box::new(item_field(
"xs",
"LineItem",
"unit_price",
))));
assert_eq!(
serde_json::to_string(&e).unwrap(),
serde_json::to_string(&want_e).unwrap()
);
let vk = FieldKind::Variant {
decode: "status_of_str".into(),
encode: "status_to_str".into(),
};
assert_eq!(
serde_json::to_string(&decode(&vk, 3)).unwrap(),
serde_json::to_string(&call(
"status_of_str",
vec![field_at(3)]
))
.unwrap()
);
let vfs = FieldSpec::new("status", vk);
assert_eq!(
serde_json::to_string(&encode(&vfs, "js", "Job")).unwrap(),
serde_json::to_string(&call(
"status_to_str",
vec![item_field("js", "Job", "status")]
))
.unwrap()
);
}
#[test]
fn app_skeleton_emits_the_canonical_tea_shape() {
let (types, fns) = app_skeleton(&AppSpec {
app: "Counter".into(),
});
match &types[0] {
TypeDefSpec::Record { name, fields } => {
assert_eq!(name, "Counter");
assert_eq!(fields[0].0, "count");
}
_ => panic!("first type is the model record"),
}
match &types[1] {
TypeDefSpec::Variant { name, cases } => {
assert_eq!(name, "CounterMsg");
assert_eq!(cases[0].0, "Touch");
}
_ => panic!("second type is the Msg variant"),
}
let names: Vec<&str> =
fns.iter().map(|f| f.name.as_str()).collect();
assert_eq!(
names,
vec![
"counter_route_msg",
"counter_load",
"counter_update",
"counter_view",
"counter_persist",
"route",
],
"the five TEA functions (prefixed) + the `route` entry"
);
let by = |n: &str| fns.iter().find(|f| f.name == n).unwrap();
let dt: BTreeSet<Effect> =
[Effect::Db, Effect::Time].into_iter().collect();
assert_eq!(by("route").requires, dt);
for n in [
"counter_route_msg",
"counter_load",
"counter_update",
"counter_view",
"counter_persist",
] {
assert!(
by(n).requires.is_empty(),
"{n} is pure in the skeleton"
);
}
match &by("route").result {
ExprSpec::Call { func, args } => {
assert_eq!(func, "run_app");
assert!(matches!(&args[0], ExprSpec::Ref(r) if r == "req"));
let refs: Vec<&str> = args[1..]
.iter()
.map(|a| match a {
ExprSpec::FuncRef(n) => n.as_str(),
_ => panic!("run_app args are FuncRefs"),
})
.collect();
assert_eq!(
refs,
vec![
"counter_route_msg",
"counter_load",
"counter_update",
"counter_view",
"counter_persist"
]
);
}
_ => panic!("route composes run_app"),
}
}
#[test]
fn entity_spec_validate_rejects_malformed_specs() {
assert!(contact().validate().is_ok());
let mut e = contact();
e.fields = vec![];
assert!(e.validate().is_err());
let bad = EntitySpec {
record: "Note".into(),
table: "notes".into(),
rows_fn: "note_from_rows".into(),
save_fn: "note_save".into(),
save_param: "notes".into(),
fields: vec![
FieldSpec::col("id", "Number", FieldKind::Num),
FieldSpec::col("body", "String", FieldKind::Text),
],
};
let err = bad.validate().unwrap_err();
assert!(
err.contains("must be `id`") && err.contains("NOT a Cairn type"),
"names the fix: {err}"
);
let mut collide = contact();
collide.save_param = "n".into();
assert!(collide.validate().unwrap_err().contains("collides"));
for r in ["i", "ins"] {
let mut c = contact();
c.save_param = r.into();
assert!(c.validate().is_err(), "{r} is reserved");
}
}
}