use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::datatypes::values::Value;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct TablePropertyMeta {
pub columns: Vec<String>,
pub dtypes: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub nullable: Vec<String>,
}
pub fn table_meta_key(node_type: &str, property: &str) -> String {
format!("{node_type}\u{1f}{property}")
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PropertyShape {
List(Box<PropertyShape>),
Map(BTreeMap<String, (PropertyShape, bool)>),
Scalar(ScalarShape),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ScalarShape {
String,
Integer,
Float,
Boolean,
DateTime,
}
impl ScalarShape {
fn name(&self) -> &'static str {
match self {
ScalarShape::String => "string",
ScalarShape::Integer => "integer",
ScalarShape::Float => "float",
ScalarShape::Boolean => "boolean",
ScalarShape::DateTime => "datetime",
}
}
fn accepts(&self, value: &Value) -> bool {
matches!(
(self, value),
(ScalarShape::String, Value::String(_))
| (ScalarShape::Integer, Value::Int64(_))
| (ScalarShape::Integer, Value::UniqueId(_))
| (ScalarShape::Float, Value::Float64(_))
| (ScalarShape::Boolean, Value::Boolean(_))
| (ScalarShape::DateTime, Value::DateTime(_))
| (ScalarShape::DateTime, Value::Timestamp(_))
)
}
}
impl PropertyShape {
pub fn render(&self) -> String {
match self {
PropertyShape::Scalar(s) => s.name().to_string(),
PropertyShape::List(inner) => format!("list<{}>", inner.render()),
PropertyShape::Map(fields) => {
let inner: Vec<String> = fields
.iter()
.map(|(k, (shape, required))| {
format!(
"{k}: {}{}",
shape.render(),
if *required { "!" } else { "" }
)
})
.collect();
format!("map{{{}}}", inner.join(", "))
}
}
}
pub fn check(&self, property: &str, value: &Value) -> Result<(), String> {
if matches!(value, Value::Null) {
return Ok(());
}
self.check_at(property, value)
}
fn check_at(&self, path: &str, value: &Value) -> Result<(), String> {
match self {
PropertyShape::Scalar(scalar) => {
if matches!(value, Value::Null) || scalar.accepts(value) {
Ok(())
} else {
Err(format!(
"{path}: expected {}, got {}",
scalar.name(),
value.type_name()
))
}
}
PropertyShape::List(inner) => match value {
Value::List(items) => {
for (i, item) in items.iter().enumerate() {
inner.check_at(&format!("{path}[{i}]"), item)?;
}
Ok(())
}
_ => Err(format!(
"{path}: expected a list, got {}",
value.type_name()
)),
},
PropertyShape::Map(fields) => match value {
Value::Map(map) => {
for (key, (shape, required)) in fields {
match map.get(key) {
Some(Value::Null) | None if *required => {
return Err(format!("{path}.{key}: required key is missing"));
}
Some(v) => shape.check_at(&format!("{path}.{key}"), v)?,
None => {}
}
}
for (key, _) in map {
if !fields.contains_key(key) {
return Err(format!(
"{path}.{key}: key is not in the declared shape ({})",
self.render()
));
}
}
Ok(())
}
_ => Err(format!("{path}: expected a map, got {}", value.type_name())),
},
}
}
}
pub fn parse_property_shape(text: &str) -> Option<Result<PropertyShape, String>> {
let trimmed = text.trim();
if !(trimmed.starts_with("list<") || trimmed.starts_with("map{")) {
return None;
}
let mut p = ShapeParser {
chars: trimmed.char_indices().peekable(),
src: trimmed,
};
Some(p.parse_shape().and_then(|shape| {
p.skip_ws();
match p.chars.peek() {
None => Ok(shape),
Some((i, _)) => Err(format!(
"property shape: unexpected trailing input at '{}'",
&p.src[*i..]
)),
}
}))
}
struct ShapeParser<'a> {
chars: std::iter::Peekable<std::str::CharIndices<'a>>,
src: &'a str,
}
impl ShapeParser<'_> {
fn skip_ws(&mut self) {
while matches!(self.chars.peek(), Some((_, c)) if c.is_whitespace()) {
self.chars.next();
}
}
fn eat(&mut self, expected: char) -> Result<(), String> {
self.skip_ws();
match self.chars.next() {
Some((_, c)) if c == expected => Ok(()),
other => Err(format!(
"property shape: expected '{expected}', found {:?}",
other.map(|(_, c)| c)
)),
}
}
fn word(&mut self) -> String {
self.skip_ws();
let mut out = String::new();
while matches!(self.chars.peek(), Some((_, c)) if c.is_alphanumeric() || *c == '_') {
out.push(self.chars.next().unwrap().1);
}
out
}
fn parse_shape(&mut self) -> Result<PropertyShape, String> {
let head = self.word();
match head.as_str() {
"list" => {
self.eat('<')?;
let inner = self.parse_shape()?;
self.eat('>')?;
Ok(PropertyShape::List(Box::new(inner)))
}
"map" => {
self.eat('{')?;
let mut fields = BTreeMap::new();
loop {
self.skip_ws();
if matches!(self.chars.peek(), Some((_, '}'))) {
self.chars.next();
break;
}
let key = self.word();
if key.is_empty() {
return Err("property shape: expected a key name in map{...}".to_string());
}
self.eat(':')?;
let shape = self.parse_shape()?;
self.skip_ws();
let required = if matches!(self.chars.peek(), Some((_, '!'))) {
self.chars.next();
true
} else {
false
};
if fields.insert(key.clone(), (shape, required)).is_some() {
return Err(format!("property shape: duplicate key '{key}'"));
}
self.skip_ws();
if matches!(self.chars.peek(), Some((_, ','))) {
self.chars.next();
}
}
Ok(PropertyShape::Map(fields))
}
scalar => match scalar {
"string" | "str" => Ok(PropertyShape::Scalar(ScalarShape::String)),
"int" | "integer" => Ok(PropertyShape::Scalar(ScalarShape::Integer)),
"float" => Ok(PropertyShape::Scalar(ScalarShape::Float)),
"bool" | "boolean" => Ok(PropertyShape::Scalar(ScalarShape::Boolean)),
"date" | "datetime" => Ok(PropertyShape::Scalar(ScalarShape::DateTime)),
other => Err(format!(
"property shape: unknown type '{other}' (accepted: string, int, float, \
bool, date, list<...>, map{{...}})"
)),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::datatypes::PropMap;
fn shape(text: &str) -> PropertyShape {
parse_property_shape(text)
.expect("looks structured")
.expect("parses")
}
#[test]
fn grammar_round_trips() {
let s = shape("list<map{sku: string!, qty: int!, price: float}>");
assert_eq!(
s.render(),
"list<map{price: float, qty: integer!, sku: string!}>"
);
assert_eq!(shape("list<int>").render(), "list<integer>");
assert_eq!(shape("map{a: bool}").render(), "map{a: boolean}");
}
#[test]
fn plain_types_are_not_shapes() {
assert!(parse_property_shape("string").is_none());
assert!(parse_property_shape("integer").is_none());
assert!(parse_property_shape("list<oops>").unwrap().is_err());
assert!(parse_property_shape("map{a string}").unwrap().is_err());
assert!(parse_property_shape("list<int> trailing").unwrap().is_err());
}
#[test]
fn validation_reports_indexed_paths() {
let s = shape("list<map{sku: string!, qty: int!, price: float}>");
let good_row = |sku: &str, qty: i64| {
Value::Map(PropMap::from_pairs(vec![
("sku".into(), Value::String(sku.to_string())),
("qty".into(), Value::Int64(qty)),
("price".into(), Value::Float64(9.5)),
]))
};
let ok = Value::List(vec![good_row("a", 1), good_row("b", 2)]);
assert!(s.check("line_items", &ok).is_ok());
let mut rows = vec![good_row("a", 1)];
rows.push(Value::Map(PropMap::from_pairs(vec![
("sku".into(), Value::String("c".into())),
("qty".into(), Value::String("eight".into())),
])));
let err = s.check("line_items", &Value::List(rows)).unwrap_err();
assert_eq!(err, "line_items[1].qty: expected integer, got String");
let missing = Value::List(vec![Value::Map(PropMap::from_pairs(vec![(
"sku".into(),
Value::String("x".into()),
)]))]);
let err = s.check("line_items", &missing).unwrap_err();
assert!(
err.contains("line_items[0].qty: required key is missing"),
"{err}"
);
let extra = Value::List(vec![Value::Map(PropMap::from_pairs(vec![
("sku".into(), Value::String("x".into())),
("qty".into(), Value::Int64(1)),
("colour".into(), Value::String("red".into())),
]))]);
let err = s.check("line_items", &extra).unwrap_err();
assert!(err.contains("line_items[0].colour"), "{err}");
assert!(s.check("line_items", &Value::Null).is_ok());
}
}