use std::collections::BTreeMap;
use std::path::Path;
use crate::smart_suggest;
use crate::store_schema::{StoreColumn, StoreColumnSchema, StoreColumnType};
use crate::store_schema_manifest::{
self, Manifest, ManifestError, ManifestStore,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProofErrorCode {
T801UnknownColumn,
T802TypeMismatch,
T803NotNullOmitted,
T804UnknownField,
T805ManifestHashMismatch,
}
impl ProofErrorCode {
pub fn slug(self) -> &'static str {
match self {
Self::T801UnknownColumn => "axon-T801",
Self::T802TypeMismatch => "axon-T802",
Self::T803NotNullOmitted => "axon-T803",
Self::T804UnknownField => "axon-T804",
Self::T805ManifestHashMismatch => "axon-T805",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProofError {
pub code: ProofErrorCode,
pub line: u32,
pub column: u32,
pub message: String,
}
impl ProofError {
fn new(code: ProofErrorCode, line: u32, column: u32, message: String) -> Self {
Self {
code,
line,
column,
message,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeclaredColumn {
pub name: String,
pub col_type: StoreColumnType,
pub not_null: bool,
pub has_default: bool,
pub primary_key: bool,
pub identity: bool,
}
#[derive(Debug, Clone, Default)]
pub struct ColumnSet {
pub columns: BTreeMap<String, DeclaredColumn>,
}
impl ColumnSet {
pub fn from_inline_schema(schema: &StoreColumnSchema) -> Option<ColumnSet> {
let StoreColumnSchema::Inline { columns, .. } = schema else {
return None;
};
Some(Self::from_inline_columns(columns))
}
pub fn from_inline_columns(columns: &[StoreColumn]) -> ColumnSet {
let mut out = BTreeMap::new();
for col in columns {
let has_default =
!col.default_value.is_empty() || col.auto_increment || col.identity;
out.insert(
col.name.clone(),
DeclaredColumn {
name: col.name.clone(),
col_type: col.col_type,
not_null: col.not_null || col.primary_key,
has_default,
primary_key: col.primary_key,
identity: col.identity,
},
);
}
ColumnSet { columns: out }
}
pub fn from_manifest_store(store: &ManifestStore) -> ColumnSet {
let mut out = BTreeMap::new();
for (name, mc) in &store.columns {
let has_default =
!mc.default_value.is_empty() || mc.auto_increment || mc.identity;
out.insert(
name.clone(),
DeclaredColumn {
name: name.clone(),
col_type: mc.col_type,
not_null: mc.not_null || mc.primary_key,
has_default,
primary_key: mc.primary_key,
identity: mc.identity,
},
);
}
ColumnSet { columns: out }
}
pub fn contains(&self, name: &str) -> bool {
self.columns.contains_key(name)
}
pub fn get(&self, name: &str) -> Option<&DeclaredColumn> {
self.columns.get(name)
}
pub fn names(&self) -> Vec<&str> {
self.columns.keys().map(|s| s.as_str()).collect()
}
}
#[derive(Debug, Clone, Default)]
pub struct FlowParamTypes {
pub types: BTreeMap<String, String>,
}
impl FlowParamTypes {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, name: String, type_name: String) {
self.types.insert(name, type_name);
}
pub fn get(&self, name: &str) -> Option<&str> {
self.types.get(name).map(|s| s.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScannedPredicate {
pub column: String,
pub op: WhereOp,
pub value: WhereValue,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WhereOp {
Eq,
NotEq,
Lt,
Gt,
Le,
Ge,
Like,
IsNull,
IsNotNull,
}
impl WhereOp {
pub fn is_equality(self) -> bool {
matches!(self, Self::Eq | Self::NotEq)
}
pub fn is_ordering(self) -> bool {
matches!(self, Self::Lt | Self::Gt | Self::Le | Self::Ge)
}
pub fn is_null_check(self) -> bool {
matches!(self, Self::IsNull | Self::IsNotNull)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WhereValue {
BoundParam(String),
Literal {
kind: LiteralKind,
raw: String,
},
NullKeyword,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LiteralKind {
Text,
Int,
Float,
Bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScanError {
Malformed { detail: String },
}
pub fn scan_where(expr: &str) -> Result<Vec<ScannedPredicate>, ScanError> {
let trimmed = expr.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
}
let tokens = tokenize(trimmed)?;
let mut out: Vec<ScannedPredicate> = Vec::new();
let mut i = 0;
while i < tokens.len() {
let predicate = parse_predicate(&tokens, &mut i)?;
out.push(predicate);
if i < tokens.len() {
let connector = match &tokens[i] {
ScanToken::Word(w)
if w.eq_ignore_ascii_case("and") || w.eq_ignore_ascii_case("or") =>
{
i += 1;
w.clone()
}
other => {
return Err(ScanError::Malformed {
detail: format!("expected `AND`/`OR` between predicates, got {other:?}"),
});
}
};
if i == tokens.len() {
return Err(ScanError::Malformed {
detail: format!("trailing `{connector}` with no following predicate"),
});
}
}
}
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ScanToken {
Word(String),
Symbol(String),
Str(String),
Int(String),
Float(String),
BoundParam(String),
}
fn tokenize(src: &str) -> Result<Vec<ScanToken>, ScanError> {
let bytes = src.as_bytes();
let mut out: Vec<ScanToken> = Vec::new();
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b.is_ascii_whitespace() {
i += 1;
continue;
}
if b == b'$' {
i += 1;
let braced = i < bytes.len() && bytes[i] == b'{';
if braced {
i += 1;
}
let start = i;
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
i += 1;
}
if i == start {
return Err(ScanError::Malformed {
detail: format!("empty parameter reference at position {start}"),
});
}
let name = String::from_utf8_lossy(&bytes[start..i]).to_string();
if braced {
if i >= bytes.len() || bytes[i] != b'}' {
return Err(ScanError::Malformed {
detail: format!("unterminated `${{...}}` reference for `{name}`"),
});
}
i += 1;
}
out.push(ScanToken::BoundParam(name));
continue;
}
if b == b'\'' {
i += 1;
let start = i;
while i < bytes.len() && bytes[i] != b'\'' {
if bytes[i] == b'\\' && i + 1 < bytes.len() {
i += 2;
continue;
}
i += 1;
}
if i >= bytes.len() {
return Err(ScanError::Malformed {
detail: format!("unterminated string starting at position {start}"),
});
}
let content = String::from_utf8_lossy(&bytes[start..i]).to_string();
i += 1;
out.push(ScanToken::Str(content));
continue;
}
if b.is_ascii_digit() || (b == b'-' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit())
{
let start = i;
if b == b'-' {
i += 1;
}
let mut saw_dot = false;
while i < bytes.len() {
let c = bytes[i];
if c.is_ascii_digit() {
i += 1;
} else if c == b'.' && !saw_dot {
saw_dot = true;
i += 1;
} else {
break;
}
}
let tok = String::from_utf8_lossy(&bytes[start..i]).to_string();
if saw_dot {
out.push(ScanToken::Float(tok));
} else {
out.push(ScanToken::Int(tok));
}
continue;
}
if b.is_ascii_alphabetic() || b == b'_' {
let start = i;
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
i += 1;
}
let word = String::from_utf8_lossy(&bytes[start..i]).to_string();
out.push(ScanToken::Word(word));
continue;
}
if i + 1 < bytes.len() {
let two = std::str::from_utf8(&bytes[i..i + 2]).unwrap_or("");
if matches!(two, "==" | "!=" | "<>" | "<=" | ">=") {
out.push(ScanToken::Symbol(two.to_string()));
i += 2;
continue;
}
}
if matches!(b, b'=' | b'<' | b'>') {
out.push(ScanToken::Symbol((b as char).to_string()));
i += 1;
continue;
}
return Err(ScanError::Malformed {
detail: format!("unexpected character {:?} at position {i}", b as char),
});
}
Ok(out)
}
fn parse_predicate(
tokens: &[ScanToken],
cursor: &mut usize,
) -> Result<ScannedPredicate, ScanError> {
let column = match tokens.get(*cursor) {
Some(ScanToken::Word(w)) => {
if matches!(
w.to_ascii_uppercase().as_str(),
"AND" | "OR" | "NOT" | "NULL"
) {
return Err(ScanError::Malformed {
detail: format!("expected column name, got reserved word `{w}`"),
});
}
w.clone()
}
other => {
return Err(ScanError::Malformed {
detail: format!("expected column identifier, got {other:?}"),
});
}
};
*cursor += 1;
let op_token = tokens.get(*cursor).ok_or_else(|| ScanError::Malformed {
detail: format!("missing operator after column `{column}`"),
})?;
let op: WhereOp = match op_token {
ScanToken::Symbol(s) => match s.as_str() {
"=" | "==" => WhereOp::Eq,
"!=" | "<>" => WhereOp::NotEq,
"<" => WhereOp::Lt,
">" => WhereOp::Gt,
"<=" => WhereOp::Le,
">=" => WhereOp::Ge,
other => {
return Err(ScanError::Malformed {
detail: format!("unknown operator `{other}` after column `{column}`"),
});
}
},
ScanToken::Word(w) if w.eq_ignore_ascii_case("like") => WhereOp::Like,
ScanToken::Word(w) if w.eq_ignore_ascii_case("is") => {
*cursor += 1;
let next = tokens.get(*cursor).ok_or_else(|| ScanError::Malformed {
detail: format!("`IS` requires `NULL` or `NOT NULL` after column `{column}`"),
})?;
let mut is_not = false;
let null_tok = match next {
ScanToken::Word(w) if w.eq_ignore_ascii_case("not") => {
is_not = true;
*cursor += 1;
tokens.get(*cursor).ok_or_else(|| ScanError::Malformed {
detail: format!(
"`IS NOT` requires `NULL` after column `{column}`"
),
})?
}
other => other,
};
match null_tok {
ScanToken::Word(w) if w.eq_ignore_ascii_case("null") => {
*cursor += 1;
return Ok(ScannedPredicate {
column,
op: if is_not { WhereOp::IsNotNull } else { WhereOp::IsNull },
value: WhereValue::NullKeyword,
});
}
other => {
return Err(ScanError::Malformed {
detail: format!(
"expected `NULL` after `IS{}`, got {other:?}",
if is_not { " NOT" } else { "" }
),
});
}
}
}
other => {
return Err(ScanError::Malformed {
detail: format!("expected operator after column `{column}`, got {other:?}"),
});
}
};
*cursor += 1;
let value_token = tokens.get(*cursor).ok_or_else(|| ScanError::Malformed {
detail: format!("missing value after `{column} {op:?}`"),
})?;
let value = match value_token {
ScanToken::Str(s) => WhereValue::Literal {
kind: LiteralKind::Text,
raw: s.clone(),
},
ScanToken::Int(s) => WhereValue::Literal {
kind: LiteralKind::Int,
raw: s.clone(),
},
ScanToken::Float(s) => WhereValue::Literal {
kind: LiteralKind::Float,
raw: s.clone(),
},
ScanToken::BoundParam(n) => WhereValue::BoundParam(n.clone()),
ScanToken::Word(w) if w.eq_ignore_ascii_case("true") || w.eq_ignore_ascii_case("false") => {
WhereValue::Literal {
kind: LiteralKind::Bool,
raw: w.clone(),
}
}
ScanToken::Word(w) if w.eq_ignore_ascii_case("null") => WhereValue::NullKeyword,
other => {
return Err(ScanError::Malformed {
detail: format!("unexpected value-position token {other:?}"),
});
}
};
*cursor += 1;
Ok(ScannedPredicate {
column,
op,
value,
})
}
pub fn literal_compatible_with_column(lit: LiteralKind, col: StoreColumnType) -> bool {
use LiteralKind as L;
use StoreColumnType as C;
match (lit, col) {
(L::Text, C::Text) => true,
(L::Text, C::Uuid) => true, (L::Text, C::Timestamptz | C::Timestamp | C::Date | C::Time) => true,
(L::Text, C::Jsonb | C::Json) => true,
(L::Text, C::Bytea) => true, (L::Text, C::Numeric) => true, (L::Int, C::Int | C::BigInt) => true,
(L::Int, C::Float | C::Double) => true,
(L::Int, C::Numeric) => true,
(L::Float, C::Float | C::Double | C::Numeric) => true,
(L::Bool, C::Bool) => true,
_ => false,
}
}
pub fn axon_param_compatible_with_column(
param_axon: &str,
col: StoreColumnType,
) -> bool {
let normalised = strip_optional_wrap(param_axon);
use StoreColumnType as C;
match (normalised, col) {
("String", _) => true,
("Text", _) => true,
("Int", C::Int | C::BigInt | C::Numeric | C::Float | C::Double) => true,
("Integer", C::Int | C::BigInt | C::Numeric | C::Float | C::Double) => true,
("BigInt", C::BigInt | C::Numeric | C::Float | C::Double) => true,
("Float", C::Float | C::Double | C::Numeric) => true,
("Double", C::Float | C::Double | C::Numeric) => true,
("Number", C::Int | C::BigInt | C::Float | C::Double | C::Numeric) => true,
("Bool", C::Bool) => true,
("Boolean", C::Bool) => true,
("Uuid", C::Uuid) => true,
("UUID", C::Uuid) => true,
("Timestamptz", C::Timestamptz) => true,
("Timestamp", C::Timestamp) => true,
("Date", C::Date) => true,
("Time", C::Time) => true,
("Json", C::Json | C::Jsonb) => true,
("Jsonb", C::Jsonb | C::Json) => true,
("Bytea", C::Bytea) => true,
_ => false,
}
}
fn strip_optional_wrap(name: &str) -> &str {
if let Some(inner) = name
.strip_prefix("Optional<")
.and_then(|s| s.strip_suffix('>'))
{
return inner;
}
if let Some(inner) = name.strip_prefix("Option<").and_then(|s| s.strip_suffix('>')) {
return inner;
}
name
}
pub fn format_column_list(columns: &ColumnSet) -> String {
let parts: Vec<String> = columns
.columns
.iter()
.map(|(name, col)| format!("{name}: {}", col.col_type.canonical_name()))
.collect();
parts.join(", ")
}
pub fn suggest_columns_composite(unknown: &str, columns: &ColumnSet) -> String {
let names = columns.names();
let suggestions = smart_suggest::suggest(
unknown,
&names,
smart_suggest::MAX_DISTANCE,
smart_suggest::MAX_RESULTS,
);
if suggestions.is_empty() {
return String::new();
}
let labelled: Vec<String> = suggestions
.iter()
.filter_map(|name| {
columns
.get(name)
.map(|c| format!("`{name}` ({})", c.col_type.canonical_name()))
})
.collect();
if labelled.is_empty() {
return String::new();
}
match labelled.len() {
1 => format!("Did you mean column {}?", labelled[0]),
2 => format!(
"Did you mean column {} or {}?",
labelled[0], labelled[1]
),
_ => {
let last = labelled.last().unwrap();
let head: Vec<String> = labelled[..labelled.len() - 1].to_vec();
format!(
"Did you mean column {}, or {}?",
head.join(", "),
last
)
}
}
}
pub fn suggest_type_compatible_columns_for_literal(
lit: LiteralKind,
columns: &ColumnSet,
excluded_column: &str,
) -> String {
let compat: Vec<&DeclaredColumn> = columns
.columns
.values()
.filter(|c| c.name != excluded_column && literal_compatible_with_column(lit, c.col_type))
.take(MAX_COMPAT_SUGGESTIONS)
.collect();
render_compat_suggestions(&compat, format!("{lit:?}-class"))
}
pub fn suggest_type_compatible_columns_for_param(
param_axon_type: &str,
columns: &ColumnSet,
excluded_column: &str,
) -> String {
let compat: Vec<&DeclaredColumn> = columns
.columns
.values()
.filter(|c| {
c.name != excluded_column
&& axon_param_compatible_with_column(param_axon_type, c.col_type)
})
.take(MAX_COMPAT_SUGGESTIONS)
.collect();
render_compat_suggestions(&compat, format!("`{param_axon_type}`-compatible"))
}
pub const MAX_COMPAT_SUGGESTIONS: usize = 3;
fn render_compat_suggestions(compat: &[&DeclaredColumn], class_label: String) -> String {
if compat.is_empty() {
return String::new();
}
let labelled: Vec<String> = compat
.iter()
.map(|c| format!("`{}` ({})", c.name, c.col_type.canonical_name()))
.collect();
let joined = labelled.join(", ");
format!("Compatible {class_label} columns in this schema: {joined}.")
}
pub fn check_filter(
where_expr: &str,
columns: &ColumnSet,
flow_params: &FlowParamTypes,
where_loc: (u32, u32),
) -> Vec<ProofError> {
let predicates = match scan_where(where_expr) {
Ok(ps) => ps,
Err(_) => return Vec::new(),
};
let mut out: Vec<ProofError> = Vec::new();
for pred in predicates {
check_predicate(&pred, columns, flow_params, where_loc, &mut out);
}
out
}
fn check_predicate(
pred: &ScannedPredicate,
columns: &ColumnSet,
flow_params: &FlowParamTypes,
where_loc: (u32, u32),
out: &mut Vec<ProofError>,
) {
let Some(col) = columns.get(&pred.column) else {
let suggestion = suggest_columns_composite(&pred.column, columns);
let suggest_suffix = if suggestion.is_empty() {
String::new()
} else {
format!(" {suggestion}")
};
out.push(ProofError::new(
ProofErrorCode::T801UnknownColumn,
where_loc.0,
where_loc.1,
format!(
"axon-T801 unknown column `{}` in `where:` clause. The \
declared schema has columns: {{{}}}.{suggest_suffix}",
pred.column,
format_column_list(columns),
),
));
return;
};
if pred.op.is_null_check() {
return;
}
if pred.op == WhereOp::Like {
if !matches!(
col.col_type,
StoreColumnType::Text | StoreColumnType::Jsonb | StoreColumnType::Json
) {
out.push(ProofError::new(
ProofErrorCode::T802TypeMismatch,
where_loc.0,
where_loc.1,
format!(
"axon-T802 `LIKE` requires a Text-class column. Column \
`{}` is declared as `{}`. Use `=` for exact equality, \
or change the column to `Text` if pattern matching \
is intended.",
pred.column,
col.col_type.canonical_name()
),
));
}
}
match &pred.value {
WhereValue::Literal { kind, .. } => {
if !literal_compatible_with_column(*kind, col.col_type) {
let compat = suggest_type_compatible_columns_for_literal(
*kind,
columns,
&pred.column,
);
let compat_suffix = if compat.is_empty() {
String::new()
} else {
format!(" {compat}")
};
out.push(ProofError::new(
ProofErrorCode::T802TypeMismatch,
where_loc.0,
where_loc.1,
format!(
"axon-T802 `where:` literal of class {kind:?} is not \
type-compatible with column `{}` declared as `{}`. \
A {kind:?} literal cannot compare against a {} \
column without an explicit conversion.{compat_suffix}",
pred.column,
col.col_type.canonical_name(),
col.col_type.canonical_name()
),
));
}
}
WhereValue::BoundParam(name) => {
if let Some(axon_type) = flow_params.get(name) {
if !axon_param_compatible_with_column(axon_type, col.col_type) {
let compat = suggest_type_compatible_columns_for_param(
axon_type,
columns,
&pred.column,
);
let compat_suffix = if compat.is_empty() {
String::new()
} else {
format!(" {compat}")
};
out.push(ProofError::new(
ProofErrorCode::T802TypeMismatch,
where_loc.0,
where_loc.1,
format!(
"axon-T802 flow parameter `${{{name}}}` of type \
`{axon_type}` is not type-compatible with \
column `{}` declared as `{}`. Either align the \
parameter type with the column type, or convert \
the value at the binding site.{compat_suffix}",
pred.column,
col.col_type.canonical_name()
),
));
}
}
}
WhereValue::NullKeyword => {
}
}
}
pub fn classify_field_value(raw: &str) -> WhereValue {
let trimmed = raw.trim();
if let Some(rest) = trimmed.strip_prefix('$') {
let (inner, has_braces) = match rest.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
Some(inner) => (inner, true),
None => (rest, false),
};
if !inner.is_empty()
&& inner
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
&& inner
.chars()
.next()
.map(|c| c.is_ascii_alphabetic() || c == '_')
.unwrap_or(false)
{
let _ = has_braces;
return WhereValue::BoundParam(inner.to_string());
}
}
if trimmed.is_empty() {
return WhereValue::Literal {
kind: LiteralKind::Text,
raw: String::new(),
};
}
if let Some(stripped) = trimmed.strip_prefix('-') {
if !stripped.is_empty() && stripped.chars().all(|c| c.is_ascii_digit()) {
return WhereValue::Literal {
kind: LiteralKind::Int,
raw: trimmed.to_string(),
};
}
} else if trimmed.chars().all(|c| c.is_ascii_digit()) {
return WhereValue::Literal {
kind: LiteralKind::Int,
raw: trimmed.to_string(),
};
}
if let Ok(_) = trimmed.parse::<f64>() {
if trimmed.contains('.') {
return WhereValue::Literal {
kind: LiteralKind::Float,
raw: trimmed.to_string(),
};
}
}
if trimmed.eq_ignore_ascii_case("true") || trimmed.eq_ignore_ascii_case("false") {
return WhereValue::Literal {
kind: LiteralKind::Bool,
raw: trimmed.to_string(),
};
}
if trimmed.eq_ignore_ascii_case("null") {
return WhereValue::NullKeyword;
}
WhereValue::Literal {
kind: LiteralKind::Text,
raw: trimmed.to_string(),
}
}
pub fn check_persist_fields(
fields: &[(String, String)],
columns: &ColumnSet,
flow_params: &FlowParamTypes,
op_loc: (u32, u32),
) -> Vec<ProofError> {
let mut out: Vec<ProofError> = Vec::new();
if fields.is_empty() {
return out;
}
let provided: std::collections::BTreeSet<&str> =
fields.iter().map(|(c, _)| c.as_str()).collect();
for (col_name, col) in &columns.columns {
if col.not_null && !col.has_default && !provided.contains(col_name.as_str()) {
let pk_hint = if col.primary_key {
" (primary key)"
} else {
""
};
out.push(ProofError::new(
ProofErrorCode::T803NotNullOmitted,
op_loc.0,
op_loc.1,
format!(
"axon-T803 `persist` omits NOT-NULL column `{col_name}`{pk_hint} \
declared as `{}` with no default. The row would fail at \
the database with a NOT NULL constraint violation. \
Either bind a value in the persist field block, declare \
a `default <value>` on the column, or make the column \
nullable.",
col.col_type.canonical_name()
),
));
}
}
check_field_block_columns(fields, columns, flow_params, op_loc, &mut out);
out
}
pub fn check_mutate_fields(
fields: &[(String, String)],
columns: &ColumnSet,
flow_params: &FlowParamTypes,
op_loc: (u32, u32),
) -> Vec<ProofError> {
let mut out: Vec<ProofError> = Vec::new();
if fields.is_empty() {
return out;
}
check_field_block_columns(fields, columns, flow_params, op_loc, &mut out);
out
}
fn check_field_block_columns(
fields: &[(String, String)],
columns: &ColumnSet,
flow_params: &FlowParamTypes,
op_loc: (u32, u32),
out: &mut Vec<ProofError>,
) {
for (col_name, raw_value) in fields {
let Some(col) = columns.get(col_name) else {
let suggestion = suggest_columns_composite(col_name, columns);
let suggest_suffix = if suggestion.is_empty() {
String::new()
} else {
format!(" {suggestion}")
};
out.push(ProofError::new(
ProofErrorCode::T804UnknownField,
op_loc.0,
op_loc.1,
format!(
"axon-T804 unknown column `{col_name}` in field block. \
The declared schema has columns: {{{}}}.{suggest_suffix}",
format_column_list(columns)
),
));
continue;
};
let value = classify_field_value(raw_value);
match &value {
WhereValue::Literal { kind, .. } => {
if !literal_compatible_with_column(*kind, col.col_type) {
let compat = suggest_type_compatible_columns_for_literal(
*kind, columns, col_name,
);
let compat_suffix = if compat.is_empty() {
String::new()
} else {
format!(" {compat}")
};
out.push(ProofError::new(
ProofErrorCode::T802TypeMismatch,
op_loc.0,
op_loc.1,
format!(
"axon-T802 field-block literal of class {kind:?} is \
not type-compatible with column `{col_name}` \
declared as `{}`. A {kind:?} literal cannot \
populate a {} column without an explicit \
conversion.{compat_suffix}",
col.col_type.canonical_name(),
col.col_type.canonical_name()
),
));
}
}
WhereValue::BoundParam(name) => {
if let Some(axon_type) = flow_params.get(name) {
if !axon_param_compatible_with_column(axon_type, col.col_type) {
let compat = suggest_type_compatible_columns_for_param(
axon_type, columns, col_name,
);
let compat_suffix = if compat.is_empty() {
String::new()
} else {
format!(" {compat}")
};
out.push(ProofError::new(
ProofErrorCode::T802TypeMismatch,
op_loc.0,
op_loc.1,
format!(
"axon-T802 field-block flow parameter `${{{name}}}` \
of type `{axon_type}` is not type-compatible with \
column `{col_name}` declared as `{}`. Either align \
the parameter type with the column type, or convert \
at the binding site.{compat_suffix}",
col.col_type.canonical_name()
),
));
}
}
}
WhereValue::NullKeyword => {
if col.not_null {
out.push(ProofError::new(
ProofErrorCode::T802TypeMismatch,
op_loc.0,
op_loc.1,
format!(
"axon-T802 field-block writes `NULL` into NOT-NULL \
column `{col_name}` declared as `{}`. Either provide \
a non-null value or make the column nullable.",
col.col_type.canonical_name()
),
));
}
}
}
}
}
pub fn load_columns_for_schema(
schema: &StoreColumnSchema,
store_name: &str,
manifest: Option<&Manifest>,
) -> Option<ColumnSet> {
match schema {
StoreColumnSchema::Inline { .. } => ColumnSet::from_inline_schema(schema),
StoreColumnSchema::ManifestRef { qualified_name, .. } => {
let m = manifest?;
let store = m.lookup(qualified_name)?;
Some(ColumnSet::from_manifest_store(store))
}
StoreColumnSchema::EnvVar { var_name, .. } => {
let m = manifest?;
let exact_key = format!("{var_name}.{store_name}");
if let Some(store) = m.lookup(&exact_key) {
return Some(ColumnSet::from_manifest_store(store));
}
let suffix = format!(".{store_name}");
for (key, store) in &m.stores {
if key.ends_with(&suffix) {
return Some(ColumnSet::from_manifest_store(store));
}
}
None
}
}
}
pub fn load_manifest_from_source_dir(source_dir: &Path) -> Result<Option<Manifest>, ProofError> {
let files = store_schema_manifest::discover_manifest_files(source_dir);
if files.is_empty() {
return Ok(None);
}
match store_schema_manifest::load_and_merge_manifests(source_dir) {
Ok(m) => Ok(Some(m)),
Err(e) => Err(manifest_error_to_proof(e)),
}
}
fn manifest_error_to_proof(err: ManifestError) -> ProofError {
let (code, msg) = match &err {
ManifestError::ContentHashMismatch { .. } => (
ProofErrorCode::T805ManifestHashMismatch,
format!("axon-T805 {err}"),
),
_ => (
ProofErrorCode::T805ManifestHashMismatch,
format!("axon-T805 {err}"),
),
};
ProofError::new(code, 0, 0, msg)
}
#[cfg(test)]
mod tests {
use super::*;
fn col(name: &str, ty: StoreColumnType, not_null: bool) -> StoreColumn {
StoreColumn {
name: name.to_string(),
col_type: ty,
primary_key: false,
auto_increment: false,
not_null,
unique: false,
default_value: String::new(),
identity: false,
line: 0,
column: 0,
}
}
fn columns_for(specs: &[(&str, StoreColumnType, bool)]) -> ColumnSet {
let inline: Vec<StoreColumn> = specs
.iter()
.map(|(n, t, nn)| col(n, *t, *nn))
.collect();
ColumnSet::from_inline_columns(&inline)
}
fn params(specs: &[(&str, &str)]) -> FlowParamTypes {
let mut p = FlowParamTypes::new();
for (n, t) in specs {
p.insert((*n).to_string(), (*t).to_string());
}
p
}
#[test]
fn scan_empty_yields_no_predicates() {
assert_eq!(scan_where("").unwrap(), vec![]);
assert_eq!(scan_where(" ").unwrap(), vec![]);
}
#[test]
fn scan_eq_literal_int() {
let p = scan_where("id = 42").unwrap();
assert_eq!(p.len(), 1);
assert_eq!(p[0].column, "id");
assert_eq!(p[0].op, WhereOp::Eq);
match &p[0].value {
WhereValue::Literal { kind, raw } => {
assert_eq!(*kind, LiteralKind::Int);
assert_eq!(raw, "42");
}
other => panic!("expected Int literal, got {other:?}"),
}
}
#[test]
fn scan_eq_quoted_string() {
let p = scan_where("tier = 'premium'").unwrap();
assert_eq!(p.len(), 1);
match &p[0].value {
WhereValue::Literal { kind, raw } => {
assert_eq!(*kind, LiteralKind::Text);
assert_eq!(raw, "premium");
}
other => panic!("expected Text literal, got {other:?}"),
}
}
#[test]
fn scan_recognises_bound_param_braced_and_bare() {
let p1 = scan_where("id = ${tenant_id}").unwrap();
let p2 = scan_where("id = $tenant_id").unwrap();
for p in [&p1, &p2] {
assert_eq!(p.len(), 1);
match &p[0].value {
WhereValue::BoundParam(n) => assert_eq!(n, "tenant_id"),
other => panic!("expected BoundParam, got {other:?}"),
}
}
}
#[test]
fn scan_eq_bool_true_false() {
let p = scan_where("active = true").unwrap();
match &p[0].value {
WhereValue::Literal { kind: LiteralKind::Bool, raw } => assert_eq!(raw, "true"),
other => panic!("got {other:?}"),
}
let p2 = scan_where("active = false").unwrap();
match &p2[0].value {
WhereValue::Literal { kind: LiteralKind::Bool, raw } => assert_eq!(raw, "false"),
other => panic!("got {other:?}"),
}
}
#[test]
fn scan_float_literal() {
let p = scan_where("price = 3.14").unwrap();
match &p[0].value {
WhereValue::Literal { kind: LiteralKind::Float, raw } => assert_eq!(raw, "3.14"),
other => panic!("got {other:?}"),
}
}
#[test]
fn scan_all_six_comparison_operators() {
for (src, expected) in [
("x = 1", WhereOp::Eq),
("x == 1", WhereOp::Eq),
("x != 1", WhereOp::NotEq),
("x <> 1", WhereOp::NotEq),
("x < 1", WhereOp::Lt),
("x > 1", WhereOp::Gt),
("x <= 1", WhereOp::Le),
("x >= 1", WhereOp::Ge),
] {
let p = scan_where(src).unwrap();
assert_eq!(p[0].op, expected, "src={src}");
}
}
#[test]
fn scan_like_keyword_case_insensitive() {
for src in ["name LIKE 'A%'", "name like 'A%'", "name LiKe 'A%'"] {
let p = scan_where(src).unwrap();
assert_eq!(p[0].op, WhereOp::Like, "src={src}");
}
}
#[test]
fn scan_is_null_and_is_not_null() {
let p1 = scan_where("deleted_at IS NULL").unwrap();
assert_eq!(p1[0].op, WhereOp::IsNull);
assert_eq!(p1[0].value, WhereValue::NullKeyword);
let p2 = scan_where("deleted_at IS NOT NULL").unwrap();
assert_eq!(p2[0].op, WhereOp::IsNotNull);
}
#[test]
fn scan_multiple_predicates_joined_by_and() {
let p = scan_where("id = 1 AND tier = 'premium'").unwrap();
assert_eq!(p.len(), 2);
assert_eq!(p[0].column, "id");
assert_eq!(p[1].column, "tier");
}
#[test]
fn scan_or_connector_is_recognised() {
let p = scan_where("tier = 'a' OR tier = 'b'").unwrap();
assert_eq!(p.len(), 2);
}
#[test]
fn scan_unterminated_string_is_malformed() {
assert!(matches!(scan_where("name = 'oops"), Err(ScanError::Malformed { .. })));
}
#[test]
fn scan_trailing_connector_is_malformed() {
assert!(matches!(scan_where("id = 1 AND"), Err(ScanError::Malformed { .. })));
}
#[test]
fn scan_reserved_word_in_column_position_is_malformed() {
assert!(matches!(scan_where("AND = 1"), Err(ScanError::Malformed { .. })));
}
#[test]
fn t801_unknown_column_with_levenshtein_hint() {
let cs = columns_for(&[
("tenant_id", StoreColumnType::Uuid, true),
("tier", StoreColumnType::Text, true),
]);
let errs = check_filter("tenantid = 'x'", &cs, ¶ms(&[]), (1, 1));
assert_eq!(errs.len(), 1);
assert_eq!(errs[0].code, ProofErrorCode::T801UnknownColumn);
assert!(errs[0].message.contains("tenantid"));
assert!(
errs[0].message.contains("tenant_id"),
"expected Levenshtein hint pointing at `tenant_id`, got: {}",
errs[0].message
);
}
#[test]
fn t801_unknown_column_without_suggestion_when_too_far() {
let cs = columns_for(&[("tenant_id", StoreColumnType::Uuid, true)]);
let errs = check_filter("WildlyDifferent = 1", &cs, ¶ms(&[]), (1, 1));
assert_eq!(errs.len(), 1);
assert_eq!(errs[0].code, ProofErrorCode::T801UnknownColumn);
assert!(
!errs[0].message.contains("Did you mean"),
"an out-of-distance typo must not surface a guess: {}",
errs[0].message
);
}
#[test]
fn known_column_passes_silently() {
let cs = columns_for(&[("id", StoreColumnType::Int, false)]);
let errs = check_filter("id = 42", &cs, ¶ms(&[]), (1, 1));
assert!(errs.is_empty(), "expected zero proof errors, got {errs:?}");
}
#[test]
fn t802_int_literal_against_text_column_is_rejected() {
let cs = columns_for(&[("tier", StoreColumnType::Text, true)]);
let errs = check_filter("tier = 42", &cs, ¶ms(&[]), (1, 1));
assert_eq!(errs.len(), 1);
assert_eq!(errs[0].code, ProofErrorCode::T802TypeMismatch);
}
#[test]
fn t802_bool_literal_against_uuid_column_is_rejected() {
let cs = columns_for(&[("id", StoreColumnType::Uuid, true)]);
let errs = check_filter("id = true", &cs, ¶ms(&[]), (1, 1));
assert_eq!(errs.len(), 1);
assert_eq!(errs[0].code, ProofErrorCode::T802TypeMismatch);
}
#[test]
fn text_literal_against_uuid_column_passes_canonical_form_rule() {
let cs = columns_for(&[("id", StoreColumnType::Uuid, true)]);
let errs = check_filter(
"id = '83d078e1-b372-42ba-9572-ff8dc521386e'",
&cs,
¶ms(&[]),
(1, 1),
);
assert!(errs.is_empty(), "text literal must match Uuid column, got {errs:?}");
}
#[test]
fn int_literal_against_int_column_passes() {
let cs = columns_for(&[("id", StoreColumnType::Int, false)]);
let errs = check_filter("id = 42", &cs, ¶ms(&[]), (1, 1));
assert!(errs.is_empty());
}
#[test]
fn float_literal_against_numeric_column_passes() {
let cs = columns_for(&[("amount", StoreColumnType::Numeric, false)]);
let errs = check_filter("amount = 3.14", &cs, ¶ms(&[]), (1, 1));
assert!(errs.is_empty());
}
#[test]
fn bool_literal_against_bool_column_passes() {
let cs = columns_for(&[("active", StoreColumnType::Bool, false)]);
let errs = check_filter("active = true", &cs, ¶ms(&[]), (1, 1));
assert!(errs.is_empty());
}
#[test]
fn t802_like_against_uuid_column_is_rejected() {
let cs = columns_for(&[("id", StoreColumnType::Uuid, true)]);
let errs = check_filter("id LIKE 'abc%'", &cs, ¶ms(&[]), (1, 1));
assert!(
errs.iter().any(|e| e.message.contains("LIKE")),
"LIKE on Uuid must surface T802 with a `LIKE` mention; got {errs:?}"
);
}
#[test]
fn like_against_text_column_passes() {
let cs = columns_for(&[("name", StoreColumnType::Text, false)]);
let errs = check_filter("name LIKE 'A%'", &cs, ¶ms(&[]), (1, 1));
assert!(errs.is_empty());
}
#[test]
fn bound_param_string_against_uuid_column_passes() {
let cs = columns_for(&[("id", StoreColumnType::Uuid, true)]);
let fp = params(&[("tenant_id", "String")]);
let errs = check_filter("id = ${tenant_id}", &cs, &fp, (1, 1));
assert!(errs.is_empty(), "got {errs:?}");
}
#[test]
fn t802_bound_param_int_against_uuid_column_is_rejected() {
let cs = columns_for(&[("id", StoreColumnType::Uuid, true)]);
let fp = params(&[("some_int", "Int")]);
let errs = check_filter("id = ${some_int}", &cs, &fp, (1, 1));
assert_eq!(errs.len(), 1);
assert_eq!(errs[0].code, ProofErrorCode::T802TypeMismatch);
assert!(errs[0].message.contains("some_int"));
assert!(errs[0].message.contains("Uuid"));
}
#[test]
fn bound_param_int_against_int_column_passes() {
let cs = columns_for(&[("id", StoreColumnType::Int, false)]);
let fp = params(&[("the_id", "Int")]);
let errs = check_filter("id = ${the_id}", &cs, &fp, (1, 1));
assert!(errs.is_empty());
}
#[test]
fn bound_param_undeclared_in_flow_silently_passes() {
let cs = columns_for(&[("id", StoreColumnType::Uuid, true)]);
let errs = check_filter("id = ${not_a_param}", &cs, ¶ms(&[]), (1, 1));
assert!(errs.is_empty());
}
#[test]
fn bound_param_with_optional_wrapper_unwraps_correctly() {
let cs = columns_for(&[("id", StoreColumnType::Int, false)]);
let fp = params(&[("maybe_id", "Optional<Int>")]);
let errs = check_filter("id = ${maybe_id}", &cs, &fp, (1, 1));
assert!(errs.is_empty());
}
#[test]
fn is_null_passes_against_any_column() {
let cs = columns_for(&[("deleted_at", StoreColumnType::Timestamptz, true)]);
let errs = check_filter("deleted_at IS NULL", &cs, ¶ms(&[]), (1, 1));
assert!(errs.is_empty());
}
#[test]
fn is_not_null_passes_against_any_column() {
let cs = columns_for(&[("deleted_at", StoreColumnType::Timestamptz, true)]);
let errs = check_filter("deleted_at IS NOT NULL", &cs, ¶ms(&[]), (1, 1));
assert!(errs.is_empty());
}
#[test]
fn is_null_against_unknown_column_still_flags_t801() {
let cs = columns_for(&[("id", StoreColumnType::Uuid, true)]);
let errs = check_filter("ghost IS NULL", &cs, ¶ms(&[]), (1, 1));
assert_eq!(errs.len(), 1);
assert_eq!(errs[0].code, ProofErrorCode::T801UnknownColumn);
}
#[test]
fn multiple_predicates_all_proven_independently() {
let cs = columns_for(&[
("id", StoreColumnType::Uuid, true),
("active", StoreColumnType::Bool, false),
]);
let errs = check_filter(
"id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' AND statuz = 'on'",
&cs,
¶ms(&[]),
(1, 1),
);
assert_eq!(errs.len(), 1);
assert_eq!(errs[0].code, ProofErrorCode::T801UnknownColumn);
assert!(errs[0].message.contains("statuz"));
}
#[test]
fn multiple_errors_in_one_expression_are_all_reported() {
let cs = columns_for(&[("id", StoreColumnType::Uuid, true)]);
let errs = check_filter("ghost1 = 1 AND ghost2 = 2", &cs, ¶ms(&[]), (1, 1));
assert_eq!(errs.len(), 2);
assert!(errs.iter().all(|e| e.code == ProofErrorCode::T801UnknownColumn));
}
#[test]
fn every_catalog_type_accepts_a_string_literal_or_int_or_bool() {
for &t in StoreColumnType::ALL {
let cs = columns_for(&[("col", t, false)]);
let candidates = match t {
StoreColumnType::Bool => vec!["col = true"],
StoreColumnType::Int
| StoreColumnType::BigInt => vec!["col = 1"],
StoreColumnType::Float
| StoreColumnType::Double => vec!["col = 1.5"],
_ => vec!["col = 'x'"],
};
for src in candidates {
let errs = check_filter(src, &cs, ¶ms(&[]), (1, 1));
assert!(
errs.is_empty(),
"catalog type {} rejected `{src}` unexpectedly: {errs:?}",
t.canonical_name()
);
}
}
}
#[test]
fn every_catalog_type_accepts_a_compatible_string_param() {
let fp = params(&[("p", "String")]);
for &t in StoreColumnType::ALL {
let cs = columns_for(&[("col", t, false)]);
let errs = check_filter("col = ${p}", &cs, &fp, (1, 1));
assert!(
errs.is_empty(),
"String param rejected by catalog type {}: {errs:?}",
t.canonical_name()
);
}
}
#[test]
fn form_b_manifest_ref_resolves_against_provided_manifest() {
let manifest = Manifest::parse_json(
r#"{
"version": 1,
"stores": {
"public.tenants": {
"columns": {
"tenant_id": { "type": "Uuid", "primary_key": true }
}
}
}
}"#,
)
.unwrap();
let schema = StoreColumnSchema::ManifestRef {
qualified_name: "public.tenants".to_string(),
line: 1,
column: 1,
};
let cs = load_columns_for_schema(&schema, "tenants", Some(&manifest)).unwrap();
assert!(cs.contains("tenant_id"));
}
#[test]
fn form_b_manifest_ref_returns_none_when_manifest_absent() {
let schema = StoreColumnSchema::ManifestRef {
qualified_name: "public.tenants".to_string(),
line: 1,
column: 1,
};
assert!(load_columns_for_schema(&schema, "tenants", None).is_none());
}
#[test]
fn form_c_env_var_uses_first_match_heuristic_when_exact_key_missing() {
let manifest = Manifest::parse_json(
r#"{
"version": 1,
"stores": {
"tenant_42.events": {
"columns": {
"event_id": { "type": "Uuid" }
}
}
}
}"#,
)
.unwrap();
let schema = StoreColumnSchema::EnvVar {
var_name: "TENANT_SCHEMA".to_string(),
line: 1,
column: 1,
};
let cs = load_columns_for_schema(&schema, "events", Some(&manifest)).unwrap();
assert!(cs.contains("event_id"));
}
#[test]
fn form_c_env_var_prefers_exact_key_when_present() {
let manifest = Manifest::parse_json(
r#"{
"version": 1,
"stores": {
"TENANT_SCHEMA.events": {
"columns": {
"exact_match_only": { "type": "Uuid" }
}
},
"tenant_42.events": {
"columns": {
"first_match_fallback": { "type": "Text" }
}
}
}
}"#,
)
.unwrap();
let schema = StoreColumnSchema::EnvVar {
var_name: "TENANT_SCHEMA".to_string(),
line: 1,
column: 1,
};
let cs = load_columns_for_schema(&schema, "events", Some(&manifest)).unwrap();
assert!(cs.contains("exact_match_only"));
assert!(!cs.contains("first_match_fallback"));
}
#[test]
fn error_code_slugs_match_the_axon_t801_through_t805_namespace() {
assert_eq!(ProofErrorCode::T801UnknownColumn.slug(), "axon-T801");
assert_eq!(ProofErrorCode::T802TypeMismatch.slug(), "axon-T802");
assert_eq!(ProofErrorCode::T803NotNullOmitted.slug(), "axon-T803");
assert_eq!(ProofErrorCode::T804UnknownField.slug(), "axon-T804");
assert_eq!(ProofErrorCode::T805ManifestHashMismatch.slug(), "axon-T805");
}
fn col_full(
name: &str,
ty: StoreColumnType,
not_null: bool,
primary_key: bool,
default_value: &str,
auto_increment: bool,
) -> StoreColumn {
StoreColumn {
name: name.to_string(),
col_type: ty,
primary_key,
auto_increment,
not_null,
unique: false,
default_value: default_value.to_string(),
identity: false,
line: 0,
column: 0,
}
}
fn columns_full(
specs: &[(&str, StoreColumnType, bool, bool, &str, bool)],
) -> ColumnSet {
let inline: Vec<StoreColumn> = specs
.iter()
.map(|(n, t, nn, pk, dv, ai)| col_full(n, *t, *nn, *pk, dv, *ai))
.collect();
ColumnSet::from_inline_columns(&inline)
}
#[test]
fn classify_field_value_recognises_braced_bound_param() {
assert_eq!(
classify_field_value("${tenant_id}"),
WhereValue::BoundParam("tenant_id".to_string())
);
}
#[test]
fn classify_field_value_recognises_bare_bound_param() {
assert_eq!(
classify_field_value("$tenant_id"),
WhereValue::BoundParam("tenant_id".to_string())
);
}
#[test]
fn classify_field_value_recognises_int_literal() {
assert_eq!(
classify_field_value("42"),
WhereValue::Literal {
kind: LiteralKind::Int,
raw: "42".to_string()
}
);
assert_eq!(
classify_field_value("-7"),
WhereValue::Literal {
kind: LiteralKind::Int,
raw: "-7".to_string()
}
);
}
#[test]
fn classify_field_value_recognises_float_literal() {
match classify_field_value("3.14") {
WhereValue::Literal { kind: LiteralKind::Float, raw } => assert_eq!(raw, "3.14"),
other => panic!("got {other:?}"),
}
}
#[test]
fn classify_field_value_recognises_bool_literal() {
for src in ["true", "false", "TRUE", "False"] {
match classify_field_value(src) {
WhereValue::Literal { kind: LiteralKind::Bool, .. } => {}
other => panic!("`{src}` → {other:?}"),
}
}
}
#[test]
fn classify_field_value_recognises_null_keyword() {
assert_eq!(classify_field_value("null"), WhereValue::NullKeyword);
assert_eq!(classify_field_value("NULL"), WhereValue::NullKeyword);
}
#[test]
fn classify_field_value_falls_back_to_text_for_multi_interpolation_or_raw() {
match classify_field_value("prefix ${a} suffix") {
WhereValue::Literal { kind: LiteralKind::Text, .. } => {}
other => panic!("got {other:?}"),
}
match classify_field_value("standard") {
WhereValue::Literal { kind: LiteralKind::Text, .. } => {}
other => panic!("got {other:?}"),
}
}
#[test]
fn classify_field_value_empty_string_is_a_text_literal() {
assert_eq!(
classify_field_value(""),
WhereValue::Literal {
kind: LiteralKind::Text,
raw: String::new()
}
);
}
#[test]
fn t803_persist_omits_not_null_column_with_no_default() {
let cs = columns_full(&[
("tenant_id", StoreColumnType::Uuid, true, true, "", false), ("tier", StoreColumnType::Text, true, false, "", false), ]);
let errs = check_persist_fields(
&[("tenant_id".into(), "${tenant_id}".into())],
&cs,
¶ms(&[("tenant_id", "String")]),
(1, 1),
);
assert_eq!(errs.len(), 1);
assert_eq!(errs[0].code, ProofErrorCode::T803NotNullOmitted);
assert!(errs[0].message.contains("tier"));
assert!(errs[0].message.contains("NOT NULL"));
}
#[test]
fn t803_skips_not_null_column_with_default() {
let cs = columns_full(&[
("id", StoreColumnType::Uuid, true, true, "", false),
("created_at", StoreColumnType::Timestamptz, true, false, "now()", false),
]);
let errs = check_persist_fields(
&[("id".into(), "${id}".into())],
&cs,
¶ms(&[("id", "String")]),
(1, 1),
);
assert!(
errs.is_empty(),
"default-bearing NOT-NULL column must be omittable: {errs:?}"
);
}
#[test]
fn t803_skips_auto_increment_column() {
let cs = columns_full(&[
("id", StoreColumnType::Int, true, true, "", true), ("name", StoreColumnType::Text, false, false, "", false),
]);
let errs = check_persist_fields(
&[("name".into(), "'Alice'".into())],
&cs,
¶ms(&[]),
(1, 1),
);
assert!(errs.is_empty(), "auto_increment column must be omittable: {errs:?}");
}
#[test]
fn t803_nullable_column_can_be_omitted() {
let cs = columns_full(&[
("id", StoreColumnType::Uuid, true, true, "", false),
("notes", StoreColumnType::Text, false, false, "", false), ]);
let errs = check_persist_fields(
&[("id".into(), "${id}".into())],
&cs,
¶ms(&[("id", "String")]),
(1, 1),
);
assert!(errs.is_empty());
}
#[test]
fn t803_multiple_omitted_not_null_columns_each_surface_an_error() {
let cs = columns_full(&[
("a", StoreColumnType::Text, true, false, "", false),
("b", StoreColumnType::Text, true, false, "", false),
("c", StoreColumnType::Text, false, false, "", false), ]);
let errs = check_persist_fields(&[], &cs, ¶ms(&[]), (1, 1));
assert!(errs.is_empty());
let errs2 = check_persist_fields(
&[("a".into(), "'x'".into())],
&cs,
¶ms(&[]),
(1, 1),
);
assert_eq!(errs2.len(), 1);
assert_eq!(errs2[0].code, ProofErrorCode::T803NotNullOmitted);
assert!(errs2[0].message.contains("`b`"));
}
#[test]
fn t804_persist_field_typo_with_levenshtein_hint() {
let cs = columns_full(&[
("tenant_id", StoreColumnType::Uuid, true, true, "", false),
("tier", StoreColumnType::Text, true, false, "", false),
]);
let errs = check_persist_fields(
&[
("tenant_id".into(), "${tid}".into()),
("tier".into(), "'std'".into()),
("tenantid".into(), "${tid}".into()), ],
&cs,
¶ms(&[("tid", "String")]),
(1, 1),
);
let t804: Vec<&ProofError> = errs
.iter()
.filter(|e| e.code == ProofErrorCode::T804UnknownField)
.collect();
assert_eq!(t804.len(), 1);
assert!(t804[0].message.contains("tenantid"));
assert!(t804[0].message.contains("tenant_id"));
}
#[test]
fn t804_persist_unknown_field_without_suggestion_when_too_far() {
let cs = columns_full(&[("id", StoreColumnType::Uuid, true, true, "", false)]);
let errs = check_persist_fields(
&[
("id".into(), "${id}".into()),
("CompletelyDifferent".into(), "'x'".into()),
],
&cs,
¶ms(&[("id", "String")]),
(1, 1),
);
let t804: Vec<&ProofError> = errs
.iter()
.filter(|e| e.code == ProofErrorCode::T804UnknownField)
.collect();
assert_eq!(t804.len(), 1);
assert!(!t804[0].message.contains("Did you mean"));
}
#[test]
fn t802_persist_int_literal_against_text_column() {
let cs = columns_full(&[
("id", StoreColumnType::Int, true, true, "", true),
("name", StoreColumnType::Text, true, false, "", false),
]);
let errs = check_persist_fields(
&[("name".into(), "42".into())],
&cs,
¶ms(&[]),
(1, 1),
);
let t802: Vec<&ProofError> = errs
.iter()
.filter(|e| e.code == ProofErrorCode::T802TypeMismatch)
.collect();
assert_eq!(t802.len(), 1);
assert!(t802[0].message.contains("Int"));
assert!(t802[0].message.contains("Text"));
}
#[test]
fn t802_persist_bound_param_type_mismatch_in_field_block() {
let cs = columns_full(&[
("id", StoreColumnType::Uuid, true, true, "", false),
("count", StoreColumnType::Int, true, false, "", false),
]);
let errs = check_persist_fields(
&[
("id".into(), "${id}".into()),
("count".into(), "${flag}".into()),
],
&cs,
¶ms(&[("id", "String"), ("flag", "Bool")]),
(1, 1),
);
let t802: Vec<&ProofError> = errs
.iter()
.filter(|e| e.code == ProofErrorCode::T802TypeMismatch)
.collect();
assert_eq!(t802.len(), 1, "expected exactly one T802, got: {errs:?}");
assert!(t802[0].message.contains("flag"));
assert!(t802[0].message.contains("Bool"));
assert!(t802[0].message.contains("Int"));
}
#[test]
fn persist_bool_param_into_bool_column_passes() {
let cs = columns_full(&[
("id", StoreColumnType::Uuid, true, true, "", false),
("active", StoreColumnType::Bool, true, false, "", false),
]);
let errs = check_persist_fields(
&[
("id".into(), "${id}".into()),
("active".into(), "${active}".into()),
],
&cs,
¶ms(&[("id", "String"), ("active", "Bool")]),
(1, 1),
);
assert!(errs.is_empty());
}
#[test]
fn t802_persist_null_into_not_null_column_is_rejected() {
let cs = columns_full(&[
("id", StoreColumnType::Uuid, true, true, "", false),
("name", StoreColumnType::Text, true, false, "", false),
]);
let errs = check_persist_fields(
&[
("id".into(), "${id}".into()),
("name".into(), "null".into()),
],
&cs,
¶ms(&[("id", "String")]),
(1, 1),
);
let t802: Vec<&ProofError> = errs
.iter()
.filter(|e| e.code == ProofErrorCode::T802TypeMismatch)
.collect();
assert_eq!(t802.len(), 1);
assert!(t802[0].message.contains("NULL"));
assert!(t802[0].message.contains("NOT-NULL"));
}
#[test]
fn persist_null_into_nullable_column_passes() {
let cs = columns_full(&[
("id", StoreColumnType::Uuid, true, true, "", false),
("notes", StoreColumnType::Text, false, false, "", false),
]);
let errs = check_persist_fields(
&[
("id".into(), "${id}".into()),
("notes".into(), "null".into()),
],
&cs,
¶ms(&[("id", "String")]),
(1, 1),
);
assert!(errs.is_empty());
}
#[test]
fn well_formed_persist_passes_with_zero_errors() {
let cs = columns_full(&[
("tenant_id", StoreColumnType::Uuid, true, true, "", false),
("tier", StoreColumnType::Text, true, false, "", false),
("active", StoreColumnType::Bool, false, false, "", false),
]);
let errs = check_persist_fields(
&[
("tenant_id".into(), "${tid}".into()),
("tier".into(), "'standard'".into()),
],
&cs,
¶ms(&[("tid", "String")]),
(1, 1),
);
assert!(errs.is_empty(), "got {errs:?}");
}
#[test]
fn d5_blockless_persist_is_skipped() {
let cs = columns_full(&[
("a", StoreColumnType::Text, true, false, "", false),
("b", StoreColumnType::Text, true, false, "", false),
]);
let errs = check_persist_fields(&[], &cs, ¶ms(&[]), (1, 1));
assert!(errs.is_empty());
}
#[test]
fn mutate_does_not_emit_t803_for_omitted_not_null_columns() {
let cs = columns_full(&[
("id", StoreColumnType::Uuid, true, true, "", false),
("tier", StoreColumnType::Text, true, false, "", false),
("active", StoreColumnType::Bool, true, false, "", false),
]);
let errs = check_mutate_fields(
&[("tier".into(), "'premium'".into())],
&cs,
¶ms(&[]),
(1, 1),
);
let t803: Vec<&ProofError> = errs
.iter()
.filter(|e| e.code == ProofErrorCode::T803NotNullOmitted)
.collect();
assert!(t803.is_empty(), "mutate must not emit T803: {errs:?}");
}
#[test]
fn t804_mutate_field_typo_with_levenshtein() {
let cs = columns_full(&[
("tenant_id", StoreColumnType::Uuid, true, true, "", false),
("tier", StoreColumnType::Text, true, false, "", false),
]);
let errs = check_mutate_fields(
&[("teir".into(), "'premium'".into())],
&cs,
¶ms(&[]),
(1, 1),
);
assert_eq!(errs.len(), 1);
assert_eq!(errs[0].code, ProofErrorCode::T804UnknownField);
assert!(errs[0].message.contains("teir"));
assert!(errs[0].message.contains("tier"));
}
#[test]
fn t802_mutate_value_type_mismatch() {
let cs = columns_full(&[
("id", StoreColumnType::Uuid, true, true, "", false),
("count", StoreColumnType::Int, false, false, "", false),
]);
let errs = check_mutate_fields(
&[("count".into(), "'not_an_int'".into())],
&cs,
¶ms(&[]),
(1, 1),
);
let t802: Vec<&ProofError> = errs
.iter()
.filter(|e| e.code == ProofErrorCode::T802TypeMismatch)
.collect();
assert_eq!(t802.len(), 1);
}
#[test]
fn well_formed_mutate_set_block_passes() {
let cs = columns_full(&[
("id", StoreColumnType::Uuid, true, true, "", false),
("tier", StoreColumnType::Text, true, false, "", false),
]);
let errs = check_mutate_fields(
&[("tier".into(), "${new_tier}".into())],
&cs,
¶ms(&[("new_tier", "String")]),
(1, 1),
);
assert!(errs.is_empty());
}
#[test]
fn d5_blockless_mutate_is_skipped() {
let cs = columns_full(&[("a", StoreColumnType::Text, true, false, "", false)]);
let errs = check_mutate_fields(&[], &cs, ¶ms(&[]), (1, 1));
assert!(errs.is_empty());
}
#[test]
fn persist_string_param_accepted_against_every_catalog_type() {
let fp = params(&[("p", "String")]);
for &t in StoreColumnType::ALL {
let cs = columns_full(&[("col", t, false, false, "", false)]);
let errs = check_persist_fields(
&[("col".into(), "${p}".into())],
&cs,
&fp,
(1, 1),
);
assert!(
errs.is_empty(),
"persist String → {} rejected: {errs:?}",
t.canonical_name()
);
}
}
#[test]
fn format_column_list_renders_name_colon_type_alphabetically() {
let cs = columns_for(&[
("tier", StoreColumnType::Text, false),
("tenant_id", StoreColumnType::Uuid, true),
("created_at", StoreColumnType::Timestamptz, false),
]);
let rendered = format_column_list(&cs);
assert_eq!(
rendered,
"created_at: Timestamptz, tenant_id: Uuid, tier: Text"
);
}
#[test]
fn format_column_list_for_empty_schema_is_empty_string() {
let cs = ColumnSet::default();
assert_eq!(format_column_list(&cs), "");
}
#[test]
fn suggest_columns_composite_single_match_includes_type() {
let cs = columns_for(&[
("tenant_id", StoreColumnType::Uuid, true),
("tier", StoreColumnType::Text, false),
]);
let hint = suggest_columns_composite("tenantid", &cs);
assert_eq!(hint, "Did you mean column `tenant_id` (Uuid)?");
}
#[test]
fn suggest_columns_composite_two_matches_uses_or_separator() {
let cs = columns_for(&[
("tier", StoreColumnType::Text, false),
("tear", StoreColumnType::Int, false), ]);
let hint = suggest_columns_composite("ter", &cs);
assert!(hint.starts_with("Did you mean column "));
assert!(hint.contains("`tear` (Int)"));
assert!(hint.contains("`tier` (Text)"));
assert!(hint.contains(" or "));
}
#[test]
fn suggest_columns_composite_three_matches_uses_oxford_comma() {
let cs = columns_for(&[
("ax", StoreColumnType::Int, false),
("bx", StoreColumnType::Int, false),
("cx", StoreColumnType::Int, false),
]);
let hint = suggest_columns_composite("x", &cs);
assert!(hint.starts_with("Did you mean column "));
assert!(hint.contains("`ax` (Int)"));
assert!(hint.contains("`bx` (Int)"));
assert!(hint.contains("`cx` (Int)"));
assert!(hint.contains(", or `"));
}
#[test]
fn suggest_columns_composite_returns_empty_when_no_candidate_in_distance() {
let cs = columns_for(&[("id", StoreColumnType::Uuid, true)]);
assert_eq!(suggest_columns_composite("WildlyDifferent", &cs), "");
}
#[test]
fn suggest_columns_composite_caps_at_three_results() {
let cs = columns_for(&[
("ax", StoreColumnType::Int, false),
("bx", StoreColumnType::Int, false),
("cx", StoreColumnType::Int, false),
("dx", StoreColumnType::Int, false),
("ex", StoreColumnType::Int, false),
]);
let hint = suggest_columns_composite("x", &cs);
let matches = hint.matches("` (Int)").count();
assert_eq!(matches, smart_suggest::MAX_RESULTS, "got: {hint}");
}
#[test]
fn t801_message_renders_columns_with_types_and_composite_suggestion() {
let cs = columns_for(&[
("tenant_id", StoreColumnType::Uuid, true),
("tier", StoreColumnType::Text, true),
]);
let errs = check_filter("tenantid = 'x'", &cs, ¶ms(&[]), (1, 1));
assert_eq!(errs.len(), 1);
let msg = &errs[0].message;
assert_eq!(errs[0].code, ProofErrorCode::T801UnknownColumn);
assert!(
msg.contains("Did you mean column `tenant_id` (Uuid)?"),
"T801 message must carry the composite suggestion, got: {msg}"
);
assert!(
msg.contains("tenant_id: Uuid"),
"T801 message must list columns with types, got: {msg}"
);
assert!(msg.contains("tier: Text"));
}
#[test]
fn t804_message_renders_columns_with_types_and_composite_suggestion() {
let cs = columns_full(&[
("tenant_id", StoreColumnType::Uuid, true, true, "", false),
("tier", StoreColumnType::Text, true, false, "", false),
]);
let errs = check_persist_fields(
&[
("tenant_id".into(), "${id}".into()),
("tier".into(), "'std'".into()),
("tenantid".into(), "${id}".into()), ],
&cs,
¶ms(&[("id", "String")]),
(1, 1),
);
let t804 = errs
.iter()
.find(|e| e.code == ProofErrorCode::T804UnknownField)
.expect("expected T804");
assert!(
t804.message.contains("Did you mean column `tenant_id` (Uuid)?"),
"T804 message must carry the composite suggestion, got: {}",
t804.message
);
assert!(t804.message.contains("tier: Text"));
}
#[test]
fn t802_literal_mismatch_surfaces_a_compatible_columns_hint() {
let cs = columns_for(&[
("tier", StoreColumnType::Text, true),
("count", StoreColumnType::Int, false),
]);
let errs = check_filter("tier = 42", &cs, ¶ms(&[]), (1, 1));
assert_eq!(errs.len(), 1);
let msg = &errs[0].message;
assert_eq!(errs[0].code, ProofErrorCode::T802TypeMismatch);
assert!(
msg.contains("Compatible") && msg.contains("`count` (Int)"),
"T802 message must surface compatible columns, got: {msg}"
);
}
#[test]
fn t802_compatible_columns_omits_the_failing_column_itself() {
let cs = columns_for(&[("tier", StoreColumnType::Text, true)]);
let errs = check_filter("tier = 42", &cs, ¶ms(&[]), (1, 1));
let msg = &errs[0].message;
assert!(
!msg.contains("Compatible"),
"no compatible-column hint when no alternative exists: {msg}"
);
}
#[test]
fn t802_bound_param_mismatch_surfaces_compatible_columns_for_the_param_type() {
let cs = columns_for(&[
("id", StoreColumnType::Uuid, true),
("active", StoreColumnType::Bool, false),
]);
let fp = params(&[("count", "Bool")]);
let errs = check_filter("id = ${count}", &cs, &fp, (1, 1));
assert_eq!(errs.len(), 1);
let msg = &errs[0].message;
assert!(
msg.contains("Compatible") && msg.contains("`active` (Bool)"),
"T802 bound-param mismatch must surface a Bool-compatible \
column hint, got: {msg}"
);
}
#[test]
fn t802_field_block_literal_mismatch_surfaces_compatible_columns() {
let cs = columns_full(&[
("id", StoreColumnType::Uuid, true, true, "", false),
("name", StoreColumnType::Text, false, false, "", false),
("count", StoreColumnType::Int, false, false, "", false),
]);
let errs = check_persist_fields(
&[
("id".into(), "${id}".into()),
("name".into(), "42".into()),
],
&cs,
¶ms(&[("id", "String")]),
(1, 1),
);
let t802 = errs
.iter()
.find(|e| e.code == ProofErrorCode::T802TypeMismatch)
.expect("expected field-block T802");
assert!(
t802.message.contains("Compatible") && t802.message.contains("`count` (Int)"),
"field-block T802 must surface compatible columns: {}",
t802.message
);
}
#[test]
fn t802_compatible_columns_caps_at_max_compat_suggestions() {
let cs = columns_for(&[
("a", StoreColumnType::Int, false),
("b", StoreColumnType::Int, false),
("c", StoreColumnType::Int, false),
("d", StoreColumnType::Int, false),
("e", StoreColumnType::Int, false),
("tier", StoreColumnType::Text, true),
]);
let errs = check_filter("tier = 42", &cs, ¶ms(&[]), (1, 1));
let msg = &errs[0].message;
let after_compat = msg.split("Compatible").nth(1).unwrap_or("");
let hits = after_compat.matches("` (Int)").count();
assert_eq!(
hits, MAX_COMPAT_SUGGESTIONS,
"expected exactly {} compatible-column hits, got: {msg}",
MAX_COMPAT_SUGGESTIONS
);
}
#[test]
fn t802_like_against_non_text_does_not_surface_a_misleading_compat_hint() {
let cs = columns_for(&[
("id", StoreColumnType::Uuid, true),
("name", StoreColumnType::Text, false),
]);
let errs = check_filter("id LIKE 'abc%'", &cs, ¶ms(&[]), (1, 1));
let t802_msg = errs
.iter()
.find(|e| e.code == ProofErrorCode::T802TypeMismatch)
.map(|e| e.message.as_str())
.unwrap_or("");
assert!(t802_msg.contains("LIKE"), "LIKE message missing: {t802_msg}");
}
}