use super::policy::{PolicyPermissiveness, PolicyTarget, RlsPolicy};
use super::types::ColumnType;
use std::collections::HashMap;
#[derive(Debug, Clone, Default)]
pub struct Schema {
pub tables: HashMap<String, Table>,
pub indexes: Vec<Index>,
pub migrations: Vec<MigrationHint>,
pub extensions: Vec<Extension>,
pub comments: Vec<Comment>,
pub sequences: Vec<Sequence>,
pub enums: Vec<EnumType>,
pub views: Vec<ViewDef>,
pub functions: Vec<SchemaFunctionDef>,
pub triggers: Vec<SchemaTriggerDef>,
pub grants: Vec<Grant>,
pub policies: Vec<RlsPolicy>,
pub resources: Vec<ResourceDef>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ResourceKind {
Bucket,
Queue,
Topic,
}
impl std::fmt::Display for ResourceKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Bucket => write!(f, "bucket"),
Self::Queue => write!(f, "queue"),
Self::Topic => write!(f, "topic"),
}
}
}
#[derive(Debug, Clone)]
pub struct ResourceDef {
pub name: String,
pub kind: ResourceKind,
pub provider: Option<String>,
pub properties: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct Table {
pub name: String,
pub columns: Vec<Column>,
pub multi_column_fks: Vec<MultiColumnForeignKey>,
pub enable_rls: bool,
pub force_rls: bool,
}
#[derive(Debug, Clone)]
pub struct Column {
pub name: String,
pub data_type: ColumnType,
pub nullable: bool,
pub primary_key: bool,
pub unique: bool,
pub default: Option<String>,
pub foreign_key: Option<ForeignKey>,
pub check: Option<CheckConstraint>,
pub generated: Option<Generated>,
}
#[derive(Debug, Clone)]
pub struct ForeignKey {
pub table: String,
pub column: String,
pub on_delete: FkAction,
pub on_update: FkAction,
pub deferrable: Deferrable,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub enum FkAction {
#[default]
NoAction,
Cascade,
SetNull,
SetDefault,
Restrict,
}
#[derive(Debug, Clone)]
pub struct Index {
pub name: String,
pub table: String,
pub columns: Vec<String>,
pub unique: bool,
pub method: IndexMethod,
pub where_clause: Option<CheckExpr>,
pub include: Vec<String>,
pub concurrently: bool,
pub expressions: Vec<String>,
}
#[derive(Debug, Clone)]
pub enum MigrationHint {
Rename {
from: String,
to: String,
},
Transform {
expression: String,
target: String,
},
Drop {
target: String,
confirmed: bool,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckComparisonOp {
Equal,
NotEqual,
GreaterThan,
GreaterOrEqual,
LessThan,
LessOrEqual,
}
impl CheckComparisonOp {
pub fn as_sql_str(self) -> &'static str {
match self {
CheckComparisonOp::Equal => "=",
CheckComparisonOp::NotEqual => "<>",
CheckComparisonOp::GreaterThan => ">",
CheckComparisonOp::GreaterOrEqual => ">=",
CheckComparisonOp::LessThan => "<",
CheckComparisonOp::LessOrEqual => "<=",
}
}
}
#[derive(Debug, Clone)]
pub enum CheckExpr {
GreaterThan {
column: String,
value: i64,
},
GreaterOrEqual {
column: String,
value: i64,
},
LessThan {
column: String,
value: i64,
},
LessOrEqual {
column: String,
value: i64,
},
Between {
column: String,
low: i64,
high: i64,
},
In {
column: String,
values: Vec<String>,
},
InIntegers {
column: String,
values: Vec<i64>,
},
CompareColumns {
left_column: String,
op: CheckComparisonOp,
right_column: String,
},
TextCompare {
column: String,
op: CheckComparisonOp,
value: String,
},
CompareColumnToCoalesce {
left_column: String,
op: CheckComparisonOp,
coalesce_column: String,
fallback: String,
fallback_cast: Option<String>,
},
LowerTrimEquals {
column: String,
},
Regex {
column: String,
pattern: String,
},
MaxLength {
column: String,
max: usize,
},
MinLength {
column: String,
min: usize,
},
NotNull {
column: String,
},
And(Box<CheckExpr>, Box<CheckExpr>),
Or(Box<CheckExpr>, Box<CheckExpr>),
Not(Box<CheckExpr>),
Sql(String),
}
#[derive(Debug, Clone)]
pub struct CheckConstraint {
pub expr: CheckExpr,
pub name: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub enum Deferrable {
#[default]
NotDeferrable,
Deferrable,
InitiallyDeferred,
InitiallyImmediate,
}
#[derive(Debug, Clone)]
pub enum Generated {
AlwaysStored(String),
AlwaysIdentity,
ByDefaultIdentity,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub enum IndexMethod {
#[default]
BTree,
Hash,
Gin,
Gist,
Brin,
SpGist,
Hnsw,
IvfFlat,
}
pub(crate) fn index_method_str(method: &IndexMethod) -> &'static str {
match method {
IndexMethod::BTree => "btree",
IndexMethod::Hash => "hash",
IndexMethod::Gin => "gin",
IndexMethod::Gist => "gist",
IndexMethod::Brin => "brin",
IndexMethod::SpGist => "spgist",
IndexMethod::Hnsw => "hnsw",
IndexMethod::IvfFlat => "ivfflat",
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Extension {
pub name: String,
pub schema: Option<String>,
pub version: Option<String>,
}
impl Extension {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
schema: None,
version: None,
}
}
pub fn schema(mut self, schema: impl Into<String>) -> Self {
self.schema = Some(schema.into());
self
}
pub fn version(mut self, version: impl Into<String>) -> Self {
self.version = Some(version.into());
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Comment {
pub target: CommentTarget,
pub text: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum CommentTarget {
Table(String),
Column {
table: String,
column: String,
},
Raw(String),
}
impl Comment {
pub fn on_table(table: impl Into<String>, text: impl Into<String>) -> Self {
Self {
target: CommentTarget::Table(table.into()),
text: text.into(),
}
}
pub fn on_column(
table: impl Into<String>,
column: impl Into<String>,
text: impl Into<String>,
) -> Self {
Self {
target: CommentTarget::Column {
table: table.into(),
column: column.into(),
},
text: text.into(),
}
}
pub fn on_raw(target: impl Into<String>, text: impl Into<String>) -> Self {
Self {
target: CommentTarget::Raw(target.into()),
text: text.into(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Sequence {
pub name: String,
pub data_type: Option<String>,
pub start: Option<i64>,
pub increment: Option<i64>,
pub min_value: Option<i64>,
pub max_value: Option<i64>,
pub cache: Option<i64>,
pub cycle: bool,
pub owned_by: Option<String>,
}
impl Sequence {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
data_type: None,
start: None,
increment: None,
min_value: None,
max_value: None,
cache: None,
cycle: false,
owned_by: None,
}
}
pub fn start(mut self, v: i64) -> Self {
self.start = Some(v);
self
}
pub fn increment(mut self, v: i64) -> Self {
self.increment = Some(v);
self
}
pub fn min_value(mut self, v: i64) -> Self {
self.min_value = Some(v);
self
}
pub fn max_value(mut self, v: i64) -> Self {
self.max_value = Some(v);
self
}
pub fn cache(mut self, v: i64) -> Self {
self.cache = Some(v);
self
}
pub fn cycle(mut self) -> Self {
self.cycle = true;
self
}
pub fn owned_by(mut self, col: impl Into<String>) -> Self {
self.owned_by = Some(col.into());
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct EnumType {
pub name: String,
pub values: Vec<String>,
}
impl EnumType {
pub fn new(name: impl Into<String>, values: Vec<String>) -> Self {
Self {
name: name.into(),
values,
}
}
pub fn add_value(mut self, value: impl Into<String>) -> Self {
self.values.push(value.into());
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct MultiColumnForeignKey {
pub columns: Vec<String>,
pub ref_table: String,
pub ref_columns: Vec<String>,
pub on_delete: FkAction,
pub on_update: FkAction,
pub deferrable: Deferrable,
pub name: Option<String>,
}
impl MultiColumnForeignKey {
pub fn new(
columns: Vec<String>,
ref_table: impl Into<String>,
ref_columns: Vec<String>,
) -> Self {
Self {
columns,
ref_table: ref_table.into(),
ref_columns,
on_delete: FkAction::default(),
on_update: FkAction::default(),
deferrable: Deferrable::default(),
name: None,
}
}
pub fn on_delete(mut self, action: FkAction) -> Self {
self.on_delete = action;
self
}
pub fn on_update(mut self, action: FkAction) -> Self {
self.on_update = action;
self
}
pub fn named(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn deferrable(mut self) -> Self {
self.deferrable = Deferrable::Deferrable;
self
}
pub fn initially_deferred(mut self) -> Self {
self.deferrable = Deferrable::InitiallyDeferred;
self
}
pub fn initially_immediate(mut self) -> Self {
self.deferrable = Deferrable::InitiallyImmediate;
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ViewDef {
pub name: String,
pub query: String,
pub materialized: bool,
}
impl ViewDef {
pub fn new(name: impl Into<String>, query: impl Into<String>) -> Self {
Self {
name: name.into(),
query: query.into(),
materialized: false,
}
}
pub fn materialized(mut self) -> Self {
self.materialized = true;
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SchemaFunctionDef {
pub name: String,
pub args: Vec<String>,
pub returns: String,
pub body: String,
pub language: String,
pub volatility: Option<String>,
}
impl SchemaFunctionDef {
pub fn new(
name: impl Into<String>,
returns: impl Into<String>,
body: impl Into<String>,
) -> Self {
Self {
name: name.into(),
args: Vec::new(),
returns: returns.into(),
body: body.into(),
language: "plpgsql".to_string(),
volatility: None,
}
}
pub fn language(mut self, lang: impl Into<String>) -> Self {
self.language = lang.into();
self
}
pub fn arg(mut self, arg: impl Into<String>) -> Self {
self.args.push(arg.into());
self
}
pub fn volatility(mut self, v: impl Into<String>) -> Self {
self.volatility = Some(v.into());
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SchemaTriggerDef {
pub name: String,
pub table: String,
pub timing: String,
pub events: Vec<String>,
pub update_columns: Vec<String>,
pub for_each_row: bool,
pub execute_function: String,
pub condition: Option<String>,
}
impl SchemaTriggerDef {
pub fn new(
name: impl Into<String>,
table: impl Into<String>,
execute_function: impl Into<String>,
) -> Self {
Self {
name: name.into(),
table: table.into(),
timing: "BEFORE".to_string(),
events: vec!["INSERT".to_string()],
update_columns: Vec::new(),
for_each_row: true,
execute_function: execute_function.into(),
condition: None,
}
}
pub fn timing(mut self, t: impl Into<String>) -> Self {
self.timing = t.into();
self
}
pub fn events(mut self, evts: Vec<String>) -> Self {
self.events = evts;
self
}
pub fn for_each_statement(mut self) -> Self {
self.for_each_row = false;
self
}
pub fn condition(mut self, cond: impl Into<String>) -> Self {
self.condition = Some(cond.into());
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Grant {
pub action: GrantAction,
pub privileges: Vec<Privilege>,
pub on_object: String,
pub to_role: String,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub enum GrantAction {
#[default]
Grant,
Revoke,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Privilege {
All,
Select,
Insert,
Update,
Delete,
Usage,
Execute,
}
impl std::fmt::Display for Privilege {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Privilege::All => write!(f, "ALL"),
Privilege::Select => write!(f, "SELECT"),
Privilege::Insert => write!(f, "INSERT"),
Privilege::Update => write!(f, "UPDATE"),
Privilege::Delete => write!(f, "DELETE"),
Privilege::Usage => write!(f, "USAGE"),
Privilege::Execute => write!(f, "EXECUTE"),
}
}
}
impl Grant {
pub fn new(
privileges: Vec<Privilege>,
on_object: impl Into<String>,
to_role: impl Into<String>,
) -> Self {
Self {
action: GrantAction::Grant,
privileges,
on_object: on_object.into(),
to_role: to_role.into(),
}
}
pub fn revoke(
privileges: Vec<Privilege>,
on_object: impl Into<String>,
from_role: impl Into<String>,
) -> Self {
Self {
action: GrantAction::Revoke,
privileges,
on_object: on_object.into(),
to_role: from_role.into(),
}
}
}
impl Schema {
pub fn new() -> Self {
Self::default()
}
pub fn add_table(&mut self, table: Table) {
self.tables.insert(table.name.clone(), table);
}
pub fn add_index(&mut self, index: Index) {
self.indexes.push(index);
}
pub fn add_hint(&mut self, hint: MigrationHint) {
self.migrations.push(hint);
}
pub fn add_extension(&mut self, ext: Extension) {
self.extensions.push(ext);
}
pub fn add_comment(&mut self, comment: Comment) {
self.comments.push(comment);
}
pub fn add_sequence(&mut self, seq: Sequence) {
self.sequences.push(seq);
}
pub fn add_enum(&mut self, enum_type: EnumType) {
self.enums.push(enum_type);
}
pub fn add_view(&mut self, view: ViewDef) {
self.views.push(view);
}
pub fn add_function(&mut self, func: SchemaFunctionDef) {
self.functions.push(func);
}
pub fn add_trigger(&mut self, trigger: SchemaTriggerDef) {
self.triggers.push(trigger);
}
pub fn add_grant(&mut self, grant: Grant) {
self.grants.push(grant);
}
pub fn add_resource(&mut self, resource: ResourceDef) {
self.resources.push(resource);
}
pub fn add_policy(&mut self, policy: RlsPolicy) {
self.policies.push(policy);
}
pub fn validate(&self) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
for table in self.tables.values() {
let mut seen_columns = std::collections::BTreeSet::new();
for col in &table.columns {
if !seen_columns.insert(col.name.as_str()) {
errors.push(format!(
"Schema error: table '{}' has duplicate column '{}'",
table.name, col.name
));
}
}
let table_columns = table
.columns
.iter()
.map(|column| column.name.as_str())
.collect::<std::collections::BTreeSet<_>>();
let mut seen_constraint_names = std::collections::BTreeSet::new();
for col in &table.columns {
if col.primary_key && !col.data_type.can_be_primary_key() {
errors.push(format!(
"Schema error: {}.{} of type {} cannot be a primary key",
table.name,
col.name,
col.data_type.name()
));
}
if col.unique && !col.data_type.supports_indexing() {
errors.push(format!(
"Schema error: {}.{} of type {} cannot have UNIQUE constraint",
table.name,
col.name,
col.data_type.name()
));
}
if let Some(check) = &col.check
&& let Some(name) = &check.name
{
if name.trim().is_empty() {
errors.push(format!(
"Constraint error: {}.{} has empty CHECK constraint name",
table.name, col.name
));
} else if !seen_constraint_names.insert(name.as_str()) {
errors.push(format!(
"Constraint error: table '{}' has duplicate constraint name '{}'",
table.name, name
));
}
}
if let Some(ref fk) = col.foreign_key {
if !self.tables.contains_key(&fk.table) {
errors.push(format!(
"FK error: {}.{} references non-existent table '{}'",
table.name, col.name, fk.table
));
} else {
let ref_table = &self.tables[&fk.table];
if !ref_table.columns.iter().any(|c| c.name == fk.column) {
errors.push(format!(
"FK error: {}.{} references non-existent column '{}.{}'",
table.name, col.name, fk.table, fk.column
));
} else if !schema_has_unique_key(
self,
&fk.table,
std::slice::from_ref(&fk.column),
) {
errors.push(format!(
"FK error: {}.{} references '{}.{}' without a UNIQUE or PRIMARY KEY constraint",
table.name, col.name, fk.table, fk.column
));
}
}
}
if let Some(check) = &col.check {
for referenced in check_expr_column_references(&check.expr) {
let referenced_column = check_expr_reference_name(referenced);
if !table_columns.contains(referenced_column.as_str()) {
errors.push(format!(
"CHECK error: {}.{} references non-existent column '{}.{}'",
table.name, col.name, table.name, referenced_column
));
}
}
}
}
for fk in &table.multi_column_fks {
if let Some(name) = &fk.name {
if name.trim().is_empty() {
errors.push(format!(
"Multi-column FK error: {} has empty constraint name",
table.name
));
} else if !seen_constraint_names.insert(name.as_str()) {
errors.push(format!(
"Constraint error: table '{}' has duplicate constraint name '{}'",
table.name, name
));
}
}
if fk.columns.is_empty() {
errors.push(format!(
"Multi-column FK error: {} has no source columns",
table.name
));
}
if fk.ref_columns.is_empty() {
errors.push(format!(
"Multi-column FK error: {} references '{}' with no target columns",
table.name, fk.ref_table
));
}
if fk.columns.len() != fk.ref_columns.len() {
errors.push(format!(
"Multi-column FK error: {} column count {} does not match referenced column count {}",
table.name,
fk.columns.len(),
fk.ref_columns.len()
));
}
for source_col in &fk.columns {
if !table.columns.iter().any(|c| c.name == *source_col) {
errors.push(format!(
"Multi-column FK error: {} references non-existent source column '{}.{}'",
table.name, table.name, source_col
));
}
}
let Some(ref_table) = self.tables.get(&fk.ref_table) else {
errors.push(format!(
"Multi-column FK error: {} references non-existent table '{}'",
table.name, fk.ref_table
));
continue;
};
let mut all_ref_columns_exist = true;
for ref_col in &fk.ref_columns {
if !ref_table.columns.iter().any(|c| c.name == *ref_col) {
all_ref_columns_exist = false;
errors.push(format!(
"Multi-column FK error: {} references non-existent column '{}.{}'",
table.name, fk.ref_table, ref_col
));
}
}
if all_ref_columns_exist
&& !fk.ref_columns.is_empty()
&& fk.columns.len() == fk.ref_columns.len()
&& !schema_has_unique_key(self, &fk.ref_table, &fk.ref_columns)
{
errors.push(format!(
"Multi-column FK error: {} references '{}({})' without a matching UNIQUE or PRIMARY KEY constraint",
table.name,
fk.ref_table,
fk.ref_columns.join(", ")
));
}
}
}
let mut seen_index_names = std::collections::BTreeSet::new();
for index in &self.indexes {
if !seen_index_names.insert(index.name.as_str()) {
errors.push(format!(
"Index error: duplicate index name '{}'",
index.name
));
}
let Some(table) = self.tables.get(&index.table) else {
errors.push(format!(
"Index error: {} references non-existent table '{}'",
index.name, index.table
));
continue;
};
if index.columns.is_empty() && index.expressions.is_empty() {
errors.push(format!(
"Index error: {} must define at least one column or expression",
index.name
));
}
if !index.columns.is_empty() && !index.expressions.is_empty() {
errors.push(format!(
"Index error: {} cannot mix columns and expressions",
index.name
));
}
for column in &index.columns {
if column.trim().is_empty() {
errors.push(format!("Index error: {} has empty column", index.name));
continue;
}
let Some(column_name) = index_column_reference_name(column) else {
continue;
};
if !table.columns.iter().any(|c| c.name == column_name) {
errors.push(format!(
"Index error: {} references non-existent column '{}.{}'",
index.name, index.table, column_name
));
}
}
for expression in &index.expressions {
if expression.trim().is_empty() {
errors.push(format!("Index error: {} has empty expression", index.name));
}
}
for include_column in &index.include {
let Some(column_name) = index_column_reference_name(include_column) else {
errors.push(format!(
"Index error: {} has invalid INCLUDE column '{}'",
index.name, include_column
));
continue;
};
if !table.columns.iter().any(|c| c.name == column_name) {
errors.push(format!(
"Index error: {} references non-existent INCLUDE column '{}.{}'",
index.name, index.table, column_name
));
}
}
if let Some(where_clause) = &index.where_clause {
for referenced in check_expr_column_references(where_clause) {
let referenced_column = check_expr_reference_name(referenced);
if !table.columns.iter().any(|c| c.name == referenced_column) {
errors.push(format!(
"Index error: {} WHERE references non-existent column '{}.{}'",
index.name, index.table, referenced_column
));
}
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
fn check_expr_column_references(expr: &CheckExpr) -> Vec<&str> {
let mut refs = Vec::new();
collect_check_expr_column_references(expr, &mut refs);
refs.sort_unstable();
refs.dedup();
refs
}
fn collect_check_expr_column_references<'a>(expr: &'a CheckExpr, refs: &mut Vec<&'a str>) {
match expr {
CheckExpr::GreaterThan { column, .. }
| CheckExpr::GreaterOrEqual { column, .. }
| CheckExpr::LessThan { column, .. }
| CheckExpr::LessOrEqual { column, .. }
| CheckExpr::Between { column, .. }
| CheckExpr::In { column, .. }
| CheckExpr::InIntegers { column, .. }
| CheckExpr::TextCompare { column, .. }
| CheckExpr::LowerTrimEquals { column }
| CheckExpr::Regex { column, .. }
| CheckExpr::MaxLength { column, .. }
| CheckExpr::MinLength { column, .. }
| CheckExpr::NotNull { column } => refs.push(column),
CheckExpr::CompareColumns {
left_column,
right_column,
..
} => {
refs.push(left_column);
refs.push(right_column);
}
CheckExpr::CompareColumnToCoalesce {
left_column,
coalesce_column,
..
} => {
refs.push(left_column);
refs.push(coalesce_column);
}
CheckExpr::And(left, right) | CheckExpr::Or(left, right) => {
collect_check_expr_column_references(left, refs);
collect_check_expr_column_references(right, refs);
}
CheckExpr::Not(inner) => collect_check_expr_column_references(inner, refs),
CheckExpr::Sql(_) => {}
}
}
fn check_expr_reference_name(reference: &str) -> String {
let trimmed = reference.trim();
let unqualified = trimmed.rsplit('.').next().unwrap_or(trimmed);
unquote_identifier(unqualified)
}
fn schema_has_unique_key(schema: &Schema, table_name: &str, columns: &[String]) -> bool {
if columns.is_empty() {
return false;
}
let Some(table) = schema.tables.get(table_name) else {
return false;
};
if columns.len() == 1
&& table
.columns
.iter()
.any(|column| column.name == columns[0] && (column.primary_key || column.unique))
{
return true;
}
schema.indexes.iter().any(|index| {
index.table == table_name
&& index.unique
&& index.where_clause.is_none()
&& index.expressions.is_empty()
&& index.columns.len() == columns.len()
&& index
.columns
.iter()
.filter_map(|column| index_column_reference_name(column))
.eq(columns.iter().cloned())
})
}
fn index_column_reference_name(fragment: &str) -> Option<String> {
let fragment = fragment.trim();
if fragment.is_empty() || fragment.contains('(') || fragment.contains("->") {
return None;
}
let token = first_index_column_token(fragment)?;
let unqualified = token.rsplit('.').next().unwrap_or(token);
Some(unquote_identifier(unqualified))
}
fn first_index_column_token(fragment: &str) -> Option<&str> {
let fragment = fragment.trim_start();
if fragment.starts_with('"') {
let mut escaped = false;
for (idx, ch) in fragment.char_indices().skip(1) {
if escaped {
escaped = false;
continue;
}
if ch == '"' {
if fragment[idx + ch.len_utf8()..].starts_with('"') {
escaped = true;
continue;
}
return Some(&fragment[..=idx]);
}
}
return None;
}
let end = fragment
.find(|ch: char| ch.is_whitespace() || ch == '-' || ch == '>')
.unwrap_or(fragment.len());
(end > 0).then_some(&fragment[..end])
}
fn unquote_identifier(identifier: &str) -> String {
identifier
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.map(|s| s.replace("\"\"", "\""))
.unwrap_or_else(|| identifier.to_string())
}
impl Table {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
columns: Vec::new(),
multi_column_fks: Vec::new(),
enable_rls: false,
force_rls: false,
}
}
pub fn column(mut self, col: Column) -> Self {
self.columns.push(col);
self
}
pub fn foreign_key(mut self, fk: MultiColumnForeignKey) -> Self {
self.multi_column_fks.push(fk);
self
}
}
impl Column {
fn primary_key_type_error(&self) -> String {
format!(
"Column '{}' of type {} cannot be a primary key. \
Valid PK types: scalar/indexable types \
(UUID, TEXT, VARCHAR, INT, BIGINT, SERIAL, BIGSERIAL, BOOLEAN, FLOAT, DECIMAL, \
TIMESTAMP, TIMESTAMPTZ, DATE, TIME, ENUM, INET, CIDR, MACADDR)",
self.name,
self.data_type.name()
)
}
fn unique_type_error(&self) -> String {
format!(
"Column '{}' of type {} cannot have UNIQUE constraint. \
JSONB and BYTEA types do not support standard indexing.",
self.name,
self.data_type.name()
)
}
pub fn new(name: impl Into<String>, data_type: ColumnType) -> Self {
Self {
name: name.into(),
data_type,
nullable: true,
primary_key: false,
unique: false,
default: None,
foreign_key: None,
check: None,
generated: None,
}
}
pub fn not_null(mut self) -> Self {
self.nullable = false;
self
}
pub fn primary_key(mut self) -> Self {
if !self.data_type.can_be_primary_key() {
#[cfg(debug_assertions)]
eprintln!("QAIL: {}", self.primary_key_type_error());
}
self.primary_key = true;
self.nullable = false;
self
}
pub fn try_primary_key(mut self) -> Result<Self, String> {
if !self.data_type.can_be_primary_key() {
return Err(self.primary_key_type_error());
}
self.primary_key = true;
self.nullable = false;
Ok(self)
}
pub fn unique(mut self) -> Self {
if !self.data_type.supports_indexing() {
#[cfg(debug_assertions)]
eprintln!("QAIL: {}", self.unique_type_error());
}
self.unique = true;
self
}
pub fn try_unique(mut self) -> Result<Self, String> {
if !self.data_type.supports_indexing() {
return Err(self.unique_type_error());
}
self.unique = true;
Ok(self)
}
pub fn default(mut self, val: impl Into<String>) -> Self {
self.default = Some(val.into());
self
}
pub fn references(mut self, table: &str, column: &str) -> Self {
self.foreign_key = Some(ForeignKey {
table: table.to_string(),
column: column.to_string(),
on_delete: FkAction::default(),
on_update: FkAction::default(),
deferrable: Deferrable::default(),
});
self
}
pub fn on_delete(mut self, action: FkAction) -> Self {
if let Some(ref mut fk) = self.foreign_key {
fk.on_delete = action;
}
self
}
pub fn on_update(mut self, action: FkAction) -> Self {
if let Some(ref mut fk) = self.foreign_key {
fk.on_update = action;
}
self
}
pub fn check(mut self, expr: CheckExpr) -> Self {
self.check = Some(CheckConstraint { expr, name: None });
self
}
pub fn check_named(mut self, name: impl Into<String>, expr: CheckExpr) -> Self {
self.check = Some(CheckConstraint {
expr,
name: Some(name.into()),
});
self
}
pub fn deferrable(mut self) -> Self {
if let Some(ref mut fk) = self.foreign_key {
fk.deferrable = Deferrable::Deferrable;
}
self
}
pub fn initially_deferred(mut self) -> Self {
if let Some(ref mut fk) = self.foreign_key {
fk.deferrable = Deferrable::InitiallyDeferred;
}
self
}
pub fn initially_immediate(mut self) -> Self {
if let Some(ref mut fk) = self.foreign_key {
fk.deferrable = Deferrable::InitiallyImmediate;
}
self
}
pub fn generated_stored(mut self, expr: impl Into<String>) -> Self {
self.generated = Some(Generated::AlwaysStored(expr.into()));
self
}
pub fn generated_identity(mut self) -> Self {
self.generated = Some(Generated::AlwaysIdentity);
self
}
pub fn generated_by_default(mut self) -> Self {
self.generated = Some(Generated::ByDefaultIdentity);
self
}
}
impl Index {
pub fn new(name: impl Into<String>, table: impl Into<String>, columns: Vec<String>) -> Self {
Self {
name: name.into(),
table: table.into(),
columns,
unique: false,
method: IndexMethod::default(),
where_clause: None,
include: Vec::new(),
concurrently: false,
expressions: Vec::new(),
}
}
pub fn expression(
name: impl Into<String>,
table: impl Into<String>,
expressions: Vec<String>,
) -> Self {
Self {
name: name.into(),
table: table.into(),
columns: Vec::new(),
unique: false,
method: IndexMethod::default(),
where_clause: None,
include: Vec::new(),
concurrently: false,
expressions,
}
}
pub fn unique(mut self) -> Self {
self.unique = true;
self
}
pub fn using(mut self, method: IndexMethod) -> Self {
self.method = method;
self
}
pub fn partial(mut self, expr: CheckExpr) -> Self {
self.where_clause = Some(expr);
self
}
pub fn include(mut self, cols: Vec<String>) -> Self {
self.include = cols;
self
}
pub fn concurrently(mut self) -> Self {
self.concurrently = true;
self
}
}
fn fk_action_str(action: &FkAction) -> &'static str {
match action {
FkAction::NoAction => "no_action",
FkAction::Cascade => "cascade",
FkAction::SetNull => "set_null",
FkAction::SetDefault => "set_default",
FkAction::Restrict => "restrict",
}
}
fn format_qail_value_token(value: &str, extra_special: &[char]) -> String {
let needs_quotes = value.is_empty()
|| value.chars().any(|ch| {
ch.is_whitespace() || matches!(ch, ',' | '\'' | '"') || extra_special.contains(&ch)
});
if needs_quotes {
format!("\"{}\"", value.replace('"', "\"\""))
} else {
value.to_string()
}
}
fn format_check_in_value(value: &str) -> String {
format_qail_value_token(value, &['[', ']'])
}
fn format_sql_text_literal(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
fn format_sql_text_literal_with_cast(value: &str, cast: &Option<String>) -> String {
let literal = format_sql_text_literal(value);
match cast {
Some(cast) => format!("{literal}::{cast}"),
None => literal,
}
}
fn check_expr_str(expr: &CheckExpr) -> String {
match expr {
CheckExpr::GreaterThan { column, value } => format!("{} > {}", column, value),
CheckExpr::GreaterOrEqual { column, value } => format!("{} >= {}", column, value),
CheckExpr::LessThan { column, value } => format!("{} < {}", column, value),
CheckExpr::LessOrEqual { column, value } => format!("{} <= {}", column, value),
CheckExpr::Between { column, low, high } => format!("{} between {} {}", column, low, high),
CheckExpr::In { column, values } => format!(
"{} in [{}]",
column,
values
.iter()
.map(|value| format_check_in_value(value))
.collect::<Vec<_>>()
.join(", ")
),
CheckExpr::InIntegers { column, values } => format!(
"{} = ANY (ARRAY[{}])",
column,
values
.iter()
.map(i64::to_string)
.collect::<Vec<_>>()
.join(", ")
),
CheckExpr::CompareColumns {
left_column,
op,
right_column,
} => format!("{} {} {}", left_column, op.as_sql_str(), right_column),
CheckExpr::TextCompare { column, op, value } => {
format!(
"{} {} {}",
column,
op.as_sql_str(),
format_sql_text_literal(value)
)
}
CheckExpr::CompareColumnToCoalesce {
left_column,
op,
coalesce_column,
fallback,
fallback_cast,
} => format!(
"{} {} COALESCE({}, {})",
left_column,
op.as_sql_str(),
coalesce_column,
format_sql_text_literal_with_cast(fallback, fallback_cast)
),
CheckExpr::LowerTrimEquals { column } => format!("{column} = lower(btrim({column}))"),
CheckExpr::Regex { column, pattern } => {
format!("{} ~ {}", column, format_sql_text_literal(pattern))
}
CheckExpr::MaxLength { column, max } => format!("length({}) <= {}", column, max),
CheckExpr::MinLength { column, min } => format!("length({}) >= {}", column, min),
CheckExpr::NotNull { column } => format!("{} not_null", column),
CheckExpr::And(l, r) => format!("{} and {}", check_expr_str(l), check_expr_str(r)),
CheckExpr::Or(l, r) => format!("{} or {}", check_expr_str(l), check_expr_str(r)),
CheckExpr::Not(e) => format!("not {}", check_expr_str(e)),
CheckExpr::Sql(sql) => sql.clone(),
}
}
fn format_enum_value(value: &str) -> String {
format_qail_value_token(value, &['{', '}'])
}
fn dollar_quote_qail_body(body: &str) -> String {
let delimiter = if !body.contains("$$") {
"$$".to_string()
} else {
let mut idx = 0usize;
loop {
let candidate = if idx == 0 {
"$qail$".to_string()
} else {
format!("$qail{idx}$")
};
if !body.contains(&candidate) {
break candidate;
}
idx = idx.saturating_add(1);
}
};
format!("{delimiter}\n{body}\n{delimiter}")
}
pub fn to_qail_string(schema: &Schema) -> String {
let mut output = String::new();
output.push_str("# QAIL Schema\n\n");
for ext in &schema.extensions {
let mut line = format!("extension {}", quote_qail_string(&ext.name));
if let Some(ref s) = ext.schema {
line.push_str(&format!(" schema {}", quote_qail_string(s)));
}
if let Some(ref v) = ext.version {
line.push_str(&format!(" version {}", quote_qail_string(v)));
}
output.push_str(&line);
output.push('\n');
}
if !schema.extensions.is_empty() {
output.push('\n');
}
for enum_type in &schema.enums {
let values = enum_type
.values
.iter()
.map(|v| format_enum_value(v))
.collect::<Vec<_>>()
.join(", ");
output.push_str(&format!("enum {} {{ {} }}\n", enum_type.name, values));
}
if !schema.enums.is_empty() {
output.push('\n');
}
for seq in &schema.sequences {
if seq.start.is_some()
|| seq.increment.is_some()
|| seq.min_value.is_some()
|| seq.max_value.is_some()
|| seq.cache.is_some()
|| seq.cycle
|| seq.owned_by.is_some()
{
let mut opts = Vec::new();
if let Some(v) = seq.start {
opts.push(format!("start {}", v));
}
if let Some(v) = seq.increment {
opts.push(format!("increment {}", v));
}
if let Some(v) = seq.min_value {
opts.push(format!("minvalue {}", v));
}
if let Some(v) = seq.max_value {
opts.push(format!("maxvalue {}", v));
}
if let Some(v) = seq.cache {
opts.push(format!("cache {}", v));
}
if seq.cycle {
opts.push("cycle".to_string());
}
if let Some(ref o) = seq.owned_by {
opts.push(format!("owned_by {}", o));
}
output.push_str(&format!("sequence {} {{ {} }}\n", seq.name, opts.join(" ")));
} else {
output.push_str(&format!("sequence {}\n", seq.name));
}
}
if !schema.sequences.is_empty() {
output.push('\n');
}
let mut table_names: Vec<&String> = schema.tables.keys().collect();
table_names.sort();
for table_name in table_names {
let table = &schema.tables[table_name];
output.push_str(&format!("table {} {{\n", table.name));
for col in &table.columns {
let mut constraints: Vec<String> = Vec::new();
if col.primary_key {
constraints.push("primary_key".to_string());
}
if !col.nullable && !col.primary_key {
constraints.push("not_null".to_string());
}
if col.unique {
constraints.push("unique".to_string());
}
if let Some(def) = &col.default {
constraints.push(format!("default {}", def));
}
if let Some(generated) = &col.generated {
match generated {
Generated::AlwaysStored(expr) => {
constraints.push(format!("generated_stored({})", expr));
}
Generated::AlwaysIdentity => {
constraints.push("generated_identity".to_string());
}
Generated::ByDefaultIdentity => {
constraints.push("generated_by_default_identity".to_string());
}
}
}
if let Some(ref fk) = col.foreign_key {
let mut fk_str = format!("references {}({})", fk.table, fk.column);
if fk.on_delete != FkAction::NoAction {
fk_str.push_str(&format!(" on_delete {}", fk_action_str(&fk.on_delete)));
}
if fk.on_update != FkAction::NoAction {
fk_str.push_str(&format!(" on_update {}", fk_action_str(&fk.on_update)));
}
match &fk.deferrable {
Deferrable::Deferrable => fk_str.push_str(" deferrable"),
Deferrable::InitiallyDeferred => fk_str.push_str(" initially_deferred"),
Deferrable::InitiallyImmediate => fk_str.push_str(" initially_immediate"),
Deferrable::NotDeferrable => {} }
constraints.push(fk_str);
}
if let Some(ref check) = col.check {
constraints.push(format!("check({})", check_expr_str(&check.expr)));
if let Some(name) = &check.name {
constraints.push(format!("check_name {}", name));
}
}
let constraint_str = if constraints.is_empty() {
String::new()
} else {
format!(" {}", constraints.join(" "))
};
output.push_str(&format!(
" {} {}{}\n",
col.name,
col.data_type.to_pg_type(),
constraint_str
));
}
for fk in &table.multi_column_fks {
let mut fk_line = format!(
" foreign_key ({}) references {}({})\n",
fk.columns.join(", "),
fk.ref_table,
fk.ref_columns.join(", ")
);
if fk.name.is_some()
|| fk.on_delete != FkAction::NoAction
|| fk.on_update != FkAction::NoAction
|| fk.deferrable != Deferrable::NotDeferrable
{
fk_line.pop();
if let Some(name) = &fk.name {
fk_line.push_str(&format!(" constraint {}", name));
}
if fk.on_delete != FkAction::NoAction {
fk_line.push_str(&format!(" on_delete {}", fk_action_str(&fk.on_delete)));
}
if fk.on_update != FkAction::NoAction {
fk_line.push_str(&format!(" on_update {}", fk_action_str(&fk.on_update)));
}
match &fk.deferrable {
Deferrable::Deferrable => fk_line.push_str(" deferrable"),
Deferrable::InitiallyDeferred => fk_line.push_str(" initially_deferred"),
Deferrable::InitiallyImmediate => fk_line.push_str(" initially_immediate"),
Deferrable::NotDeferrable => {}
}
fk_line.push('\n');
}
output.push_str(&fk_line);
}
if table.enable_rls {
output.push_str(" enable_rls\n");
}
if table.force_rls {
output.push_str(" force_rls\n");
}
output.push_str("}\n\n");
}
for idx in &schema.indexes {
let unique = if idx.unique { "unique " } else { "" };
let concurrently = if idx.concurrently {
"concurrently "
} else {
""
};
let cols = if !idx.expressions.is_empty() {
idx.expressions.join(", ")
} else {
idx.columns.join(", ")
};
let mut line = format!(
"{}index {}{} on {}",
unique, concurrently, idx.name, idx.table
);
if idx.method != IndexMethod::BTree {
line.push_str(" using ");
line.push_str(index_method_str(&idx.method));
}
line.push_str(" (");
line.push_str(&cols);
line.push(')');
if !idx.include.is_empty() {
line.push_str(" include (");
line.push_str(&idx.include.join(", "));
line.push(')');
}
if let Some(where_clause) = &idx.where_clause {
line.push_str(" where ");
line.push_str(&check_expr_str(where_clause));
}
output.push_str(&line);
output.push('\n');
}
for hint in &schema.migrations {
match hint {
MigrationHint::Rename { from, to } => {
output.push_str(&format!("rename {} -> {}\n", from, to));
}
MigrationHint::Transform { expression, target } => {
output.push_str(&format!("transform {} -> {}\n", expression, target));
}
MigrationHint::Drop { target, confirmed } => {
let confirm = if *confirmed { " confirm" } else { "" };
output.push_str(&format!("drop {}{}\n", target, confirm));
}
}
}
for view in &schema.views {
let prefix = if view.materialized {
"materialized view"
} else {
"view"
};
let body = dollar_quote_qail_body(&view.query);
output.push_str(&format!("{} {} {}\n\n", prefix, view.name, body));
}
for func in &schema.functions {
let args = func.args.join(", ");
let volatility = func
.volatility
.as_deref()
.filter(|v| !v.trim().is_empty())
.map(|v| format!(" {}", v))
.unwrap_or_default();
let body = dollar_quote_qail_body(&func.body);
output.push_str(&format!(
"function {}({}) returns {} language {}{} {}\n\n",
func.name, args, func.returns, func.language, volatility, body
));
}
for trigger in &schema.triggers {
let mut events = Vec::new();
for evt in &trigger.events {
if evt.eq_ignore_ascii_case("UPDATE") && !trigger.update_columns.is_empty() {
events.push(format!("UPDATE OF {}", trigger.update_columns.join(", ")));
} else {
events.push(evt.clone());
}
}
output.push_str(&format!(
"trigger {} on {} {} {} execute {}\n",
trigger.name,
trigger.table,
trigger.timing.to_lowercase(),
events.join(" or ").to_lowercase(),
trigger.execute_function
));
}
if !schema.triggers.is_empty() {
output.push('\n');
}
for policy in &schema.policies {
let cmd = match policy.target {
PolicyTarget::All => "all",
PolicyTarget::Select => "select",
PolicyTarget::Insert => "insert",
PolicyTarget::Update => "update",
PolicyTarget::Delete => "delete",
};
let perm = match policy.permissiveness {
PolicyPermissiveness::Permissive => "",
PolicyPermissiveness::Restrictive => " restrictive",
};
let role_str = match &policy.role {
Some(r) => format!(" to {}", r),
None => String::new(),
};
output.push_str(&format!(
"policy {} on {} for {}{}{}",
policy.name, policy.table, cmd, role_str, perm
));
if let Some(ref using) = policy.using {
output.push_str(&format!("\n using $$ {} $$", using));
}
if let Some(ref wc) = policy.with_check {
output.push_str(&format!("\n with_check $$ {} $$", wc));
}
output.push_str("\n\n");
}
for grant in &schema.grants {
let privs: Vec<String> = grant
.privileges
.iter()
.map(|p| p.to_string().to_lowercase())
.collect();
match grant.action {
GrantAction::Grant => {
output.push_str(&format!(
"grant {} on {} to {}\n",
privs.join(", "),
grant.on_object,
grant.to_role
));
}
GrantAction::Revoke => {
output.push_str(&format!(
"revoke {} on {} from {}\n",
privs.join(", "),
grant.on_object,
grant.to_role
));
}
}
}
if !schema.grants.is_empty() {
output.push('\n');
}
for comment in &schema.comments {
let text = quote_qail_string(&comment.text);
match &comment.target {
CommentTarget::Table(t) => {
output.push_str(&format!("comment on {} {}\n", t, text));
}
CommentTarget::Column { table, column } => {
output.push_str(&format!("comment on {}.{} {}\n", table, column, text));
}
CommentTarget::Raw(target) => {
output.push_str(&format!("comment on {} {}\n", target, text));
}
}
}
output
}
fn quote_qail_string(value: &str) -> String {
format!("\"{}\"", value.replace('"', "\"\""))
}
pub fn schema_to_commands(schema: &Schema) -> Vec<crate::ast::Qail> {
use crate::ast::{Action, ColumnGeneration, Constraint, Expr, IndexDef, Qail};
let mut cmds = Vec::new();
let mut indegree: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut reverse_adj: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for name in schema.tables.keys() {
indegree.insert(name.clone(), 0);
}
for table in schema.tables.values() {
let mut deps = std::collections::HashSet::new();
for col in &table.columns {
if let Some(fk) = &col.foreign_key
&& fk.table != table.name
&& schema.tables.contains_key(&fk.table)
{
deps.insert(fk.table.clone());
}
}
for fk in &table.multi_column_fks {
if fk.ref_table != table.name && schema.tables.contains_key(&fk.ref_table) {
deps.insert(fk.ref_table.clone());
}
}
indegree.insert(table.name.clone(), deps.len());
for dep in deps {
reverse_adj.entry(dep).or_default().push(table.name.clone());
}
}
let mut ready = std::collections::BTreeSet::new();
for (name, deg) in &indegree {
if *deg == 0 {
ready.insert(name.clone());
}
}
let mut ordered_names: Vec<String> = Vec::with_capacity(schema.tables.len());
while let Some(next) = ready.pop_first() {
ordered_names.push(next.clone());
if let Some(dependents) = reverse_adj.get(&next) {
for dep_name in dependents {
if let Some(d) = indegree.get_mut(dep_name)
&& *d > 0
{
*d -= 1;
if *d == 0 {
ready.insert(dep_name.clone());
}
}
}
}
}
if ordered_names.len() < schema.tables.len() {
let mut leftovers: Vec<String> = schema
.tables
.keys()
.filter(|name| !ordered_names.contains(*name))
.cloned()
.collect();
leftovers.sort();
ordered_names.extend(leftovers);
}
for table_name in ordered_names {
let table = &schema.tables[&table_name];
let columns: Vec<Expr> = table
.columns
.iter()
.map(|col| {
let mut constraints = Vec::new();
if col.primary_key {
constraints.push(Constraint::PrimaryKey);
}
if col.nullable {
constraints.push(Constraint::Nullable);
}
if col.unique {
constraints.push(Constraint::Unique);
}
if let Some(def) = &col.default {
constraints.push(Constraint::Default(def.clone()));
}
if let Some(ref fk) = col.foreign_key {
constraints.push(Constraint::References(foreign_key_to_sql(fk)));
}
if let Some(check) = &col.check {
let check_sql = check_expr_to_sql(&check.expr);
if let Some(name) = &check.name {
constraints.push(Constraint::Check(vec![format!(
"CONSTRAINT {} CHECK ({})",
name, check_sql
)]));
} else {
constraints.push(Constraint::Check(vec![check_sql]));
}
}
if let Some(generated) = &col.generated {
let gen_constraint = match generated {
Generated::AlwaysStored(expr) => {
Constraint::Generated(ColumnGeneration::Stored(expr.clone()))
}
Generated::AlwaysIdentity => {
Constraint::Generated(ColumnGeneration::Stored("identity".to_string()))
}
Generated::ByDefaultIdentity => Constraint::Generated(
ColumnGeneration::Stored("identity_by_default".to_string()),
),
};
constraints.push(gen_constraint);
}
Expr::Def {
name: col.name.clone(),
data_type: col.data_type.to_pg_type(),
constraints,
}
})
.collect();
cmds.push(Qail {
action: Action::Make,
table: table.name.clone(),
columns,
..Default::default()
});
if table.enable_rls {
cmds.push(Qail {
action: Action::AlterEnableRls,
table: table.name.clone(),
..Default::default()
});
}
if table.force_rls {
cmds.push(Qail {
action: Action::AlterForceRls,
table: table.name.clone(),
..Default::default()
});
}
}
for idx in &schema.indexes {
cmds.push(Qail {
action: Action::Index,
table: String::new(),
index_def: Some(IndexDef {
name: idx.name.clone(),
table: idx.table.clone(),
columns: if !idx.expressions.is_empty() {
idx.expressions.clone()
} else {
idx.columns.clone()
},
unique: idx.unique,
index_type: Some(index_method_str(&idx.method).to_string()),
include: idx.include.clone(),
concurrently: idx.concurrently,
where_clause: idx.where_clause.as_ref().map(check_expr_to_sql),
}),
..Default::default()
});
}
let mut fk_table_names: Vec<&String> = schema
.tables
.iter()
.filter(|(_, table)| !table.multi_column_fks.is_empty())
.map(|(name, _)| name)
.collect();
fk_table_names.sort();
for table_name in fk_table_names {
let table = &schema.tables[table_name];
for fk in &table.multi_column_fks {
cmds.push(multi_column_fk_to_alter_command(&table.name, fk));
}
}
cmds
}
pub(super) fn multi_column_fk_to_table_constraint(
fk: &MultiColumnForeignKey,
) -> crate::ast::TableConstraint {
crate::ast::TableConstraint::ForeignKey {
name: fk.name.clone(),
columns: fk.columns.clone(),
ref_table: fk.ref_table.clone(),
ref_columns: fk.ref_columns.clone(),
on_delete: (fk.on_delete != FkAction::NoAction)
.then(|| fk_action_to_sql(&fk.on_delete).to_string()),
on_update: (fk.on_update != FkAction::NoAction)
.then(|| fk_action_to_sql(&fk.on_update).to_string()),
deferrable: deferrable_to_sql(&fk.deferrable).map(str::to_string),
}
}
pub(super) fn multi_column_fk_to_alter_command(
table_name: &str,
fk: &MultiColumnForeignKey,
) -> crate::ast::Qail {
crate::ast::Qail {
action: crate::ast::Action::Alter,
table: table_name.to_string(),
table_constraints: vec![multi_column_fk_to_table_constraint(fk)],
..Default::default()
}
}
fn fk_action_to_sql(action: &FkAction) -> &'static str {
match action {
FkAction::NoAction => "NO ACTION",
FkAction::Cascade => "CASCADE",
FkAction::SetNull => "SET NULL",
FkAction::SetDefault => "SET DEFAULT",
FkAction::Restrict => "RESTRICT",
}
}
fn deferrable_to_sql(deferrable: &Deferrable) -> Option<&'static str> {
match deferrable {
Deferrable::NotDeferrable => None,
Deferrable::Deferrable => Some("DEFERRABLE"),
Deferrable::InitiallyDeferred => Some("DEFERRABLE INITIALLY DEFERRED"),
Deferrable::InitiallyImmediate => Some("DEFERRABLE INITIALLY IMMEDIATE"),
}
}
pub(crate) fn foreign_key_to_sql(fk: &ForeignKey) -> String {
let mut target = format!("{}({})", fk.table, fk.column);
if fk.on_delete != FkAction::NoAction {
target.push_str(" ON DELETE ");
target.push_str(fk_action_to_sql(&fk.on_delete));
}
if fk.on_update != FkAction::NoAction {
target.push_str(" ON UPDATE ");
target.push_str(fk_action_to_sql(&fk.on_update));
}
if let Some(def) = deferrable_to_sql(&fk.deferrable) {
target.push(' ');
target.push_str(def);
}
target
}
pub(crate) fn check_expr_to_sql(expr: &CheckExpr) -> String {
match expr {
CheckExpr::GreaterThan { column, value } => format!("{column} > {value}"),
CheckExpr::GreaterOrEqual { column, value } => format!("{column} >= {value}"),
CheckExpr::LessThan { column, value } => format!("{column} < {value}"),
CheckExpr::LessOrEqual { column, value } => format!("{column} <= {value}"),
CheckExpr::Between { column, low, high } => format!("{column} BETWEEN {low} AND {high}"),
CheckExpr::In { column, values } => {
if values.len() == 1 && looks_like_raw_check_expr(&values[0]) {
return values[0].clone();
}
let quoted = values
.iter()
.map(|v| format!("'{}'", v.replace('\'', "''")))
.collect::<Vec<_>>()
.join(", ");
format!("{column} IN ({quoted})")
}
CheckExpr::InIntegers { column, values } => format!(
"{column} IN ({})",
values
.iter()
.map(i64::to_string)
.collect::<Vec<_>>()
.join(", ")
),
CheckExpr::CompareColumns {
left_column,
op,
right_column,
} => format!("{left_column} {} {right_column}", op.as_sql_str()),
CheckExpr::TextCompare { column, op, value } => {
format!(
"{column} {} {}",
op.as_sql_str(),
format_sql_text_literal(value)
)
}
CheckExpr::CompareColumnToCoalesce {
left_column,
op,
coalesce_column,
fallback,
fallback_cast,
} => format!(
"{left_column} {} COALESCE({coalesce_column}, {})",
op.as_sql_str(),
format_sql_text_literal_with_cast(fallback, fallback_cast)
),
CheckExpr::LowerTrimEquals { column } => format!("{column} = lower(btrim({column}))"),
CheckExpr::Regex { column, pattern } => {
format!("{column} ~ {}", format_sql_text_literal(pattern))
}
CheckExpr::MaxLength { column, max } => format!("char_length({column}) <= {max}"),
CheckExpr::MinLength { column, min } => format!("char_length({column}) >= {min}"),
CheckExpr::NotNull { column } => format!("{column} IS NOT NULL"),
CheckExpr::And(left, right) => {
format!(
"({}) AND ({})",
check_expr_to_sql(left),
check_expr_to_sql(right)
)
}
CheckExpr::Or(left, right) => {
format!(
"({}) OR ({})",
check_expr_to_sql(left),
check_expr_to_sql(right)
)
}
CheckExpr::Not(inner) => format!("NOT ({})", check_expr_to_sql(inner)),
CheckExpr::Sql(sql) => sql.clone(),
}
}
fn looks_like_raw_check_expr(s: &str) -> bool {
s.chars()
.any(|c| c.is_whitespace() || matches!(c, '<' | '>' | '=' | '!' | '(' | ')' | ':'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_schema_builder() {
let mut schema = Schema::new();
let users = Table::new("users")
.column(Column::new("id", ColumnType::Serial).primary_key())
.column(Column::new("name", ColumnType::Text).not_null())
.column(Column::new("email", ColumnType::Text).unique());
schema.add_table(users);
schema.add_index(Index::new("idx_users_email", "users", vec!["email".into()]).unique());
let output = to_qail_string(&schema);
assert!(output.contains("table users"));
assert!(output.contains("id SERIAL primary_key"));
assert!(output.contains("unique index idx_users_email"));
}
#[test]
fn test_to_qail_string_preserves_vector_index_methods() {
let mut schema = Schema::new();
schema.add_index(
Index::new(
"idx_docs_embedding_hnsw",
"documents",
vec!["embedding vector_l2_ops".into()],
)
.using(IndexMethod::Hnsw),
);
schema.add_index(
Index::new(
"idx_docs_embedding_ivfflat",
"documents",
vec!["embedding vector_cosine_ops".into()],
)
.using(IndexMethod::IvfFlat),
);
let output = to_qail_string(&schema);
assert!(output.contains(
"index idx_docs_embedding_hnsw on documents using hnsw (embedding vector_l2_ops)"
));
assert!(output.contains(
"index idx_docs_embedding_ivfflat on documents using ivfflat (embedding vector_cosine_ops)"
));
}
#[test]
fn test_to_qail_string_preserves_covering_concurrent_index_options() {
let mut schema = Schema::new();
schema.add_index(
Index::new("idx_users_email_cover", "users", vec!["email".into()])
.unique()
.include(vec!["name".into(), "created_at".into()])
.concurrently()
.partial(CheckExpr::Sql("deleted_at IS NULL".to_string())),
);
let output = to_qail_string(&schema);
assert!(output.contains(
"unique index concurrently idx_users_email_cover on users (email) include (name, created_at) where deleted_at IS NULL"
));
}
#[test]
fn test_migration_hints() {
let mut schema = Schema::new();
schema.add_hint(MigrationHint::Rename {
from: "users.username".into(),
to: "users.name".into(),
});
let output = to_qail_string(&schema);
assert!(output.contains("rename users.username -> users.name"));
}
#[test]
fn test_to_qail_string_includes_function_volatility() {
let mut schema = Schema::new();
let func = SchemaFunctionDef::new(
"is_super_admin",
"boolean",
"BEGIN RETURN true; END;".to_string(),
)
.language("plpgsql")
.volatility("stable");
schema.add_function(func);
let output = to_qail_string(&schema);
assert!(
output.contains("function is_super_admin() returns boolean language plpgsql stable $$")
);
}
#[test]
fn test_invalid_primary_key_type_strict() {
let err = Column::new("data", ColumnType::Jsonb)
.try_primary_key()
.expect_err("JSONB should be rejected by strict PK policy");
assert!(err.contains("cannot be a primary key"));
}
#[test]
fn test_invalid_primary_key_type_fail_soft() {
let col = Column::new("data", ColumnType::Jsonb).primary_key();
assert!(col.primary_key);
assert!(!col.nullable);
}
#[test]
fn test_invalid_unique_type_strict() {
let err = Column::new("data", ColumnType::Jsonb)
.try_unique()
.expect_err("JSONB should be rejected by strict UNIQUE policy");
assert!(err.contains("cannot have UNIQUE"));
}
#[test]
fn test_invalid_unique_type_fail_soft() {
let col = Column::new("data", ColumnType::Jsonb).unique();
assert!(col.unique);
}
#[test]
fn test_validate_rejects_invalid_primary_key_type() {
let mut schema = Schema::new();
schema.add_table(
Table::new("events").column(Column::new("data", ColumnType::Jsonb).primary_key()),
);
let errors = schema
.validate()
.expect_err("invalid primary-key type should fail validation");
assert!(
errors.iter().any(|err| {
err.contains("events.data")
&& err.contains("JSONB")
&& err.contains("cannot be a primary key")
}),
"{errors:?}"
);
}
#[test]
fn test_validate_rejects_invalid_unique_type() {
let mut schema = Schema::new();
schema.add_table(
Table::new("events").column(Column::new("data", ColumnType::Jsonb).unique()),
);
let errors = schema
.validate()
.expect_err("invalid unique type should fail validation");
assert!(
errors.iter().any(|err| {
err.contains("events.data")
&& err.contains("JSONB")
&& err.contains("cannot have UNIQUE")
}),
"{errors:?}"
);
}
#[test]
fn test_foreign_key_valid() {
let mut schema = Schema::new();
schema.add_table(
Table::new("users").column(Column::new("id", ColumnType::Uuid).primary_key()),
);
schema.add_table(
Table::new("posts")
.column(Column::new("id", ColumnType::Uuid).primary_key())
.column(
Column::new("user_id", ColumnType::Uuid)
.references("users", "id")
.on_delete(FkAction::Cascade),
),
);
assert!(schema.validate().is_ok());
}
#[test]
fn test_foreign_key_invalid_table() {
let mut schema = Schema::new();
schema.add_table(
Table::new("posts")
.column(Column::new("id", ColumnType::Uuid).primary_key())
.column(Column::new("user_id", ColumnType::Uuid).references("nonexistent", "id")),
);
let result = schema.validate();
assert!(result.is_err());
assert!(result.unwrap_err()[0].contains("non-existent table"));
}
#[test]
fn test_foreign_key_invalid_column() {
let mut schema = Schema::new();
schema.add_table(
Table::new("users").column(Column::new("id", ColumnType::Uuid).primary_key()),
);
schema.add_table(
Table::new("posts")
.column(Column::new("id", ColumnType::Uuid).primary_key())
.column(
Column::new("user_id", ColumnType::Uuid).references("users", "wrong_column"),
),
);
let result = schema.validate();
assert!(result.is_err());
assert!(result.unwrap_err()[0].contains("non-existent column"));
}
#[test]
fn test_foreign_key_requires_unique_target() {
let mut schema = Schema::new();
schema.add_table(Table::new("users").column(Column::new("email", ColumnType::Text)));
schema.add_table(
Table::new("posts")
.column(Column::new("id", ColumnType::Uuid).primary_key())
.column(Column::new("author_email", ColumnType::Text).references("users", "email")),
);
let errors = schema
.validate()
.expect_err("FK targets must be unique or primary-key backed");
assert!(
errors.iter().any(|err| err.contains("posts.author_email")
&& err.contains("without a UNIQUE or PRIMARY KEY constraint")),
"{errors:?}"
);
}
#[test]
fn test_multi_column_foreign_key_invalid_table_and_columns() {
let mut schema = Schema::new();
schema.add_table(
Table::new("trips")
.column(Column::new("route_id", ColumnType::Text))
.foreign_key(MultiColumnForeignKey::new(
vec!["route_id".to_string(), "schedule_id".to_string()],
"schedules",
vec!["route_id".to_string(), "schedule_id".to_string()],
)),
);
let errors = schema
.validate()
.expect_err("invalid composite FK should fail validation");
assert!(
errors
.iter()
.any(|err| err.contains("non-existent source column 'trips.schedule_id'")),
"{errors:?}"
);
assert!(
errors
.iter()
.any(|err| err.contains("non-existent table 'schedules'")),
"{errors:?}"
);
}
#[test]
fn test_multi_column_foreign_key_invalid_target_column_and_arity() {
let mut schema = Schema::new();
schema.add_table(Table::new("schedules").column(Column::new("route_id", ColumnType::Text)));
schema.add_table(
Table::new("trips")
.column(Column::new("route_id", ColumnType::Text))
.foreign_key(MultiColumnForeignKey::new(
vec!["route_id".to_string()],
"schedules",
vec!["route_id".to_string(), "schedule_id".to_string()],
)),
);
let errors = schema
.validate()
.expect_err("invalid composite FK should fail validation");
assert!(
errors.iter().any(|err| err.contains("column count 1")),
"{errors:?}"
);
assert!(
errors
.iter()
.any(|err| err.contains("non-existent column 'schedules.schedule_id'")),
"{errors:?}"
);
}
#[test]
fn test_multi_column_foreign_key_requires_unique_target() {
let mut schema = Schema::new();
schema.add_table(
Table::new("schedules")
.column(Column::new("route_id", ColumnType::Text))
.column(Column::new("schedule_id", ColumnType::Text)),
);
schema.add_table(
Table::new("trips")
.column(Column::new("route_id", ColumnType::Text))
.column(Column::new("schedule_id", ColumnType::Text))
.foreign_key(MultiColumnForeignKey::new(
vec!["route_id".to_string(), "schedule_id".to_string()],
"schedules",
vec!["route_id".to_string(), "schedule_id".to_string()],
)),
);
let errors = schema
.validate()
.expect_err("composite FK targets must have a matching unique key");
assert!(
errors.iter().any(|err| {
err.contains("Multi-column FK error")
&& err.contains("schedules(route_id, schedule_id)")
&& err.contains("matching UNIQUE or PRIMARY KEY")
}),
"{errors:?}"
);
}
#[test]
fn test_multi_column_foreign_key_valid_with_unique_index() {
let mut schema = Schema::new();
schema.add_table(
Table::new("schedules")
.column(Column::new("route_id", ColumnType::Text))
.column(Column::new("schedule_id", ColumnType::Text)),
);
schema.add_index(
Index::new(
"schedules_route_schedule_key",
"schedules",
vec!["route_id".to_string(), "schedule_id".to_string()],
)
.unique(),
);
schema.add_table(
Table::new("trips")
.column(Column::new("route_id", ColumnType::Text))
.column(Column::new("schedule_id", ColumnType::Text))
.foreign_key(MultiColumnForeignKey::new(
vec!["route_id".to_string(), "schedule_id".to_string()],
"schedules",
vec!["route_id".to_string(), "schedule_id".to_string()],
)),
);
assert!(schema.validate().is_ok());
}
#[test]
fn test_validate_rejects_duplicate_columns() {
let mut schema = Schema::new();
schema.add_table(
Table::new("users")
.column(Column::new("email", ColumnType::Text))
.column(Column::new("email", ColumnType::Text)),
);
let errors = schema
.validate()
.expect_err("duplicate columns should fail validation");
assert!(
errors
.iter()
.any(|err| err.contains("duplicate column 'email'")),
"{errors:?}"
);
}
#[test]
fn test_validate_rejects_duplicate_index_names() {
let mut schema = Schema::new();
schema.add_table(Table::new("users").column(Column::new("email", ColumnType::Text)));
schema.add_index(Index::new(
"idx_users_email",
"users",
vec!["email".to_string()],
));
schema.add_index(Index::new(
"idx_users_email",
"users",
vec!["email".to_string()],
));
let errors = schema
.validate()
.expect_err("duplicate indexes should fail validation");
assert!(
errors
.iter()
.any(|err| err.contains("duplicate index name 'idx_users_email'")),
"{errors:?}"
);
}
#[test]
fn test_validate_rejects_check_on_missing_column() {
let mut schema = Schema::new();
schema.add_table(Table::new("orders").column(
Column::new("status", ColumnType::Text).check(CheckExpr::In {
column: "missing_status".to_string(),
values: vec!["paid".to_string(), "pending".to_string()],
}),
));
let errors = schema
.validate()
.expect_err("CHECK references should fail validation");
assert!(
errors.iter().any(|err| {
err.contains("CHECK error")
&& err.contains("orders.status")
&& err.contains("orders.missing_status")
}),
"{errors:?}"
);
}
#[test]
fn test_validate_rejects_nested_check_on_missing_column() {
let mut schema = Schema::new();
schema.add_table(
Table::new("pricing_plans")
.column(Column::new("start_date", ColumnType::Date))
.column(
Column::new("end_date", ColumnType::Date).check(CheckExpr::And(
Box::new(CheckExpr::CompareColumns {
left_column: "end_date".to_string(),
op: CheckComparisonOp::GreaterOrEqual,
right_column: "start_date".to_string(),
}),
Box::new(CheckExpr::CompareColumnToCoalesce {
left_column: "end_date".to_string(),
op: CheckComparisonOp::GreaterOrEqual,
coalesce_column: "missing_fallback_date".to_string(),
fallback: "1970-01-01".to_string(),
fallback_cast: Some("date".to_string()),
}),
)),
),
);
let errors = schema
.validate()
.expect_err("nested CHECK references should fail validation");
assert!(
errors
.iter()
.any(|err| err.contains("pricing_plans.missing_fallback_date")),
"{errors:?}"
);
}
#[test]
fn test_validate_rejects_duplicate_check_constraint_names() {
let mut schema = Schema::new();
schema.add_table(
Table::new("orders")
.column(Column::new("status", ColumnType::Text).check_named(
"orders_status_check",
CheckExpr::In {
column: "status".to_string(),
values: vec!["pending".to_string(), "paid".to_string()],
},
))
.column(Column::new("payment_status", ColumnType::Text).check_named(
"orders_status_check",
CheckExpr::In {
column: "payment_status".to_string(),
values: vec!["pending".to_string(), "paid".to_string()],
},
)),
);
let errors = schema
.validate()
.expect_err("duplicate constraint names should fail validation");
assert!(
errors
.iter()
.any(|err| { err.contains("duplicate constraint name 'orders_status_check'") }),
"{errors:?}"
);
}
#[test]
fn test_validate_rejects_duplicate_check_and_fk_constraint_names() {
let mut schema = Schema::new();
schema.add_table(
Table::new("schedules")
.column(Column::new("route_id", ColumnType::Text))
.column(Column::new("schedule_id", ColumnType::Text)),
);
schema.add_index(
Index::new(
"schedules_route_schedule_key",
"schedules",
vec!["route_id".to_string(), "schedule_id".to_string()],
)
.unique(),
);
schema.add_table(
Table::new("trips")
.column(Column::new("route_id", ColumnType::Text).check_named(
"trips_schedule_guard",
CheckExpr::NotNull {
column: "route_id".to_string(),
},
))
.column(Column::new("schedule_id", ColumnType::Text))
.foreign_key(
MultiColumnForeignKey::new(
vec!["route_id".to_string(), "schedule_id".to_string()],
"schedules",
vec!["route_id".to_string(), "schedule_id".to_string()],
)
.named("trips_schedule_guard"),
),
);
let errors = schema
.validate()
.expect_err("duplicate constraint names across constraint kinds should fail");
assert!(
errors
.iter()
.any(|err| { err.contains("duplicate constraint name 'trips_schedule_guard'") }),
"{errors:?}"
);
}
#[test]
fn test_validate_rejects_empty_constraint_names() {
let mut schema = Schema::new();
schema.add_table(Table::new("orders").column(
Column::new("status", ColumnType::Text).check_named(
" ",
CheckExpr::NotNull {
column: "status".to_string(),
},
),
));
let errors = schema
.validate()
.expect_err("empty constraint names should fail validation");
assert!(
errors
.iter()
.any(|err| err.contains("empty CHECK constraint name")),
"{errors:?}"
);
}
#[test]
fn test_validate_rejects_index_on_missing_table_or_column() {
let mut schema = Schema::new();
schema.add_table(Table::new("users").column(Column::new("email", ColumnType::Text)));
schema.add_index(Index::new(
"idx_missing_table",
"profiles",
vec!["email".to_string()],
));
schema.add_index(Index::new(
"idx_missing_column",
"users",
vec!["username".to_string()],
));
let errors = schema
.validate()
.expect_err("invalid indexes should fail validation");
assert!(
errors
.iter()
.any(|err| err.contains("idx_missing_table") && err.contains("profiles")),
"{errors:?}"
);
assert!(
errors
.iter()
.any(|err| err.contains("idx_missing_column") && err.contains("users.username")),
"{errors:?}"
);
}
#[test]
fn test_validate_rejects_empty_index_definition() {
let mut schema = Schema::new();
schema.add_table(Table::new("users").column(Column::new("email", ColumnType::Text)));
schema.add_index(Index::new("idx_users_empty", "users", vec![]));
let errors = schema
.validate()
.expect_err("empty index definitions should fail validation");
assert!(
errors.iter().any(|err| {
err.contains("idx_users_empty") && err.contains("at least one column or expression")
}),
"{errors:?}"
);
}
#[test]
fn test_validate_rejects_blank_index_column_fragment() {
let mut schema = Schema::new();
schema.add_table(Table::new("users").column(Column::new("email", ColumnType::Text)));
schema.add_index(Index::new(
"idx_users_blank",
"users",
vec![" ".to_string()],
));
let errors = schema
.validate()
.expect_err("blank index columns should fail validation");
assert!(
errors
.iter()
.any(|err| err.contains("idx_users_blank") && err.contains("empty column")),
"{errors:?}"
);
}
#[test]
fn test_validate_rejects_mixed_index_columns_and_expressions() {
let mut schema = Schema::new();
schema.add_table(Table::new("users").column(Column::new("email", ColumnType::Text)));
let mut index = Index::expression(
"idx_users_email_lower",
"users",
vec!["lower(email)".to_string()],
);
index.columns.push("email".to_string());
schema.add_index(index);
let errors = schema
.validate()
.expect_err("mixed index keys should fail validation");
assert!(
errors.iter().any(|err| {
err.contains("idx_users_email_lower")
&& err.contains("cannot mix columns and expressions")
}),
"{errors:?}"
);
}
#[test]
fn test_validate_rejects_missing_index_include_column() {
let mut schema = Schema::new();
schema.add_table(
Table::new("users")
.column(Column::new("email", ColumnType::Text))
.column(Column::new("created_at", ColumnType::Timestamp)),
);
schema.add_index(
Index::new("idx_users_email_cover", "users", vec!["email".to_string()])
.include(vec!["name".to_string()]),
);
let errors = schema
.validate()
.expect_err("invalid INCLUDE column should fail validation");
assert!(
errors
.iter()
.any(|err| { err.contains("idx_users_email_cover") && err.contains("users.name") }),
"{errors:?}"
);
}
#[test]
fn test_validate_rejects_missing_partial_index_predicate_column() {
let mut schema = Schema::new();
schema.add_table(Table::new("users").column(Column::new("email", ColumnType::Text)));
schema.add_index(
Index::new("idx_users_active_email", "users", vec!["email".to_string()]).partial(
CheckExpr::NotNull {
column: "deleted_at".to_string(),
},
),
);
let errors = schema
.validate()
.expect_err("invalid partial-index predicates should fail validation");
assert!(
errors.iter().any(|err| {
err.contains("idx_users_active_email") && err.contains("users.deleted_at")
}),
"{errors:?}"
);
}
#[test]
fn test_validate_allows_index_sort_direction_and_opclass_columns() {
let mut schema = Schema::new();
schema.add_table(
Table::new("documents")
.column(Column::new(
"embedding",
ColumnType::Array(Box::new(ColumnType::Float)),
))
.column(Column::new("created_at", ColumnType::Timestamptz)),
);
schema.add_index(
Index::new(
"idx_docs_embedding_hnsw",
"documents",
vec!["embedding vector_l2_ops".to_string()],
)
.using(IndexMethod::Hnsw),
);
schema.add_index(Index::new(
"idx_docs_created_at",
"documents",
vec!["created_at DESC NULLS LAST".to_string()],
));
assert!(schema.validate().is_ok());
}
#[test]
fn test_schema_to_commands_preserves_fk_actions_and_checks() {
let mut schema = Schema::new();
schema.add_table(
Table::new("orgs").column(Column::new("id", ColumnType::Uuid).primary_key()),
);
schema.add_table(
Table::new("users")
.column(Column::new("id", ColumnType::Uuid).primary_key())
.column(
Column::new("org_id", ColumnType::Uuid)
.references("orgs", "id")
.on_delete(FkAction::Cascade)
.on_update(FkAction::Restrict),
)
.column(
Column::new("age", ColumnType::Int).check(CheckExpr::GreaterOrEqual {
column: "age".to_string(),
value: 18,
}),
),
);
let cmds = schema_to_commands(&schema);
let users_cmd = cmds
.iter()
.find(|c| c.action == crate::ast::Action::Make && c.table == "users")
.expect("users create command should exist");
let org_id_constraints = users_cmd
.columns
.iter()
.find_map(|e| match e {
crate::ast::Expr::Def {
name, constraints, ..
} if name == "org_id" => Some(constraints),
_ => None,
})
.expect("org_id should exist");
let age_constraints = users_cmd
.columns
.iter()
.find_map(|e| match e {
crate::ast::Expr::Def {
name, constraints, ..
} if name == "age" => Some(constraints),
_ => None,
})
.expect("age should exist");
assert!(
org_id_constraints.iter().any(|c| matches!(
c,
crate::ast::Constraint::References(target)
if target.contains("orgs(id)")
&& target.contains("ON DELETE CASCADE")
&& target.contains("ON UPDATE RESTRICT")
)),
"foreign key action clauses should be preserved"
);
assert!(
age_constraints
.iter()
.any(|c| matches!(c, crate::ast::Constraint::Check(vals) if vals.len() == 1)),
"check expressions should be preserved"
);
}
#[test]
fn schema_to_commands_preserves_table_rls_flags() {
let mut docs = Table::new("docs").column(Column::new("id", ColumnType::Uuid).primary_key());
docs.enable_rls = true;
docs.force_rls = true;
let mut schema = Schema::new();
schema.add_table(docs);
let cmds = schema_to_commands(&schema);
let make_idx = cmds
.iter()
.position(|cmd| cmd.action == crate::ast::Action::Make && cmd.table == "docs")
.expect("table create command should exist");
let enable_idx = cmds
.iter()
.position(|cmd| cmd.action == crate::ast::Action::AlterEnableRls && cmd.table == "docs")
.expect("enable RLS command should exist");
let force_idx = cmds
.iter()
.position(|cmd| cmd.action == crate::ast::Action::AlterForceRls && cmd.table == "docs")
.expect("force RLS command should exist");
assert!(make_idx < enable_idx);
assert!(enable_idx < force_idx);
}
#[test]
fn schema_to_commands_preserves_multi_column_foreign_keys() {
use crate::transpiler::ToSql;
let mut schema = Schema::new();
schema.add_table(
Table::new("schedules")
.column(Column::new("route_id", ColumnType::Text))
.column(Column::new("schedule_id", ColumnType::Text)),
);
schema.add_index(
Index::new(
"idx_schedules_route_schedule",
"schedules",
vec!["route_id".to_string(), "schedule_id".to_string()],
)
.unique(),
);
schema.add_table(
Table::new("trips")
.column(Column::new("route_id", ColumnType::Text))
.column(Column::new("schedule_id", ColumnType::Text))
.foreign_key(
MultiColumnForeignKey::new(
vec!["route_id".to_string(), "schedule_id".to_string()],
"schedules",
vec!["route_id".to_string(), "schedule_id".to_string()],
)
.named("fk_trips_schedule")
.on_delete(FkAction::Cascade)
.on_update(FkAction::Restrict)
.initially_deferred(),
),
);
let cmds = schema_to_commands(&schema);
let schedules_idx = cmds
.iter()
.position(|c| c.action == crate::ast::Action::Make && c.table == "schedules")
.expect("schedules create command should exist");
let trips_idx = cmds
.iter()
.position(|c| c.action == crate::ast::Action::Make && c.table == "trips")
.expect("trips create command should exist");
let unique_idx = cmds
.iter()
.position(|c| {
c.action == crate::ast::Action::Index
&& c.index_def
.as_ref()
.is_some_and(|idx| idx.name == "idx_schedules_route_schedule")
})
.expect("unique index command should exist");
let add_fk_idx = cmds
.iter()
.position(|c| c.action == crate::ast::Action::Alter && c.table == "trips")
.expect("trips composite foreign key ALTER command should exist");
assert!(schedules_idx < unique_idx);
assert!(trips_idx < unique_idx);
assert!(unique_idx < add_fk_idx);
let trips_cmd = cmds
.iter()
.find(|c| c.action == crate::ast::Action::Make && c.table == "trips")
.expect("trips create command should exist");
assert!(
trips_cmd.table_constraints.is_empty(),
"composite foreign keys should not be emitted inline on CREATE TABLE"
);
let add_fk_cmd = &cmds[add_fk_idx];
assert!(
add_fk_cmd
.table_constraints
.iter()
.any(|constraint| matches!(
constraint,
crate::ast::TableConstraint::ForeignKey {
name,
columns,
ref_table,
ref_columns,
on_delete,
on_update,
deferrable,
} if columns == &["route_id", "schedule_id"]
&& name.as_deref() == Some("fk_trips_schedule")
&& ref_table == "schedules"
&& ref_columns == &["route_id", "schedule_id"]
&& on_delete.as_deref() == Some("CASCADE")
&& on_update.as_deref() == Some("RESTRICT")
&& deferrable.as_deref() == Some("DEFERRABLE INITIALLY DEFERRED")
)),
"multi-column foreign key should be represented in generated commands"
);
let sql = add_fk_cmd.to_sql();
assert!(
sql.contains(
"ALTER TABLE trips ADD CONSTRAINT fk_trips_schedule FOREIGN KEY (route_id, schedule_id) REFERENCES schedules(route_id, schedule_id) ON DELETE CASCADE ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED"
),
"generated SQL should include composite foreign key, got: {sql}"
);
}
#[test]
fn test_check_expr_sql_renders_integer_in_and_column_comparison() {
assert_eq!(
check_expr_to_sql(&CheckExpr::InIntegers {
column: "duration_hours".to_string(),
values: vec![8, 10, 12],
}),
"duration_hours IN (8, 10, 12)"
);
assert_eq!(
check_expr_to_sql(&CheckExpr::CompareColumns {
left_column: "origin_harbor_id".to_string(),
op: CheckComparisonOp::NotEqual,
right_column: "destination_harbor_id".to_string(),
}),
"origin_harbor_id <> destination_harbor_id"
);
assert_eq!(
check_expr_to_sql(&CheckExpr::TextCompare {
column: "module".to_string(),
op: CheckComparisonOp::NotEqual,
value: "charter".to_string(),
}),
"module <> 'charter'"
);
assert_eq!(
check_expr_to_sql(&CheckExpr::CompareColumnToCoalesce {
left_column: "start_date".to_string(),
op: CheckComparisonOp::LessOrEqual,
coalesce_column: "end_date".to_string(),
fallback: "2099-12-31".to_string(),
fallback_cast: Some("date".to_string()),
}),
"start_date <= COALESCE(end_date, '2099-12-31'::date)"
);
assert_eq!(
check_expr_to_sql(&CheckExpr::LowerTrimEquals {
column: "slug".to_string(),
}),
"slug = lower(btrim(slug))"
);
}
}