use std::collections::HashMap;
use serde_json::{json, Map, Value};
use super::package::PackageIssue;
use super::query::{expand_query_source, parse_records_source, RecordsQuery};
use super::record_store::RECORDS_SCHEME;
use super::sources::parse_source;
use super::query::{RECORDS_COMPUTED_MAX, RECORDS_COMPUTED_ROW_KEYS, RECORDS_ROW_KEYS};
use crate::cel::{check_expression, expression_names, is_optional, optional_type, parse_expression, required_type, type_name};
fn ty(name: &str) -> Value {
Value::String(name.into())
}
fn escape(key: &str) -> String {
key.replace('~', "~0").replace('/', "~1")
}
fn unescape(key: &str) -> String {
key.replace("~1", "/").replace("~0", "~")
}
pub fn schema_type(schema: Option<&Value>) -> Value {
let Some(schema) = schema.and_then(Value::as_object) else { return ty("dyn") };
let types: Vec<Option<&str>> = match schema.get("type") {
Some(Value::Array(list)) => list.iter().filter(|t| t.as_str() != Some("null")).map(Value::as_str).collect(),
other => vec![other.and_then(Value::as_str)],
};
let kind = if types.len() == 1 { types[0] } else { None };
match kind {
Some("number" | "integer") => ty("number"),
Some("boolean") => ty("bool"),
Some("string") => match schema.get("format").and_then(Value::as_str) {
Some("date") => ty("date"),
Some("date-time") => ty("timestamp"),
_ => ty("string"),
},
Some("array") => Value::Array(vec![schema_type(schema.get("items"))]),
Some("object") => match schema.get("properties").and_then(Value::as_object) {
Some(props) => row_of(props, schema.get("required")),
None => ty("map"),
},
_ => ty("dyn"),
}
}
pub fn row_of(props: &Map<String, Value>, required: Option<&Value>) -> Value {
let names = required.and_then(Value::as_array);
let is_required = |k: &str| names.is_some_and(|n| n.iter().any(|x| x.as_str() == Some(k)));
Value::Object(
props
.iter()
.map(|(k, v)| {
let t = schema_type(Some(v));
(k.clone(), if is_required(k) { t } else { optional_type(t) })
})
.collect(),
)
}
pub fn record_row_type(kind: Option<&Value>) -> Value {
let schema = kind.and_then(Value::as_object).and_then(|k| k.get("schema")).and_then(Value::as_object);
let props = schema.and_then(|s| s.get("properties")).and_then(Value::as_object);
let mut row = match props {
Some(p) => row_of(p, schema.and_then(|s| s.get("required"))),
None => Value::Object(Map::new()),
};
if let Value::Object(m) = &mut row {
for (k, t) in [("id", "string"), ("createdAt", "timestamp"), ("updatedAt", "timestamp"), ("createdBy", "string"), ("authorId", "string"), ("author", "string")] {
m.insert(k.into(), ty(t));
}
if let Some(computed) = kind.and_then(|k| k.get("computed")).and_then(Value::as_object) {
let vars = computed_vars(kind);
let mut names: Vec<&String> = computed.keys().collect();
names.sort();
for name in names {
if m.contains_key(name.as_str()) {
continue;
}
let t = computed[name.as_str()].as_str().and_then(|src| check_expression(src, &vars).ok()).unwrap_or_else(|| optional_type(ty("dyn")));
m.insert(name.clone(), t);
}
}
}
row
}
fn computed_vars(kind: Option<&Value>) -> Map<String, Value> {
let schema = kind.and_then(Value::as_object).and_then(|k| k.get("schema")).and_then(Value::as_object);
let props = schema.and_then(|s| s.get("properties")).and_then(Value::as_object);
let mut vars = match props.map(|p| row_of(p, schema.and_then(|s| s.get("required")))) {
Some(Value::Object(m)) => m,
_ => Map::new(),
};
for key in RECORDS_COMPUTED_ROW_KEYS {
vars.insert(key.into(), ty(if key == "createdAt" || key == "updatedAt" { "timestamp" } else { "string" }));
}
vars
}
pub fn validate_record_computed(kind: &Value, at: &str) -> Vec<PackageIssue> {
let Some(computed) = kind.get("computed") else { return Vec::new() };
let Some(computed) = computed.as_object() else {
return vec![PackageIssue { path: format!("{at}/computed"), message: "computed is {name: expression}".into() }];
};
let mut issues = Vec::new();
let mut names: Vec<&String> = computed.keys().collect();
names.sort();
if names.len() > RECORDS_COMPUTED_MAX {
issues.push(PackageIssue { path: format!("{at}/computed"), message: format!("at most {RECORDS_COMPUTED_MAX} computed fields per kind") });
}
let props = kind.get("schema").and_then(|s| s.get("properties")).and_then(Value::as_object);
let declared = |n: &str| props.is_some_and(|p| p.contains_key(n));
let vars = computed_vars(Some(kind));
for name in names {
let fp = format!("{at}/computed/{}", escape(name));
let source = &computed[name.as_str()];
if declared(name) {
issues.push(PackageIssue { path: fp, message: format!("{name} shadows a field of the schema") });
} else if RECORDS_ROW_KEYS.contains(&name.as_str()) {
issues.push(PackageIssue { path: fp, message: format!("{name} is a reserved record key") });
} else if let Some(source) = source.as_str() {
let reads: Vec<String> = parse_expression(source).map(|ast| expression_names(&ast)).unwrap_or_default().into_iter().filter(|n| computed.contains_key(n) && !declared(n)).collect();
if !reads.is_empty() {
issues.push(PackageIssue { path: fp, message: format!("a computed field reads no computed field: {}", reads.join(", ")) });
} else if let Err(message) = check_expression(source, &vars) {
issues.push(PackageIssue { path: fp, message });
}
} else {
issues.push(PackageIssue { path: fp, message: "a computed field is an expression (a string)".into() });
}
}
issues
}
fn month_type() -> Value {
json!({
"key": "string", "label": "string", "prev": "string", "next": "string", "today": "date",
"days": [{"date": "date", "day": "number", "weekday": "string", "count": "number", "sum": "number", "running": optional_type(ty("map"))}],
"labels": ["string"], "counts": ["number"], "sums": ["number"]
})
}
pub fn records_value_type(kind: Option<&Value>, query: Option<&RecordsQuery>) -> Value {
let has = |present: Option<bool>, t: Value| if present == Some(true) { t } else { optional_type(t) };
let mut row = record_row_type(kind);
if let (Some(q), Value::Object(m)) = (query, &mut row) {
if !q.running.is_empty() && q.group.is_empty() {
m.insert("running".into(), ty("map"));
}
}
let mut out = Map::new();
out.insert("rows".into(), Value::Array(vec![row]));
out.insert("count".into(), ty("number"));
out.insert("sum".into(), has(query.map(|q| q.sum.is_some()), ty("number")));
out.insert("groups".into(), has(query.map(|q| !q.group.is_empty()), json!(["map"])));
out.insert("totals".into(), has(query.map(|q| !q.aggregate.is_empty()), ty("map")));
out.insert("before".into(), has(query.map(|q| q.before.is_some()), ty("map")));
out.insert("month".into(), has(query.map(|q| q.month.is_some()), month_type()));
Value::Object(out)
}
fn list_type(items: Vec<Value>) -> Value {
Value::Array(vec![match items.first() {
None => ty("dyn"),
Some(first) if items.iter().all(|t| t == first) => first.clone(),
Some(_) => ty("dyn"),
}])
}
pub fn literal_type(v: &Value) -> Value {
match v {
Value::Array(items) => list_type(items.iter().map(literal_type).collect()),
Value::Object(m) => Value::Object(m.iter().map(|(k, x)| (k.clone(), literal_type(x))).collect()),
other => ty(type_name(other)),
}
}
struct Walked {
t: Result<Value, String>,
strict: bool,
}
fn open(t: Value) -> Walked {
Walked { t: Ok(t), strict: false }
}
fn is_index(seg: &str) -> bool {
!seg.is_empty() && seg.bytes().all(|b| b.is_ascii_digit())
}
fn walk(t: Value, segments: &[String], strict: bool) -> Result<Value, String> {
let mut t = t;
let mut opt = false;
for seg in segments {
if is_optional(&t) {
opt = true;
t = required_type(&t);
}
t = match t {
Value::Array(mut item) => {
if !is_index(seg) {
return Ok(ty("dyn"));
}
opt = true;
item.pop().unwrap_or_else(|| ty("dyn"))
}
Value::String(name) if name == "map" => {
opt = true;
ty("dyn")
}
Value::Object(mut m) => match m.shift_remove(seg) {
Some(f) => f,
None if strict => return Err(seg.clone()),
None => return Ok(ty("dyn")),
},
_ => return Ok(ty("dyn")),
};
}
Ok(if opt { optional_type(t) } else { t })
}
fn is_binding(v: &Value) -> bool {
v.as_object().is_some_and(|o| o.len() == 1 && o.get("path").is_some_and(Value::is_string))
}
fn is_call(v: &Value) -> bool {
v.as_object().is_some_and(|o| o.get("call").is_some_and(Value::is_string))
}
fn closed_kind(kind: Option<&Value>) -> bool {
kind.and_then(|k| k.get("schema")).and_then(|s| s.get("additionalProperties")) != Some(&Value::Bool(true))
}
struct Ctx<'a> {
template: &'a Map<String, Value>,
records: Option<&'a Value>,
queries: Option<&'a Value>,
}
fn path_type(path: &str, ctx: &Ctx) -> Walked {
if !path.starts_with('/') {
return open(ty("dyn"));
}
for b in ctx.template.get("bindings").and_then(Value::as_array).into_iter().flatten() {
let (Some(bp), Some(source)) = (b.get("path").and_then(Value::as_str), b.get("source").and_then(Value::as_str)) else { continue };
if path != bp && !path.starts_with(&format!("{bp}/")) {
continue;
}
let parsed = parse_source(source);
let kind = parsed.as_ref().filter(|p| p.scheme == RECORDS_SCHEME).map(|p| p.name.clone());
let Some(kind) = kind.filter(|k| ctx.records.and_then(Value::as_object).is_some_and(|r| r.contains_key(k))) else { return open(ty("dyn")) };
let rest: Vec<String> = path[bp.len()..].split('/').enumerate().filter(|(i, s)| *i > 0 || !s.is_empty()).map(|(_, s)| unescape(s)).collect();
let def = ctx.records.and_then(|r| r.get(&kind));
let strict = closed_kind(def);
let query = parse_source(&expand_query_source(source, ctx.queries.filter(|q| q.is_object()))).and_then(|p| parse_records_source(&p));
return Walked { t: walk(records_value_type(def, query.as_ref()), &rest, strict), strict };
}
let mut cur = ctx.template.get("data");
let segments: Vec<String> = if path == "/" { vec![] } else { path[1..].split('/').map(unescape).collect() };
for seg in segments {
cur = match cur {
Some(Value::Array(list)) if is_index(&seg) => seg.parse::<usize>().ok().and_then(|i| list.get(i)),
Some(Value::Object(m)) => match m.get(&seg) {
Some(v) => Some(v),
None => return open(ty("dyn")),
},
_ => return open(ty("dyn")),
};
}
open(cur.map_or_else(|| ty("dyn"), literal_type))
}
#[derive(Clone)]
struct Scope {
t: Value,
strict: bool,
}
fn binding_type(path: &str, scope: Option<&Scope>, ctx: &Ctx) -> Walked {
if path.starts_with('/') {
return path_type(path, ctx);
}
let Some(scope) = scope else { return open(ty("dyn")) };
let segments: Vec<String> = path.split('/').filter(|s| !s.is_empty()).map(unescape).collect();
Walked { t: walk(scope.t.clone(), &segments, scope.strict), strict: scope.strict }
}
fn row_scopes(ctx: &Ctx) -> HashMap<String, Option<Scope>> {
let mut by_id: HashMap<&str, &Map<String, Value>> = HashMap::new();
for c in ctx.template.get("components").and_then(Value::as_array).into_iter().flatten() {
if let Some((id, o)) = c.as_object().and_then(|o| o.get("id").and_then(Value::as_str).map(|id| (id, o))) {
by_id.entry(id).or_insert(o);
}
}
let mut scopes: HashMap<String, Option<Scope>> = HashMap::new();
fn place(id: &str, scope: Option<Scope>, by_id: &HashMap<&str, &Map<String, Value>>, ctx: &Ctx, scopes: &mut HashMap<String, Option<Scope>>) {
let Some(c) = by_id.get(id) else { return };
if scopes.contains_key(id) {
return;
}
scopes.insert(id.to_string(), scope.clone());
match c.get("children") {
Some(Value::Array(list)) => {
for child in list.iter().filter_map(Value::as_str) {
place(child, scope.clone(), by_id, ctx, scopes);
}
}
Some(Value::Object(t)) => {
if let (Some(component_id), Some(path)) = (t.get("componentId").and_then(Value::as_str), t.get("path").and_then(Value::as_str)) {
let list = binding_type(path, scope.as_ref(), ctx);
let lt = list.t.as_ref().map(required_type).unwrap_or_else(|_| ty("dyn"));
let item = match < {
Value::Array(items) => Scope { t: items.first().cloned().unwrap_or_else(|| ty("dyn")), strict: list.strict },
_ => Scope { t: ty("dyn"), strict: false },
};
place(component_id, Some(item), by_id, ctx, scopes);
}
}
_ => {}
}
if let Some(slots) = c.get("slots").and_then(Value::as_object) {
for child in slots.values().filter_map(Value::as_str) {
place(child, scope.clone(), by_id, ctx, scopes);
}
}
}
place("root", None, &by_id, ctx, &mut scopes);
scopes
}
fn var_type(v: &Value, at: &str, scope: Option<&Scope>, ctx: &Ctx, issues: &mut Vec<PackageIssue>) -> Value {
if is_binding(v) {
let path = v["path"].as_str().unwrap_or_default();
return match binding_type(path, scope, ctx).t {
Err(field) => {
issues.push(PackageIssue { path: format!("{at}/path"), message: format!("unknown field {field} in {path}") });
optional_type(ty("dyn"))
}
Ok(t) if t == ty("dyn") => optional_type(t),
Ok(t) => t,
};
}
if is_call(v) {
return optional_type(ty("dyn"));
}
match v {
Value::Array(items) => list_type(items.iter().enumerate().map(|(i, x)| var_type(x, &format!("{at}/{i}"), scope, ctx, issues)).collect()),
Value::Object(m) => Value::Object(m.iter().map(|(k, x)| (k.clone(), var_type(x, &format!("{at}/{}", escape(k)), scope, ctx, issues))).collect()),
other => ty(type_name(other)),
}
}
fn visit(value: &Value, at: &str, scope: Option<&Scope>, ctx: &Ctx, issues: &mut Vec<PackageIssue>) {
match value {
Value::Array(list) => {
for (i, v) in list.iter().enumerate() {
visit(v, &format!("{at}/{i}"), scope, ctx, issues);
}
}
Value::Object(obj) => {
if let (Some("expr"), Some(Value::Object(args))) = (obj.get("call").and_then(Value::as_str), obj.get("args")) {
let here = format!("{at}/args");
if !args.contains_key("default") {
issues.push(PackageIssue { path: format!("{here}/default"), message: "an expression binding needs a default, shown when it fails".into() });
}
match (args.get("expr"), args.get("vars")) {
(Some(Value::String(expr)), vars) if vars.is_none_or(Value::is_object) => {
let mut types = Map::new();
let mut unknown = Vec::new();
for (name, v) in vars.and_then(Value::as_object).into_iter().flatten() {
types.insert(name.clone(), var_type(v, &format!("{here}/vars/{}", escape(name)), scope, ctx, &mut unknown));
}
let checked = unknown.is_empty();
issues.extend(unknown);
if checked {
if let Err(message) = check_expression(expr, &types) {
issues.push(PackageIssue { path: format!("{here}/expr"), message });
}
}
}
(Some(Value::String(_)), _) => issues.push(PackageIssue { path: format!("{here}/vars"), message: "vars is an object".into() }),
_ => issues.push(PackageIssue { path: format!("{here}/expr"), message: "expr is a string".into() }),
}
}
for (k, v) in obj {
visit(v, &format!("{at}/{}", escape(k)), scope, ctx, issues);
}
}
_ => {}
}
}
pub fn validate_package_expressions(pkg: &Map<String, Value>) -> Vec<PackageIssue> {
let mut issues = Vec::new();
for (id, template) in pkg.get("templates").and_then(Value::as_object).map(super::package::sorted_entries).into_iter().flatten() {
let Some(template) = template.as_object() else { continue };
let Some(components) = template.get("components").and_then(Value::as_array) else { continue };
let ctx = Ctx { template, records: pkg.get("records"), queries: pkg.get("queries") };
let scopes = row_scopes(&ctx);
for (i, c) in components.iter().enumerate() {
let scope = c.get("id").and_then(Value::as_str).and_then(|cid| scopes.get(cid)).and_then(Option::as_ref);
visit(c, &format!("/templates/{}/components/{i}", escape(id)), scope, &ctx, &mut issues);
}
}
issues
}