use sea_query::{
Alias, ColumnDef as SeaColumnDef, ForeignKeyAction, MysqlQueryBuilder, PostgresQueryBuilder,
Query, QueryStatementWriter, SchemaStatementBuilder, SimpleExpr, SqliteQueryBuilder, Table,
};
use vespertide_core::{
ColumnDef, ColumnType, ComplexColumnType, EnumValues, ReferenceAction, SimpleColumnType,
TableConstraint, TableDef,
};
use super::create_table::build_create_table_for_backend;
use super::types::{BuiltQuery, DatabaseBackend, RawSql};
#[must_use]
pub(crate) fn normalize_fill_with(fill_with: Option<&str>) -> Option<&str> {
fill_with.map(|s| if s.is_empty() { "''" } else { s })
}
pub(crate) fn build_schema_statement<T: SchemaStatementBuilder>(
stmt: &T,
backend: DatabaseBackend,
) -> String {
match backend {
DatabaseBackend::Postgres => stmt.to_string(PostgresQueryBuilder),
DatabaseBackend::MySql => stmt.to_string(MysqlQueryBuilder),
DatabaseBackend::Sqlite => stmt.to_string(SqliteQueryBuilder),
}
}
pub(crate) fn build_query_statement<T: QueryStatementWriter>(
stmt: &T,
backend: DatabaseBackend,
) -> String {
match backend {
DatabaseBackend::Postgres => stmt.to_string(PostgresQueryBuilder),
DatabaseBackend::MySql => stmt.to_string(MysqlQueryBuilder),
DatabaseBackend::Sqlite => stmt.to_string(SqliteQueryBuilder),
}
}
pub(crate) fn apply_column_type_with_table(
col: &mut SeaColumnDef,
ty: &ColumnType,
table: &str,
backend: DatabaseBackend,
) {
match ty {
ColumnType::Simple(simple) => apply_simple_column_type(col, *simple, backend),
ColumnType::Complex(complex) => apply_complex_column_type(col, complex, table, backend),
}
}
fn apply_simple_column_type(
col: &mut SeaColumnDef,
simple: SimpleColumnType,
backend: DatabaseBackend,
) {
match simple {
SimpleColumnType::SmallInt => {
col.small_integer();
}
SimpleColumnType::Integer => {
col.integer();
}
SimpleColumnType::BigInt => {
col.big_integer();
}
SimpleColumnType::Real => {
col.float();
}
SimpleColumnType::DoublePrecision => {
col.double();
}
SimpleColumnType::Text => {
col.text();
}
SimpleColumnType::Boolean => {
col.boolean();
}
SimpleColumnType::Date => {
col.date();
}
SimpleColumnType::Time => {
col.time();
}
SimpleColumnType::Timestamp => {
col.timestamp();
}
SimpleColumnType::Timestamptz => apply_timestamptz_type(col, backend),
SimpleColumnType::Interval => apply_interval_type(col, backend),
SimpleColumnType::Bytea => {
col.binary();
}
SimpleColumnType::Uuid => {
col.uuid();
}
SimpleColumnType::Json => {
col.json();
}
SimpleColumnType::Inet => apply_postgres_text_fallback_type(col, backend, "INET"),
SimpleColumnType::Cidr => apply_postgres_text_fallback_type(col, backend, "CIDR"),
SimpleColumnType::Macaddr => apply_postgres_text_fallback_type(col, backend, "MACADDR"),
SimpleColumnType::Xml => apply_postgres_text_fallback_type(col, backend, "XML"),
_ => unreachable!("SimpleColumnType is #[non_exhaustive]; all variants are matched above"),
}
}
fn apply_timestamptz_type(col: &mut SeaColumnDef, backend: DatabaseBackend) {
match backend {
DatabaseBackend::Postgres => {
col.timestamp_with_time_zone();
}
DatabaseBackend::MySql | DatabaseBackend::Sqlite => {
col.timestamp();
}
}
}
fn apply_interval_type(col: &mut SeaColumnDef, backend: DatabaseBackend) {
match backend {
DatabaseBackend::Postgres => {
col.interval(None, None);
}
DatabaseBackend::MySql | DatabaseBackend::Sqlite => {
col.text();
}
}
}
fn apply_postgres_text_fallback_type(
col: &mut SeaColumnDef,
backend: DatabaseBackend,
postgres_type: &str,
) {
match backend {
DatabaseBackend::Postgres => {
col.custom(Alias::new(postgres_type));
}
DatabaseBackend::MySql | DatabaseBackend::Sqlite => {
col.text();
}
}
}
fn apply_complex_column_type(
col: &mut SeaColumnDef,
complex: &ComplexColumnType,
table: &str,
backend: DatabaseBackend,
) {
match complex {
ComplexColumnType::Varchar { length } => {
col.string_len(*length);
}
ComplexColumnType::Numeric { precision, scale } => {
apply_numeric_type(col, *precision, *scale, backend);
}
ComplexColumnType::Char { length } => {
col.char_len(*length);
}
ComplexColumnType::Custom { custom_type } => {
col.custom(Alias::new(custom_type));
}
ComplexColumnType::Enum { name, values } => match values {
EnumValues::Integer(_) => {
col.integer();
}
EnumValues::String(variants) => {
let type_name = build_enum_type_name(table, name);
let aliases: Vec<Alias> = variants.iter().map(Alias::new).collect();
col.enumeration(Alias::new(&type_name), aliases);
}
},
_ => unreachable!("ComplexColumnType is #[non_exhaustive]; all variants are matched above"),
}
}
fn apply_numeric_type(
col: &mut SeaColumnDef,
precision: u32,
scale: u32,
backend: DatabaseBackend,
) {
debug_assert!(
scale <= precision,
"numeric scale ({scale}) must be <= precision ({precision}); schema validation should reject this before SQL generation"
);
let safe_precision = precision.min(28);
let safe_scale = scale.min(safe_precision);
match backend {
DatabaseBackend::Postgres | DatabaseBackend::MySql => {
col.decimal_len(safe_precision, safe_scale);
}
DatabaseBackend::Sqlite => {
col.double();
}
}
}
pub(crate) fn to_sea_fk_action(action: &ReferenceAction) -> ForeignKeyAction {
match action {
ReferenceAction::Cascade => ForeignKeyAction::Cascade,
ReferenceAction::Restrict => ForeignKeyAction::Restrict,
ReferenceAction::SetNull => ForeignKeyAction::SetNull,
ReferenceAction::SetDefault => ForeignKeyAction::SetDefault,
ReferenceAction::NoAction => ForeignKeyAction::NoAction,
_ => unreachable!("ReferenceAction is #[non_exhaustive]; all variants are matched above"),
}
}
pub(super) const UUID_FUNCTION_SPELLINGS: [&str; 3] =
["gen_random_uuid()", "uuid()", "lower(hex(randomblob(16)))"];
pub(super) const TIMESTAMP_FUNCTION_SPELLINGS: [&str; 4] = [
"current_timestamp()",
"now()",
"current_timestamp",
"getdate()",
];
pub(super) fn matches_any_spelling(value: &str, spellings: &[&str]) -> bool {
spellings.iter().any(|s| value.eq_ignore_ascii_case(s))
}
pub(crate) fn convert_default_for_backend(default: &str, backend: DatabaseBackend) -> String {
if matches_any_spelling(default, &UUID_FUNCTION_SPELLINGS) {
return match backend {
DatabaseBackend::Postgres => "gen_random_uuid()".to_string(),
DatabaseBackend::MySql => "(UUID())".to_string(),
DatabaseBackend::Sqlite => "lower(hex(randomblob(16)))".to_string(),
};
}
if matches_any_spelling(default, &TIMESTAMP_FUNCTION_SPELLINGS) {
return "CURRENT_TIMESTAMP".to_string();
}
if let Some((value, cast_type)) = parse_pg_type_cast(default) {
return convert_cast_chain(value, &cast_type, backend);
}
default.to_string()
}
pub(super) fn find_last_top_level_cast(expr: &str) -> Option<usize> {
let mut in_quote = false;
let mut depth: usize = 0;
let mut pending_colon: Option<usize> = None;
let mut last = None;
for (index, byte) in expr.bytes().enumerate() {
if in_quote {
if byte == b'\'' {
in_quote = false;
}
pending_colon = None;
continue;
}
match byte {
b'\'' => {
in_quote = true;
pending_colon = None;
}
b'(' => {
depth += 1;
pending_colon = None;
}
b')' => {
depth = depth.saturating_sub(1);
pending_colon = None;
}
b':' => match pending_colon.take() {
Some(start) if depth == 0 => last = Some(start),
Some(_) => {}
None => pending_colon = Some(index),
},
_ => pending_colon = None,
}
}
if in_quote { None } else { last }
}
pub(super) fn parse_pg_type_cast(expr: &str) -> Option<(&str, String)> {
let trimmed = expr.trim();
let split = find_last_top_level_cast(trimmed)?;
let value = trimmed[..split].trim();
let cast_type = trimmed[split + 2..].trim().to_lowercase();
if value.is_empty() || cast_type.is_empty() {
return None;
}
Some((value, cast_type))
}
fn convert_cast_chain(value: &str, cast_type: &str, backend: DatabaseBackend) -> String {
let inner = match parse_pg_type_cast(value) {
Some((inner_value, inner_cast)) => convert_cast_chain(inner_value, &inner_cast, backend),
None => value.to_string(),
};
convert_type_cast(&inner, cast_type, backend)
}
fn pg_type_to_mysql_cast(pg_type: &str) -> &'static str {
match pg_type {
"json" | "jsonb" => "JSON",
"integer" | "int" | "int4" | "smallint" | "int2" | "bigint" | "int8" => "SIGNED",
"real" | "float4" | "double precision" | "float8" | "numeric" | "decimal" => "DECIMAL",
"boolean" | "bool" => "UNSIGNED",
"date" => "DATE",
"time" => "TIME",
"timestamp"
| "timestamptz"
| "timestamp with time zone"
| "timestamp without time zone" => "DATETIME",
"bytea" => "BINARY",
_ => "CHAR",
}
}
fn convert_type_cast(value: &str, cast_type: &str, backend: DatabaseBackend) -> String {
match backend {
DatabaseBackend::Postgres => format!("{value}::{cast_type}"),
DatabaseBackend::MySql => {
let mysql_type = pg_type_to_mysql_cast(cast_type);
format!("CAST({value} AS {mysql_type})")
}
DatabaseBackend::Sqlite => value.to_string(),
}
}
pub(super) fn is_enum_type(column_type: &ColumnType) -> bool {
matches!(
column_type,
ColumnType::Complex(ComplexColumnType::Enum { .. })
)
}
pub(crate) fn normalize_enum_default(column_type: &ColumnType, value: &str) -> String {
if is_enum_type(column_type) && needs_quoting(value) {
format!("'{value}'")
} else {
value.to_string()
}
}
pub(super) fn needs_quoting(default_str: &str) -> bool {
let trimmed = default_str.trim();
if trimmed.is_empty() {
return true;
}
if trimmed.starts_with('\'') || trimmed.starts_with('"') {
return false;
}
if trimmed.contains('(') || trimmed.contains(')') {
return false;
}
if trimmed.eq_ignore_ascii_case("null") {
return false;
}
if trimmed.eq_ignore_ascii_case("current_timestamp")
|| trimmed.eq_ignore_ascii_case("current_date")
|| trimmed.eq_ignore_ascii_case("current_time")
{
return false;
}
true
}
pub(crate) fn build_sea_column_def_with_table(
backend: DatabaseBackend,
table: &str,
column: &ColumnDef,
) -> SeaColumnDef {
let mut col = SeaColumnDef::new(Alias::new(&column.name));
apply_column_type_with_table(&mut col, &column.r#type, table, backend);
if !column.nullable {
col.not_null();
}
if let Some(default) = &column.default {
let default_str = default.to_sql();
let converted = convert_default_for_backend(&default_str, backend);
let final_default =
if is_enum_type(&column.r#type) && default.is_string() && needs_quoting(&converted) {
format!("'{converted}'")
} else {
converted
};
let final_default = if backend == DatabaseBackend::Sqlite
&& final_default.contains('(')
&& !final_default.starts_with('(')
{
format!("({final_default})")
} else {
final_default
};
col.default(Into::<SimpleExpr>::into(sea_query::Expr::cust(
final_default,
)));
}
col
}
pub(crate) fn build_create_enum_type_sql(
table: &str,
column_type: &ColumnType,
) -> Option<super::types::RawSql> {
if let ColumnType::Complex(ComplexColumnType::Enum { name, values }) = column_type {
if values.is_integer() {
return None;
}
let values_sql = values.sql_values_joined(", ");
let type_name = build_enum_type_name(table, name);
let type_name = quote_ident(&type_name, DatabaseBackend::Postgres);
let pg_sql = format!("CREATE TYPE {type_name} AS ENUM ({values_sql})");
Some(super::types::RawSql::postgres_only(pg_sql))
} else {
None
}
}
pub(crate) fn build_drop_enum_type_sql(
table: &str,
column_type: &ColumnType,
) -> Option<super::types::RawSql> {
if let ColumnType::Complex(ComplexColumnType::Enum { name, .. }) = column_type {
let type_name = build_enum_type_name(table, name);
let type_name = quote_ident(&type_name, DatabaseBackend::Postgres);
let pg_sql = format!("DROP TYPE {type_name}");
Some(super::types::RawSql::postgres_only(pg_sql))
} else {
None
}
}
pub(crate) use vespertide_naming::{
build_check_constraint_name, build_enum_type_name, build_foreign_key_name, build_index_name,
build_unique_constraint_name,
};
pub(crate) fn build_sqlite_enum_check_clause(
table: &str,
column: &str,
column_type: &ColumnType,
) -> Option<String> {
if let ColumnType::Complex(ComplexColumnType::Enum { values, .. }) = column_type {
let name = build_check_constraint_name(table, column);
let values_sql = values.sql_values_joined(", ");
let name = quote_ident(&name, DatabaseBackend::Sqlite);
let column = quote_ident(column, DatabaseBackend::Sqlite);
Some(format!(
"CONSTRAINT {name} CHECK ({column} IN ({values_sql}))"
))
} else {
None
}
}
pub(crate) fn collect_sqlite_enum_check_clauses(table: &str, columns: &[ColumnDef]) -> Vec<String> {
columns
.iter()
.filter_map(|col| build_sqlite_enum_check_clause(table, &col.name, &col.r#type))
.collect()
}
pub(crate) fn extract_check_clauses(constraints: &[TableConstraint]) -> Vec<String> {
constraints
.iter()
.filter_map(|c| {
if let TableConstraint::Check { name, expr, .. } = c {
let name = quote_ident(name, DatabaseBackend::Sqlite);
Some(format!("CONSTRAINT {name} CHECK ({expr})"))
} else {
None
}
})
.collect()
}
pub(crate) fn collect_all_check_clauses(
table: &str,
columns: &[ColumnDef],
constraints: &[TableConstraint],
) -> Vec<String> {
let mut clauses = collect_sqlite_enum_check_clauses(table, columns);
let explicit = extract_check_clauses(constraints);
let mut retained: Vec<String> = Vec::with_capacity(explicit.len());
{
let mut seen: std::collections::BTreeSet<&str> =
clauses.iter().map(String::as_str).collect();
for clause in &explicit {
if seen.insert(clause.as_str()) {
retained.push(clause.clone());
}
}
}
clauses.extend(retained);
clauses
}
pub(crate) fn build_create_with_checks(
backend: DatabaseBackend,
create_stmt: &sea_query::TableCreateStatement,
check_clauses: &[String],
) -> BuiltQuery {
if check_clauses.is_empty() {
BuiltQuery::CreateTable(Box::new(create_stmt.clone()))
} else {
let base_sql = build_schema_statement(create_stmt, backend);
let mut modified_sql = base_sql;
if let Some(pos) = modified_sql.rfind(')') {
let check_sql = check_clauses.join(", ");
modified_sql.insert_str(pos, &check_sql);
modified_sql.insert_str(pos, ", ");
}
BuiltQuery::Raw(RawSql::uniform(modified_sql))
}
}
pub(crate) fn build_sqlite_temp_table_create(
backend: DatabaseBackend,
temp_table: &str,
table: &str,
columns: &[ColumnDef],
constraints: &[TableConstraint],
) -> BuiltQuery {
let create_stmt = build_create_table_for_backend(backend, temp_table, columns, constraints);
let check_clauses = collect_all_check_clauses(table, columns, constraints);
build_create_with_checks(backend, &create_stmt, &check_clauses)
}
pub(super) fn build_copy_into_temp_table(
table: &str,
temp_table: &str,
columns: &[ColumnDef],
) -> BuiltQuery {
let column_aliases: Vec<Alias> = columns
.iter()
.map(|column| Alias::new(&column.name))
.collect();
let mut select_query = Query::select();
for column_alias in &column_aliases {
select_query.column(column_alias.clone());
}
select_query.from(Alias::new(table));
let insert_stmt = Query::insert()
.into_table(Alias::new(temp_table))
.columns(column_aliases)
.select_from(select_query)
.expect("SQLite temp table copy SELECT should be valid")
.to_owned();
BuiltQuery::Insert(Box::new(insert_stmt))
}
pub(crate) fn recreate_indexes_after_rebuild(
table: &str,
constraints: &[TableConstraint],
pending_constraints: &[TableConstraint],
) -> Vec<BuiltQuery> {
let mut queries = Vec::with_capacity(constraints.len());
let pending_constraints: std::collections::BTreeSet<_> = pending_constraints.iter().collect();
let quoted_table = quote_ident(table, DatabaseBackend::Sqlite);
let mut push_index =
|index_name: String, columns: &[vespertide_core::ColumnName], unique: bool| {
let cols_sql = quote_idents(columns, DatabaseBackend::Sqlite);
let quoted_index = quote_ident(&index_name, DatabaseBackend::Sqlite);
let keyword = if unique {
"CREATE UNIQUE INDEX"
} else {
"CREATE INDEX"
};
let sql = format!("{keyword} {quoted_index} ON {quoted_table} ({cols_sql})");
queries.push(BuiltQuery::Raw(RawSql::uniform(sql)));
};
for constraint in constraints {
if pending_constraints.contains(constraint) {
continue;
}
match constraint {
TableConstraint::Index { name, columns } => {
push_index(
build_index_name(table, columns, name.as_deref()),
columns,
false,
);
}
TableConstraint::Unique { name, columns, .. } => {
push_index(
build_unique_constraint_name(table, columns, name.as_deref()),
columns,
true,
);
}
_ => {}
}
}
queries
}
pub(super) fn build_sqlite_table_rebuild(
backend: DatabaseBackend,
table: &str,
create_columns: &[ColumnDef],
create_constraints: &[TableConstraint],
copy_columns: &[ColumnDef],
recreate_constraints: &[TableConstraint],
pending_constraints: &[TableConstraint],
) -> Vec<BuiltQuery> {
let temp_table = format!("{table}_temp");
let create_query = build_sqlite_temp_table_create(
backend,
&temp_table,
table,
create_columns,
create_constraints,
);
let insert_query = build_copy_into_temp_table(table, &temp_table, copy_columns);
let drop_query = super::delete_table::build_delete_table(table);
let rename_query = super::rename_table::build_rename_table(&temp_table, table);
let index_queries =
recreate_indexes_after_rebuild(table, recreate_constraints, pending_constraints);
let mut queries = Vec::with_capacity(4 + index_queries.len());
queries.push(create_query);
queries.push(insert_query);
queries.push(drop_query);
queries.push(rename_query);
queries.extend(index_queries);
queries
}
#[must_use]
pub fn quote_ident(name: &str, backend: DatabaseBackend) -> String {
let mut out = String::with_capacity(name.len() + 2);
quote_ident_into(&mut out, name, backend);
out
}
fn quote_ident_into(out: &mut String, name: &str, backend: DatabaseBackend) {
let delim = match backend {
DatabaseBackend::Postgres | DatabaseBackend::Sqlite => '"',
DatabaseBackend::MySql => '`',
};
if !name.contains(delim) {
out.reserve(name.len() + 2);
out.push(delim);
out.push_str(name);
out.push(delim);
return;
}
let escaped = name.replace(delim, &format!("{delim}{delim}"));
out.reserve(escaped.len() + 2);
out.push(delim);
out.push_str(&escaped);
out.push(delim);
}
#[must_use]
pub fn quote_idents<T: AsRef<str>>(names: &[T], backend: DatabaseBackend) -> String {
let mut out = String::new();
for (i, n) in names.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
quote_ident_into(&mut out, n.as_ref(), backend);
}
out
}
#[must_use]
pub(super) fn build_pg_alter_column_sql(table: &str, column: &str, suffix: &str) -> String {
let quoted_table = quote_ident(table, DatabaseBackend::Postgres);
let quoted_column = quote_ident(column, DatabaseBackend::Postgres);
format!("ALTER TABLE {quoted_table} ALTER COLUMN {quoted_column} {suffix}")
}
pub(crate) fn require_table_in_schema<'a>(
schema: &'a [TableDef],
table: &str,
context: &str,
) -> Result<&'a TableDef, crate::QueryError> {
schema.iter().find(|t| t.name == table).ok_or_else(|| {
crate::QueryError::SchemaError(format!(
"Table '{table}' not found in current schema. {context}."
))
})
}
#[must_use]
pub(crate) fn build_drop_index_query(table: &str, index_name: &str) -> BuiltQuery {
let idx_drop = sea_query::Index::drop()
.table(Alias::new(table))
.name(index_name)
.to_owned();
BuiltQuery::DropIndex(Box::new(idx_drop))
}
pub(crate) fn require_column_in_table<'a>(
table_def: &'a TableDef,
column: &str,
) -> Result<&'a ColumnDef, crate::QueryError> {
table_def
.columns
.iter()
.find(|c| c.name == column)
.ok_or_else(|| {
crate::QueryError::SchemaError(format!(
"Column '{column}' not found in table '{table}'.",
table = table_def.name,
))
})
}
pub(crate) fn build_mysql_modify_column_with<F>(
table: &str,
column: &str,
current_schema: &[TableDef],
context: &str,
mutator: F,
) -> Result<BuiltQuery, crate::QueryError>
where
F: FnOnce(&mut ColumnDef),
{
let table_def = require_table_in_schema(current_schema, table, context)?;
let column_def = require_column_in_table(table_def, column)?;
let mut modified_col_def = column_def.clone();
mutator(&mut modified_col_def);
let sea_col = build_sea_column_def_with_table(DatabaseBackend::MySql, table, &modified_col_def);
let stmt = Table::alter()
.table(Alias::new(table))
.modify_column(sea_col)
.to_owned();
Ok(BuiltQuery::AlterTable(Box::new(stmt)))
}
pub(crate) fn build_sqlite_modify_column_with<F>(
table: &str,
column: &str,
current_schema: &[TableDef],
pending_constraints: &[TableConstraint],
context: &str,
mutator: F,
) -> Result<Vec<BuiltQuery>, crate::QueryError>
where
F: FnOnce(&mut ColumnDef),
{
let table_def = require_table_in_schema(current_schema, table, context)?;
let mut new_columns = table_def.columns.clone();
if let Some(col) = new_columns.iter_mut().find(|c| c.name == column) {
mutator(col);
}
Ok(build_sqlite_table_rebuild(
DatabaseBackend::Sqlite,
table,
&new_columns,
&table_def.constraints,
&table_def.columns,
&table_def.constraints,
pending_constraints,
))
}
#[must_use]
pub(crate) fn find_column_in_schema<'a>(
schema: &'a [TableDef],
table: &str,
column: &str,
) -> Option<&'a ColumnDef> {
schema
.iter()
.find(|t| t.name == table)
.and_then(|t| t.columns.iter().find(|c| c.name == column))
}
#[cfg(test)]
mod tests {
use super::*;
use sea_query::{Alias, ColumnDef as SeaColDef, Table};
#[test]
fn build_create_with_checks_empty_clauses_returns_plain_create_table() {
let mut stmt = Table::create();
stmt.table(Alias::new("users"))
.col(SeaColDef::new(Alias::new("id")).integer().not_null());
let query = build_create_with_checks(DatabaseBackend::Postgres, &stmt, &[]);
let sql = query.build(DatabaseBackend::Postgres);
assert!(
sql.contains("CREATE TABLE"),
"expected CREATE TABLE in: {sql}"
);
assert!(
!sql.contains("CHECK ("),
"no CHECK should be injected: {sql}"
);
assert!(
matches!(query, BuiltQuery::CreateTable(_)),
"empty-checks branch must return BuiltQuery::CreateTable"
);
}
#[test]
fn require_table_in_schema_hit_returns_table_ref() {
let schema = vec![TableDef {
name: "users".into(),
description: None,
columns: vec![],
constraints: vec![],
}];
let table_def = require_table_in_schema(&schema, "users", "test context")
.expect("table 'users' should be found in the schema");
assert_eq!(table_def.name.as_str(), "users");
}
#[test]
fn require_table_in_schema_miss_emits_canonical_message() {
let schema: Vec<TableDef> = vec![];
let err = require_table_in_schema(&schema, "missing", "SQLite requires test context")
.expect_err("missing table must error");
let msg = err.to_string();
assert!(
msg.contains(
"Table 'missing' not found in current schema. SQLite requires test context."
),
"expected canonical message with trailing period, got: {msg}"
);
}
}