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)]
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>,
},
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,
}
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",
}
}
#[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
}
}
#[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() {
for col in &table.columns {
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
));
}
}
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
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 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.join(", ")),
CheckExpr::Regex { column, pattern } => format!("{} ~ '{}'", column, 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(),
}
}
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 \"{}\"", ext.name);
if let Some(ref s) = ext.schema {
line.push_str(&format!(" schema {}", s));
}
if let Some(ref v) = ext.version {
line.push_str(&format!(" version \"{}\"", 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| v.as_str())
.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(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 {
output.push_str(&format!(
" foreign_key ({}) references {}({})\n",
fk.columns.join(", "),
fk.ref_table,
fk.ref_columns.join(", ")
));
}
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 cols = if !idx.expressions.is_empty() {
idx.expressions.join(", ")
} else {
idx.columns.join(", ")
};
let mut line = format!("{}index {} on {}", unique, 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 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"
};
output.push_str(&format!(
"{} {} $$\n{}\n$$\n\n",
prefix, view.name, view.query
));
}
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();
output.push_str(&format!(
"function {}({}) returns {} language {}{} $$\n{}\n$$\n\n",
func.name, args, func.returns, func.language, volatility, func.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 {
match &comment.target {
CommentTarget::Table(t) => {
output.push_str(&format!("comment on {} \"{}\"\n", t, comment.text));
}
CommentTarget::Column { table, column } => {
output.push_str(&format!(
"comment on {}.{} \"{}\"\n",
table, column, comment.text
));
}
CommentTarget::Raw(target) => {
output.push_str(&format!("comment on {} \"{}\"\n", target, comment.text));
}
}
}
output
}
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());
}
}
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()
});
}
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()),
where_clause: idx.where_clause.as_ref().map(check_expr_to_sql),
}),
..Default::default()
});
}
cmds
}
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"),
}
}
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
}
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::Regex { column, pattern } => {
format!("{column} ~ '{}'", pattern.replace('\'', "''"))
}
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_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_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_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"
);
}
}