use std::collections::BTreeMap;
use std::collections::btree_map::Entry;
use crate::apps::{AppDescriptor, AppRegistry};
use crate::descriptor::{
DeferrabilitySpec, EnumDescriptor, ExclusionConstraintSpec, ExclusionElement, FieldDescriptor,
GeneratedColumnSpec, IndexKind, IndexNullsOrder, IndexOrder, IndexSpec, IndexTarget, IndexType,
ModelDescriptor, PartitionSpec, PkType, RustSourceType,
};
use crate::fts::FtsDescriptor;
use crate::migrate::provider::{DescriptorProvider, InventoryDescriptorProvider};
use crate::relation::{OnDelete, RelationKind};
use super::schema::{
AppliedSchema, ColumnSchema, CustomPkKindSchema, EnumSchema, ExclusionConstraintSchema,
ExclusionElementSchema, ForeignKeySchema, FtsSchema, GeneratedColumnSchema, IndexColumnSchema,
IndexKindSchema, IndexNullsOrderSchema, IndexOrderSchema, IndexSchema, IndexTargetSchema,
IndexTypeSchema, OnDeleteSchema, PartitionSchema, PkKindSchema, PrimaryKeySchema,
RelationKindSchema, SNAPSHOT_FORMAT_VERSION, TableSchema,
};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct BucketKey {
pub database: String,
pub app: String,
}
#[derive(Debug)]
pub enum ProjectionError {
DuplicateModelTypeName {
type_name: String,
first_table: String,
second_table: String,
},
DuplicateTableInBucket {
bucket: BucketKey,
table: String,
first_type: String,
second_type: String,
},
DuplicateEnumPostgresType {
postgres_type: String,
first_rust_type: String,
second_rust_type: String,
},
UnknownAppLabel {
app_label: String,
model_table: String,
},
CrossDatabaseForeignKey {
source_bucket: BucketKey,
source_table: String,
source_column: String,
target_bucket: BucketKey,
target_table: String,
},
ConflictingDeferrabilitySpec {
model_type_name: String,
field_name: String,
first: (bool, bool),
second: (bool, bool),
},
ProxyParentNotRegistered {
source_bucket: BucketKey,
source_table: String,
source_column: String,
proxy_type: String,
parent_type: String,
},
ProxyCycle {
type_name: String,
},
RelationAccessorCollisions(crate::relation::registry::RelationRegistryError),
}
impl std::fmt::Display for ProjectionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProjectionError::DuplicateModelTypeName {
type_name,
first_table,
second_table,
} => write!(
f,
"two `#[model]`s share the Rust type name `{type_name}`: \
tables `{first_table}` and `{second_table}`. Type names must \
be globally unique because FK target resolution keys off \
`type_name`. Rename one of the structs."
),
ProjectionError::DuplicateTableInBucket {
bucket,
table,
first_type,
second_type,
} => write!(
f,
"two models in bucket (database={}, app={}) land at the same \
Postgres table `{table}`: types `{first_type}` and `{second_type}`. \
Each `(database, app, table)` triple may carry at most one model.",
bucket.database, bucket.app
),
ProjectionError::DuplicateEnumPostgresType {
postgres_type,
first_rust_type,
second_rust_type,
} => write!(
f,
"two `#[derive(DjogiEnum)]` types share the Postgres type \
name `{postgres_type}`: `{first_rust_type}` and `{second_rust_type}`. \
Postgres `CREATE TYPE` names must be globally unique."
),
ProjectionError::UnknownAppLabel {
app_label,
model_table,
} => write!(
f,
"model `{model_table}` declares `#[model(app = ...)]` resolving \
to label `{app_label}`, but no app with that label is registered \
via `djogi::apps!`. Either declare the app or fix the label."
),
ProjectionError::CrossDatabaseForeignKey {
source_bucket,
source_table,
source_column,
target_bucket,
target_table,
} => write!(
f,
"cross-database foreign key rejected: `{}.{source_table}.{source_column}` \
(database `{}`) references `{target_table}` (database `{}`). \
Postgres FK constraints cannot span databases. Use an application-\
level join or the outbox pattern instead.",
source_bucket.app, source_bucket.database, target_bucket.database
),
ProjectionError::ConflictingDeferrabilitySpec {
model_type_name,
field_name,
first,
second,
} => write!(
f,
"two `DeferrabilitySpec` entries for `{model_type_name}::{field_name}` \
disagree: first=(deferrable={}, initially_deferred={}), \
second=(deferrable={}, initially_deferred={}). Inventory iteration \
order is not deterministic — the macro must emit at most one spec \
per `(model_type_name, field_name)`.",
first.0, first.1, second.0, second.1
),
ProjectionError::ProxyParentNotRegistered {
source_bucket,
source_table,
source_column,
proxy_type,
parent_type,
} => write!(
f,
"foreign key `{}.{source_table}.{source_column}` (database `{}`) \
targets proxy `{proxy_type}` whose `proxy_for = {parent_type}` parent \
is not registered in the inventory. Proxies never project DDL — \
the parent owns the table — so the FK target table cannot be \
resolved without the parent's descriptor. Register `{parent_type}` \
via `#[model(...)]` in a crate that participates in the migration \
inventory.",
source_bucket.app, source_bucket.database
),
ProjectionError::ProxyCycle { type_name } => write!(
f,
"proxy chain forms a cycle starting at `{type_name}` — \
`proxy_for` references must terminate at a concrete (non-proxy) \
parent. Break the cycle by removing one of the `proxy_for` \
declarations in the loop."
),
ProjectionError::RelationAccessorCollisions(inner) => write!(
f,
"relation-accessor collisions detected before projection \
(GH #158); the framework gates `project_from_inventory` on \
the global `inventory::iter::<ReverseRelationMarker>` walk \
so cross-kind clashes surface with relation metadata \
rather than at a downstream `ambiguous method call` site:\n\
{inner}"
),
}
}
}
impl std::error::Error for ProjectionError {}
fn insert_unique<K: Ord, V, E>(
map: &mut BTreeMap<K, V>,
key: K,
value: V,
on_conflict: impl FnOnce(&V, &V) -> Result<(), E>,
) -> Result<(), E> {
match map.entry(key) {
Entry::Vacant(v) => {
v.insert(value);
Ok(())
}
Entry::Occupied(occ) => on_conflict(occ.get(), &value),
}
}
#[allow(clippy::result_large_err)]
pub fn project_from_provider(
p: &dyn DescriptorProvider,
) -> Result<BTreeMap<BucketKey, AppliedSchema>, ProjectionError> {
crate::relation::registry::validate_global_relation_accessor_registry()
.map_err(ProjectionError::RelationAccessorCollisions)?;
project_from_iters_with_deferrability(
p.models(),
p.enums(),
p.apps().iter(),
p.deferrability_specs(),
rfc3339_now_seconds(),
)
}
#[allow(clippy::result_large_err)]
pub fn project_from_inventory() -> Result<BTreeMap<BucketKey, AppliedSchema>, ProjectionError> {
project_from_provider(&InventoryDescriptorProvider::new())
}
#[allow(clippy::result_large_err)]
#[allow(dead_code)]
pub(crate) fn project_from_inventory_with_relation_validator<F>(
validator: F,
) -> Result<BTreeMap<BucketKey, AppliedSchema>, ProjectionError>
where
F: FnOnce() -> Result<(), crate::relation::registry::RelationRegistryError>,
{
validator().map_err(ProjectionError::RelationAccessorCollisions)?;
project_from_iters(
inventory::iter::<ModelDescriptor>(),
inventory::iter::<EnumDescriptor>(),
AppRegistry::all().iter(),
rfc3339_now_seconds(),
)
}
#[allow(clippy::result_large_err)]
pub(crate) fn project_from_iters<'a, M, E, A>(
models: M,
enums: E,
apps: A,
generated_at: String,
) -> Result<BTreeMap<BucketKey, AppliedSchema>, ProjectionError>
where
M: IntoIterator<Item = &'a ModelDescriptor>,
E: IntoIterator<Item = &'a EnumDescriptor>,
A: IntoIterator<Item = &'a AppDescriptor>,
{
project_from_iters_with_deferrability(
models,
enums,
apps,
inventory::iter::<DeferrabilitySpec>(),
generated_at,
)
}
#[allow(clippy::result_large_err)]
pub(crate) fn project_from_iters_with_deferrability<'a, M, E, A, D>(
models: M,
enums: E,
apps: A,
deferrability_specs: D,
generated_at: String,
) -> Result<BTreeMap<BucketKey, AppliedSchema>, ProjectionError>
where
M: IntoIterator<Item = &'a ModelDescriptor>,
E: IntoIterator<Item = &'a EnumDescriptor>,
A: IntoIterator<Item = &'a AppDescriptor>,
D: IntoIterator<Item = &'static DeferrabilitySpec>,
{
let models: Vec<&ModelDescriptor> = models.into_iter().collect();
let apps: Vec<&AppDescriptor> = apps.into_iter().collect();
let mut deferrability_by_field: BTreeMap<(&str, &str), (bool, bool)> = BTreeMap::new();
for spec in deferrability_specs {
let key = (spec.model_type_name, spec.field_name);
let value = (spec.deferrable, spec.initially_deferred);
if let Some(prev) = deferrability_by_field.get(&key)
&& *prev != value
{
return Err(ProjectionError::ConflictingDeferrabilitySpec {
model_type_name: spec.model_type_name.to_string(),
field_name: spec.field_name.to_string(),
first: *prev,
second: value,
});
}
deferrability_by_field.insert(key, value);
}
let mut label_to_app: BTreeMap<&str, &AppDescriptor> = BTreeMap::new();
label_to_app.insert(AppDescriptor::GLOBAL_LABEL, &AppDescriptor::GLOBAL);
for a in &apps {
label_to_app.insert(a.label, a);
}
let mut type_to_table: BTreeMap<&str, &str> = BTreeMap::new();
for m in &models {
insert_unique(
&mut type_to_table,
m.type_name,
m.table_name,
|prev_table, new_table| {
if prev_table == new_table {
Ok(())
} else {
Err(ProjectionError::DuplicateModelTypeName {
type_name: m.type_name.to_string(),
first_table: (*prev_table).to_string(),
second_table: (*new_table).to_string(),
})
}
},
)?;
}
let mut bucket_models: BTreeMap<BucketKey, Vec<&ModelDescriptor>> = BTreeMap::new();
let mut type_to_bucket: BTreeMap<&str, BucketKey> = BTreeMap::new();
let mut type_to_proxy_for: BTreeMap<&str, &str> = BTreeMap::new();
for m in &models {
let label = m.app.unwrap_or(AppDescriptor::GLOBAL_LABEL);
let app = label_to_app
.get(label)
.ok_or_else(|| ProjectionError::UnknownAppLabel {
app_label: label.to_string(),
model_table: m.table_name.to_string(),
})?;
let bucket = BucketKey {
database: app.database.to_string(),
app: app.label.to_string(),
};
type_to_bucket.insert(m.type_name, bucket.clone());
bucket_models.entry(bucket).or_default().push(m);
if let Some(parent) = m.proxy_for {
type_to_proxy_for.insert(m.type_name, parent);
}
}
for m in &models {
if m.proxy_for.is_some() {
continue;
}
let source_label = m.app.unwrap_or(AppDescriptor::GLOBAL_LABEL);
let source_app = label_to_app[source_label];
let source_bucket = BucketKey {
database: source_app.database.to_string(),
app: source_app.label.to_string(),
};
for f in m.fields {
if f.relation_kind.is_none() {
continue;
}
let Some(target_type) = f.target_type_name else {
continue;
};
let mut resolved_target: &str = target_type;
let mut steps = 0usize;
while let Some(parent) = type_to_proxy_for.get(resolved_target).copied() {
if !type_to_bucket.contains_key(parent) {
return Err(ProjectionError::ProxyParentNotRegistered {
source_bucket,
source_table: m.table_name.to_string(),
source_column: f.name.to_string(),
proxy_type: resolved_target.to_string(),
parent_type: parent.to_string(),
});
}
resolved_target = parent;
steps += 1;
if steps > models.len() {
return Err(ProjectionError::ProxyCycle {
type_name: target_type.to_string(),
});
}
}
let Some(target_bucket) = type_to_bucket.get(resolved_target) else {
continue; };
if target_bucket.database != source_bucket.database {
let target_table = type_to_table
.get(resolved_target)
.copied()
.unwrap_or(resolved_target)
.to_string();
return Err(ProjectionError::CrossDatabaseForeignKey {
source_bucket,
source_table: m.table_name.to_string(),
source_column: f.name.to_string(),
target_bucket: target_bucket.clone(),
target_table,
});
}
}
}
for app in label_to_app.values() {
let bucket = BucketKey {
database: app.database.to_string(),
app: app.label.to_string(),
};
bucket_models.entry(bucket).or_default();
}
let mut enum_map: BTreeMap<&str, EnumSchema> = BTreeMap::new();
let mut enum_rust_type_for_pg: BTreeMap<&str, &str> = BTreeMap::new();
for e in enums {
insert_unique(
&mut enum_rust_type_for_pg,
e.postgres_type,
e.type_name,
|prev_rust, new_rust| {
Err(ProjectionError::DuplicateEnumPostgresType {
postgres_type: e.postgres_type.to_string(),
first_rust_type: (*prev_rust).to_string(),
second_rust_type: (*new_rust).to_string(),
})
},
)?;
enum_map.insert(
e.postgres_type,
EnumSchema {
name: e.postgres_type.to_string(),
variants: e.variants.iter().map(|v| v.to_string()).collect(),
},
);
}
let mut registered_apps: Vec<String> = label_to_app.keys().map(|k| k.to_string()).collect();
registered_apps.sort();
registered_apps.dedup();
let mut type_to_pk_sql: BTreeMap<&str, String> = BTreeMap::new();
let mut type_to_pk_family: BTreeMap<&str, StrictIdFamily> = BTreeMap::new();
for m in &models {
type_to_pk_sql.insert(m.type_name, pk_sql_type_text(&m.pk_type));
type_to_pk_family.insert(m.type_name, strict_id_family_of_pk(&m.pk_type));
}
let mut out: BTreeMap<BucketKey, AppliedSchema> = BTreeMap::new();
for (bucket, ms) in bucket_models {
let mut tables: BTreeMap<String, TableSchema> = BTreeMap::new();
let mut indexes: Vec<IndexSchema> = Vec::new();
for m in &ms {
if m.proxy_for.is_some() {
continue;
}
let projected = project_model(
m,
&type_to_table,
&type_to_pk_sql,
&type_to_pk_family,
&deferrability_by_field,
);
insert_unique(
&mut tables,
projected.table.clone(),
projected,
|existing, duplicate| {
Err(ProjectionError::DuplicateTableInBucket {
bucket: bucket.clone(),
table: duplicate.table.clone(),
first_type: existing.table.clone(),
second_type: m.type_name.to_string(),
})
},
)?;
if m.has_outbox {
let outbox = project_outbox_table(m, &type_to_pk_sql);
insert_unique(
&mut tables,
outbox.table.clone(),
outbox,
|existing, duplicate| {
Err(ProjectionError::DuplicateTableInBucket {
bucket: bucket.clone(),
table: duplicate.table.clone(),
first_type: existing.table.clone(),
second_type: format!("{}::outbox", m.type_name),
})
},
)?;
indexes.push(project_outbox_pending_index(m.table_name));
}
for idx in m.indexes {
indexes.push(project_index(idx, m.table_name));
}
if let Some(fts) = &m.fts {
indexes.push(project_fts_index(m.table_name, fts));
}
for f in m.fields {
if f.indexed {
let synthetic = project_field_level_index(f.name, f.index_type, m.table_name);
if indexes
.iter()
.any(|e| e.table == synthetic.table && e.name == synthetic.name)
{
continue;
}
indexes.push(synthetic);
}
}
}
indexes.sort_by(|a, b| {
(a.table.as_str(), a.name.as_str()).cmp(&(b.table.as_str(), b.name.as_str()))
});
let bucket_enums: BTreeMap<String, EnumSchema> = enum_map
.iter()
.filter(|(name, _)| {
tables.values().any(|t| {
t.columns
.iter()
.any(|c| sql_type_references_enum(&c.sql_type, name))
})
})
.map(|(k, v)| ((*k).to_string(), v.clone()))
.collect();
let schema = AppliedSchema {
djogi_version: env!("CARGO_PKG_VERSION").to_string(),
enums: bucket_enums,
format_version: SNAPSHOT_FORMAT_VERSION.to_string(),
generated_at: generated_at.clone(),
indexes,
models: tables,
registered_apps: registered_apps.clone(),
};
out.insert(bucket, schema);
}
Ok(out)
}
fn sql_type_references_enum(sql_type: &str, enum_name: &str) -> bool {
sql_type == enum_name
}
pub(crate) fn rfc3339_now_seconds() -> String {
use time::OffsetDateTime;
let now = OffsetDateTime::now_utc();
let secs = now.unix_timestamp();
let trimmed = OffsetDateTime::from_unix_timestamp(secs).unwrap_or(now);
let format = time::format_description::well_known::Rfc3339;
trimmed
.format(&format)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
}
fn project_model(
m: &ModelDescriptor,
type_to_table: &BTreeMap<&str, &str>,
type_to_pk_sql: &BTreeMap<&str, String>,
type_to_pk_family: &BTreeMap<&str, StrictIdFamily>,
deferrability_by_field: &BTreeMap<(&str, &str), (bool, bool)>,
) -> TableSchema {
let mut columns: Vec<ColumnSchema> = m
.fields
.iter()
.map(|f| {
project_column(
f,
m,
type_to_table,
type_to_pk_sql,
type_to_pk_family,
deferrability_by_field,
)
})
.collect();
if let Some(fts) = &m.fts {
columns.push(project_fts_column(fts));
}
let primary_key = project_primary_key(&m.pk_type);
let mut exclusion_constraints: Vec<ExclusionConstraintSchema> = m
.exclusion_constraints
.iter()
.map(project_exclusion_constraint)
.collect();
exclusion_constraints.sort_by(|a, b| a.name.cmp(&b.name));
TableSchema {
app: m.app.map(|s| s.to_string()),
columns,
exclusion_constraints,
fts: m.fts.as_ref().map(project_fts),
is_through: m.is_through,
moved_from_app: m.moved_from_app.map(|s| s.to_string()),
partition: m.partition_by.as_ref().map(project_partition),
primary_key,
rationale: m.rationale.map(|s| s.to_string()),
renamed_from: m.renamed_from.map(|s| s.to_string()),
rls_enabled: m.tenant_key.is_some(),
table: m.table_name.to_string(),
table_comment: m.table_comment.map(|s| s.to_string()),
storage_params: m.storage_params.map(|s| s.to_string()),
tablespace: m.tablespace.map(|s| s.to_string()),
tenant_key: m.tenant_key.map(|s| s.to_string()),
}
}
fn project_fts_column(fts: &FtsDescriptor) -> ColumnSchema {
ColumnSchema {
check: None,
codec: None,
comment: None,
default_sql: None,
foreign_key: None,
generated: Some(GeneratedColumnSchema {
expression: fts_generated_expression(fts),
stored: true,
}),
identity: None,
index_type: None,
indexed: false,
max_length: None,
name: fts.column.to_string(),
nullable: true,
on_delete: None,
outbox_exclude: false,
rationale: None,
relation_kind: None,
renamed_from: None,
sequence_within: None,
sql_type: "TSVECTOR".to_string(),
unique: false,
type_change_using: None,
}
}
fn fts_generated_expression(fts: &FtsDescriptor) -> String {
let sources = crate::fts::parse_source_columns(fts.source)
.expect("macro-emitted FtsDescriptor source columns are validated");
let source_expr = sources
.iter()
.map(|column| quote_ident_expr(column))
.collect::<Vec<_>>()
.join(" || ' ' || ");
format!("to_tsvector('{}', {source_expr})", fts.dictionary)
}
fn quote_ident_expr(name: &str) -> String {
let mut out = String::with_capacity(name.len() + 2);
out.push('"');
for b in name.as_bytes() {
if *b == b'"' {
out.push('"');
out.push('"');
} else {
out.push(*b as char);
}
}
out.push('"');
out
}
fn project_outbox_table(
m: &ModelDescriptor,
type_to_pk_sql: &BTreeMap<&str, String>,
) -> TableSchema {
let row_id_sql_type = type_to_pk_sql
.get(m.type_name)
.cloned()
.unwrap_or_else(|| pk_sql_type_text(&m.pk_type));
let table = crate::migrate::naming::outbox_table_name(m.table_name);
TableSchema {
app: m.app.map(|s| s.to_string()),
columns: vec![
outbox_column("id", "BIGINT", Some("heerid_next()")),
outbox_column("row_id", &row_id_sql_type, None),
outbox_column("action", "TEXT", None)
.with_check("action IN ('create', 'save', 'delete')"),
outbox_column("payload", "JSONB", None),
outbox_column("created_at", "TIMESTAMPTZ", Some("now()")),
outbox_column("state", "TEXT", Some("'pending'"))
.with_check("state IN ('pending', 'processing', 'published', 'failed')"),
outbox_column("leased_until", "TIMESTAMPTZ", None).nullable(),
outbox_column("retry_count", "INTEGER", Some("0")),
outbox_column("failed_reason", "TEXT", None).nullable(),
],
exclusion_constraints: Vec::new(),
fts: None,
is_through: false,
moved_from_app: None,
partition: None,
primary_key: PrimaryKeySchema {
columns: vec!["id".to_string()],
kind: PkKindSchema::HeerId,
},
rationale: None,
renamed_from: None,
rls_enabled: false,
table,
table_comment: None,
storage_params: None,
tablespace: None,
tenant_key: None,
}
}
fn project_outbox_pending_index(table: &str) -> IndexSchema {
let outbox_table = crate::migrate::naming::outbox_table_name(table);
IndexSchema {
extension_dependency: None,
include: Vec::new(),
index_type: IndexTypeSchema::BTree,
kind: IndexKindSchema::NonUnique,
name: outbox_pending_index_name(table),
nulls_not_distinct: false,
predicate: Some("state = 'pending'".to_string()),
requires_out_of_transaction: false,
table: outbox_table,
target: IndexTargetSchema::Columns(vec![
IndexColumnSchema {
name: "state".to_string(),
nulls: IndexNullsOrderSchema::Default,
opclass: None,
order: IndexOrderSchema::Asc,
},
IndexColumnSchema {
name: "created_at".to_string(),
nulls: IndexNullsOrderSchema::Default,
opclass: None,
order: IndexOrderSchema::Asc,
},
]),
}
}
fn outbox_pending_index_name(table: &str) -> String {
let full = format!("{table}_outbox_pending_idx");
if full.len() <= 63 {
return full;
}
use std::hash::{BuildHasher, BuildHasherDefault, Hasher};
let mut h =
BuildHasherDefault::<std::collections::hash_map::DefaultHasher>::default().build_hasher();
h.write(full.as_bytes());
let digest = format!("{:08x}", h.finish() as u32);
let stem: String = full.as_bytes()[..54].iter().map(|b| *b as char).collect();
format!("{stem}_{digest}")
}
fn outbox_column(name: &str, sql_type: &str, default_sql: Option<&str>) -> ColumnSchema {
ColumnSchema {
check: None,
codec: None,
comment: None,
default_sql: default_sql.map(str::to_string),
foreign_key: None,
generated: None,
identity: None,
index_type: None,
indexed: false,
max_length: None,
name: name.to_string(),
nullable: false,
on_delete: None,
outbox_exclude: false,
rationale: None,
relation_kind: None,
renamed_from: None,
sequence_within: None,
sql_type: sql_type.to_string(),
unique: false,
type_change_using: None,
}
}
trait OutboxColumnExt {
fn nullable(self) -> Self;
fn with_check(self, check: &str) -> Self;
}
impl OutboxColumnExt for ColumnSchema {
fn nullable(mut self) -> Self {
self.nullable = true;
self
}
fn with_check(mut self, check: &str) -> Self {
self.check = Some(check.to_string());
self
}
}
fn project_exclusion_constraint(spec: &ExclusionConstraintSpec) -> ExclusionConstraintSchema {
ExclusionConstraintSchema {
name: spec.name.to_string(),
using: spec.using.to_string(),
elements: spec
.elements
.iter()
.map(project_exclusion_element)
.collect(),
where_clause: spec.where_clause.map(|s| s.to_string()),
deferrable: spec.deferrable,
initially_deferred: spec.initially_deferred,
extension_dependency: spec.extension_dependency.map(|s| s.to_string()),
}
}
fn project_exclusion_element(elem: &ExclusionElement) -> ExclusionElementSchema {
ExclusionElementSchema {
expr: elem.expr.to_string(),
with_operator: elem.with_operator.to_string(),
}
}
fn project_generated_column(spec: &GeneratedColumnSpec) -> GeneratedColumnSchema {
GeneratedColumnSchema {
expression: spec.expression.to_string(),
stored: spec.stored,
}
}
fn field_type_check(
sql_type: &crate::descriptor::FieldSqlType,
rust_source_type: Option<RustSourceType>,
column_name: &str,
) -> Option<String> {
use crate::descriptor::{FieldSqlType, RangeSubtypeKind};
let qcol = quote_ident_for_check(column_name);
match sql_type {
FieldSqlType::Date => Some(date_range_expr(&qcol)),
FieldSqlType::Timestamptz => {
Some(timestamptz_range_expr(&qcol))
}
FieldSqlType::SmallInt => match rust_source_type {
Some(RustSourceType::I8) => Some(format!("{qcol} >= -128 AND {qcol} <= 127")),
Some(RustSourceType::U8) => Some(format!("{qcol} >= 0 AND {qcol} <= 255")),
_ => None,
},
FieldSqlType::Integer => match rust_source_type {
Some(RustSourceType::U16) => Some(format!("{qcol} >= 0 AND {qcol} <= 65535")),
_ => None,
},
FieldSqlType::BigInt => match rust_source_type {
Some(RustSourceType::U32) => Some(format!("{qcol} >= 0 AND {qcol} <= 4294967295")),
_ => None,
},
FieldSqlType::Numeric => match rust_source_type {
Some(RustSourceType::U64) => Some(format!(
"{qcol} >= 0 AND {qcol} <= 18446744073709551615 AND {qcol} = trunc({qcol})"
)),
Some(RustSourceType::Decimal) => Some(format!(
"({qcol}) IS NULL OR ({})",
decimal_repr_expr(&qcol)
)),
_ => None,
},
FieldSqlType::TimestamptzArray => Some(tstz_array_is_finite_check(&qcol)),
FieldSqlType::DateArray => Some(date_array_is_finite_check(&qcol)),
FieldSqlType::NumericArray => Some(numeric_array_is_rust_decimal_check(&qcol)),
FieldSqlType::Range { subtype } => match subtype {
RangeSubtypeKind::Int4 | RangeSubtypeKind::Int8 => None,
RangeSubtypeKind::Num => Some(range_endpoint_checks(&qcol, decimal_repr_expr)),
RangeSubtypeKind::Ts => Some(range_endpoint_checks(&qcol, timestamp_range_expr)),
RangeSubtypeKind::Tstz => Some(range_endpoint_checks(&qcol, timestamptz_range_expr)),
RangeSubtypeKind::Date => Some(range_endpoint_checks(&qcol, date_range_expr)),
},
_ => None,
}
}
fn date_range_expr(column_expr: &str) -> String {
format!("pg_catalog.isfinite({column_expr}) AND {column_expr} <= DATE '9999-12-31'")
}
fn timestamptz_range_expr(column_expr: &str) -> String {
format!(
"pg_catalog.isfinite({column_expr}) AND \
{column_expr} <= TIMESTAMPTZ '9999-12-31 23:59:59.999999+00'"
)
}
fn timestamp_range_expr(column_expr: &str) -> String {
format!(
"pg_catalog.isfinite({column_expr}) AND \
{column_expr} <= TIMESTAMP '9999-12-31 23:59:59.999999'"
)
}
fn decimal_repr_expr(column_expr: &str) -> String {
format!(
"scale({column_expr}) IS NOT NULL AND \
scale({column_expr}) <= 28 AND \
abs({column_expr}) * power(10::numeric, scale({column_expr})) <= 79228162514264337593543950335"
)
}
fn range_endpoint_checks(range_column: &str, bound_check: fn(&str) -> String) -> String {
let lower_endpoint = format!("lower({range_column})");
let upper_endpoint = format!("upper({range_column})");
let lower = format!(
"{lower_endpoint} IS NULL OR ({})",
bound_check(&lower_endpoint)
);
let upper = format!(
"{upper_endpoint} IS NULL OR ({})",
bound_check(&upper_endpoint)
);
let lower = format!("({lower})");
let upper = format!("({upper})");
format!("{lower} AND {upper}")
}
fn date_array_is_finite_check(array_column: &str) -> String {
format!("{array_column} IS NULL OR djogi.__djogi_date_array_is_finite_v1({array_column})")
}
fn tstz_array_is_finite_check(array_column: &str) -> String {
format!("{array_column} IS NULL OR djogi.__djogi_tstz_array_is_finite_v1({array_column})")
}
fn numeric_array_is_rust_decimal_check(array_column: &str) -> String {
format!(
"{array_column} IS NULL OR djogi.__djogi_numeric_array_is_rust_decimal_v1({array_column})"
)
}
fn combine_check_expressions(
type_derived: Option<String>,
adopter: Option<&str>,
) -> Option<String> {
match (type_derived, adopter) {
(None, None) => None,
(Some(t), None) => Some(t),
(None, Some(a)) => Some(a.trim().to_string()),
(Some(t), Some(a)) => Some(format!("({}) AND ({})", t.trim(), a.trim())),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StrictIdFamily {
HeerId,
RanjId,
None,
}
fn strict_id_family_of_pk(pk: &PkType) -> StrictIdFamily {
match pk {
PkType::HeerId | PkType::HeerIdDesc => StrictIdFamily::HeerId,
PkType::RanjId | PkType::RanjIdDesc => StrictIdFamily::RanjId,
PkType::Serial | PkType::None | PkType::Composite(_) | PkType::Custom(_) => {
StrictIdFamily::None
}
}
}
fn strict_id_check_expr(family: StrictIdFamily, column_name: &str) -> Option<String> {
let qcol = quote_ident_for_check(column_name);
match family {
StrictIdFamily::HeerId => Some(format!("{qcol} >= 0")),
StrictIdFamily::RanjId => Some(format!(
"pg_catalog.substring({qcol}::text, 15, 1) = '8' AND \
pg_catalog.substring({qcol}::text, 20, 1) IN ('8','9','a','b')"
)),
StrictIdFamily::None => None,
}
}
fn quote_ident_for_check(name: &str) -> String {
let mut out = String::with_capacity(name.len() + 2);
out.push('"');
for byte in name.bytes() {
if byte == b'"' {
out.push('"');
out.push('"');
} else {
out.push(byte as char);
}
}
out.push('"');
out
}
fn project_column(
f: &FieldDescriptor,
parent: &ModelDescriptor,
type_to_table: &BTreeMap<&str, &str>,
type_to_pk_sql: &BTreeMap<&str, String>,
type_to_pk_family: &BTreeMap<&str, StrictIdFamily>,
deferrability_by_field: &BTreeMap<(&str, &str), (bool, bool)>,
) -> ColumnSchema {
let projected_on_delete = if f.relation_kind.is_some() {
Some(project_on_delete(f.on_delete.unwrap_or(OnDelete::Restrict)))
} else {
None
};
let foreign_key = if f.relation_kind.is_some() {
f.target_type_name.map(|target| {
let (deferrable, initially_deferred) = deferrability_by_field
.get(&(parent.type_name, f.name))
.copied()
.unwrap_or((false, false));
let ref_table = type_to_table
.get(target)
.copied()
.unwrap_or(target)
.to_string();
ForeignKeySchema {
on_delete: projected_on_delete.unwrap_or(OnDeleteSchema::Restrict),
ref_column: "id".to_string(),
ref_table,
deferrable,
initially_deferred,
}
})
} else {
None
};
let default_sql = if f.name == "id" {
pk_default_sql(&parent.pk_type)
} else if (f.name == "created_at" || f.name == "updated_at")
&& matches!(f.sql_type, crate::descriptor::FieldSqlType::Timestamptz)
{
Some("now()".to_string())
} else {
None
};
let sql_type = if f.relation_kind.is_some()
&& let Some(target) = f.target_type_name
&& let Some(pk_sql) = type_to_pk_sql.get(target)
{
pk_sql.clone()
} else {
f.sql_type.to_string()
};
let identity = if f.name == "id" && matches!(parent.pk_type, PkType::Serial) {
Some(crate::migrate::schema::IdentityKindSchema::ByDefault)
} else {
None
};
let type_derived_check: Option<String> = if foreign_key.is_some() {
None
} else {
field_type_check(&f.sql_type, f.rust_source_type, f.name)
};
let strict_id_check_clause: Option<String> = if f.strict_id_check {
let family = if f.relation_kind.is_some() {
f.target_type_name
.and_then(|target| type_to_pk_family.get(target).copied())
.unwrap_or(StrictIdFamily::None)
} else if f.name == "id" {
strict_id_family_of_pk(&parent.pk_type)
} else {
match f.sql_type {
crate::descriptor::FieldSqlType::BigInt => StrictIdFamily::HeerId,
crate::descriptor::FieldSqlType::Uuid => StrictIdFamily::RanjId,
_ => StrictIdFamily::None,
}
};
strict_id_check_expr(family, f.name)
} else {
None
};
let check: Option<String> = combine_check_expressions(
combine_check_expressions(type_derived_check, strict_id_check_clause.as_deref()),
f.check_sql,
);
ColumnSchema {
check,
codec: f
.protected
.as_ref()
.and_then(|p| p.codec)
.map(|s| s.to_string()),
comment: f.comment.map(|s| s.to_string()),
default_sql,
foreign_key,
generated: f.generated.as_ref().map(project_generated_column),
identity,
index_type: f.index_type.map(project_index_type),
indexed: f.indexed,
max_length: f.max_length,
name: f.name.to_string(),
nullable: f.nullable,
on_delete: projected_on_delete,
outbox_exclude: f.outbox_exclude,
rationale: f.rationale.map(|s| s.to_string()),
relation_kind: f.relation_kind.map(project_relation_kind),
renamed_from: f.renamed_from.map(|s| s.to_string()),
sequence_within: f.sequence_within.map(|s| s.to_string()),
sql_type,
unique: f.unique,
type_change_using: f.type_change_using.map(|s| s.to_string()),
}
}
fn pk_sql_type_text(pk: &PkType) -> String {
match pk {
PkType::HeerId | PkType::HeerIdDesc => "BIGINT".to_string(),
PkType::RanjId | PkType::RanjIdDesc => "UUID".to_string(),
PkType::Serial => "INTEGER".to_string(),
PkType::Custom(c) => c.sql_type.to_string(),
PkType::Composite(_) | PkType::None => "TEXT".to_string(),
}
}
fn project_primary_key(pk: &PkType) -> PrimaryKeySchema {
match pk {
PkType::HeerId => PrimaryKeySchema {
columns: vec!["id".to_string()],
kind: PkKindSchema::HeerId,
},
PkType::HeerIdDesc => PrimaryKeySchema {
columns: vec!["id".to_string()],
kind: PkKindSchema::HeerIdRecencyBiased,
},
PkType::RanjId => PrimaryKeySchema {
columns: vec!["id".to_string()],
kind: PkKindSchema::RanjId,
},
PkType::RanjIdDesc => PrimaryKeySchema {
columns: vec!["id".to_string()],
kind: PkKindSchema::RanjIdRecencyBiased,
},
PkType::Serial => PrimaryKeySchema {
columns: vec!["id".to_string()],
kind: PkKindSchema::Serial,
},
PkType::None => PrimaryKeySchema {
columns: Vec::new(),
kind: PkKindSchema::None,
},
PkType::Composite(cols) => PrimaryKeySchema {
columns: cols.iter().map(|c| c.to_string()).collect(),
kind: PkKindSchema::Composite,
},
PkType::Custom(c) => PrimaryKeySchema {
columns: vec!["id".to_string()],
kind: PkKindSchema::Custom(CustomPkKindSchema {
default_sql: c.default_sql.to_string(),
sql_type: c.sql_type.to_string(),
type_name: c.type_name.to_string(),
}),
},
}
}
pub(crate) fn pk_default_sql(pk: &PkType) -> Option<String> {
match pk {
PkType::HeerId => Some("heerid_next()".to_string()),
PkType::HeerIdDesc => Some("heerid_next_desc()".to_string()),
PkType::RanjId => Some("ranjid_next()".to_string()),
PkType::RanjIdDesc => Some("ranjid_next_desc()".to_string()),
PkType::Serial => None,
PkType::None => None,
PkType::Composite(_) => None,
PkType::Custom(c) if c.default_sql.is_empty() => None,
PkType::Custom(c) => Some(c.default_sql.to_string()),
}
}
fn project_fts(f: &FtsDescriptor) -> FtsSchema {
FtsSchema {
column: f.column.to_string(),
dictionary: f.dictionary.to_string(),
source: f.source.to_string(),
}
}
fn project_fts_index(table: &str, fts: &FtsDescriptor) -> IndexSchema {
IndexSchema {
extension_dependency: None,
include: Vec::new(),
index_type: IndexTypeSchema::Gin,
kind: IndexKindSchema::NonUnique,
name: fts_index_name(table, fts.column),
nulls_not_distinct: false,
predicate: None,
requires_out_of_transaction: false,
table: table.to_string(),
target: IndexTargetSchema::Columns(vec![IndexColumnSchema {
name: fts.column.to_string(),
nulls: IndexNullsOrderSchema::Default,
opclass: None,
order: IndexOrderSchema::Asc,
}]),
}
}
fn project_field_level_index(
col_name: &str,
index_type: Option<IndexType>,
table: &str,
) -> IndexSchema {
let name = crate::descriptor::index_name(
table,
crate::descriptor::IndexNameKind::NonUnique,
crate::descriptor::IndexNameTarget::Columns(&[col_name]),
);
IndexSchema {
extension_dependency: None,
include: Vec::new(),
index_type: index_type
.map(project_index_type)
.unwrap_or(IndexTypeSchema::BTree),
kind: IndexKindSchema::NonUnique,
name,
nulls_not_distinct: false,
predicate: None,
requires_out_of_transaction: false,
table: table.to_string(),
target: IndexTargetSchema::Columns(vec![IndexColumnSchema {
name: col_name.to_string(),
nulls: IndexNullsOrderSchema::Default,
opclass: None,
order: IndexOrderSchema::Asc,
}]),
}
}
fn fts_index_name(table: &str, column: &str) -> String {
let full = format!("{table}_{column}_gin");
if full.len() <= 63 {
return full;
}
use std::hash::{BuildHasher, BuildHasherDefault, Hasher};
let mut h =
BuildHasherDefault::<std::collections::hash_map::DefaultHasher>::default().build_hasher();
h.write(full.as_bytes());
let digest = format!("{:08x}", h.finish() as u32);
let stem: String = full.as_bytes()[..54].iter().map(|b| *b as char).collect();
format!("{stem}_{digest}")
}
fn project_partition(p: &PartitionSpec) -> PartitionSchema {
match p {
PartitionSpec::Range { column } => PartitionSchema::Range {
column: column.to_string(),
},
PartitionSpec::Hash { column, partitions } => PartitionSchema::Hash {
column: column.to_string(),
partitions: *partitions,
},
}
}
fn project_index(idx: &IndexSpec, table: &str) -> IndexSchema {
debug_assert!(
!matches!(
idx.kind,
IndexKind::UniqueConstraint | IndexKind::UniqueIndex
) || matches!(idx.index_type, IndexType::BTree),
"project_index: PostgreSQL unique indexes are btree-only; \
IndexSpec {name:?} on table {table:?} carries kind {kind:?} \
with non-btree index_type {ty:?} (djogi#83).",
name = idx.name,
table = table,
kind = idx.kind,
ty = idx.index_type,
);
IndexSchema {
extension_dependency: idx.extension_dependency.map(|s| s.to_string()),
include: idx.include.iter().map(|s| s.to_string()).collect(),
index_type: project_index_type(idx.index_type),
kind: project_index_kind(idx.kind),
name: idx.name.to_string(),
nulls_not_distinct: idx.nulls_not_distinct,
predicate: idx.predicate.map(|s| s.to_string()),
requires_out_of_transaction: idx.requires_out_of_transaction,
table: table.to_string(),
target: project_index_target(&idx.target),
}
}
fn project_index_type(t: IndexType) -> IndexTypeSchema {
match t {
IndexType::BTree => IndexTypeSchema::BTree,
IndexType::Gin => IndexTypeSchema::Gin,
IndexType::Gist => IndexTypeSchema::Gist,
IndexType::Hash => IndexTypeSchema::Hash,
IndexType::Spgist => IndexTypeSchema::Spgist,
IndexType::Brin => IndexTypeSchema::Brin,
}
}
fn project_index_kind(k: IndexKind) -> IndexKindSchema {
match k {
IndexKind::NonUnique => IndexKindSchema::NonUnique,
IndexKind::UniqueConstraint => IndexKindSchema::UniqueConstraint,
IndexKind::UniqueIndex => IndexKindSchema::UniqueIndex,
}
}
fn project_index_target(t: &IndexTarget) -> IndexTargetSchema {
match t {
IndexTarget::Columns(cols) => IndexTargetSchema::Columns(
cols.iter()
.map(|c| IndexColumnSchema {
name: c.name.to_string(),
nulls: project_index_nulls(c.nulls),
opclass: c.opclass.map(|s| s.to_string()),
order: project_index_order(c.order),
})
.collect(),
),
IndexTarget::Expression(expr) => IndexTargetSchema::Expression(expr.to_string()),
}
}
fn project_index_order(o: IndexOrder) -> IndexOrderSchema {
match o {
IndexOrder::Asc => IndexOrderSchema::Asc,
IndexOrder::Desc => IndexOrderSchema::Desc,
}
}
fn project_index_nulls(n: IndexNullsOrder) -> IndexNullsOrderSchema {
match n {
IndexNullsOrder::Default => IndexNullsOrderSchema::Default,
IndexNullsOrder::First => IndexNullsOrderSchema::First,
IndexNullsOrder::Last => IndexNullsOrderSchema::Last,
}
}
fn project_on_delete(o: OnDelete) -> OnDeleteSchema {
match o {
OnDelete::Cascade => OnDeleteSchema::Cascade,
OnDelete::Restrict => OnDeleteSchema::Restrict,
OnDelete::SetNull => OnDeleteSchema::SetNull,
OnDelete::SetDefault => OnDeleteSchema::SetDefault,
OnDelete::Protect => OnDeleteSchema::Restrict,
OnDelete::DoNothing => OnDeleteSchema::NoAction,
}
}
fn project_relation_kind(k: RelationKind) -> RelationKindSchema {
match k {
RelationKind::ForeignKey => RelationKindSchema::ForeignKey,
RelationKind::OneToOne => RelationKindSchema::OneToOne,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::apps::AppDescriptor;
use crate::descriptor::{
EnumDescriptor, FieldDescriptor, FieldSqlType, IndexColumnSpec, IndexKind, IndexSpec,
IndexTarget, IndexType, ModelDescriptor, PkType, RangeSubtypeKind, field_descriptor,
model_descriptor,
};
use crate::relation::registry::{
RelationKind as RegistryRelationKind, RelationRegistryError, ReverseRelationMarker,
validate_relation_accessor_collisions,
};
fn synth_collision_marker(
kind: RegistryRelationKind,
source: &'static str,
name: &'static str,
target: &'static str,
via: &'static str,
) -> ReverseRelationMarker {
crate::relation::registry::__macro_support::__make_reverse_relation_marker(
kind, source, name, target, via,
)
}
fn synth_model(table: &'static str, type_name: &'static str) -> ModelDescriptor {
ModelDescriptor {
..model_descriptor(type_name, table, PkType::HeerIdDesc, &[])
}
}
fn synth_app(label: &'static str, database: &'static str) -> AppDescriptor {
AppDescriptor {
label,
database,
renamed_from: None,
tombstone: false,
}
}
fn empty_global() -> BucketKey {
BucketKey {
database: "main".to_string(),
app: "".to_string(),
}
}
#[test]
fn empty_inventory_yields_only_synthetic_global_bucket() {
let buckets = project_from_iters(
std::iter::empty::<&ModelDescriptor>(),
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
assert_eq!(buckets.len(), 1);
let global = buckets.get(&empty_global()).expect("global bucket present");
assert!(global.models.is_empty());
assert!(global.indexes.is_empty());
assert_eq!(global.registered_apps, vec!["".to_string()]);
}
#[test]
fn models_without_app_land_in_global_bucket() {
let m = synth_model("widgets", "Widget");
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let global = buckets.get(&empty_global()).expect("global");
assert_eq!(global.models.len(), 1);
assert!(global.models.contains_key("widgets"));
}
#[test]
fn models_with_app_land_in_app_bucket() {
let billing = synth_app("billing", "main");
let m = ModelDescriptor {
app: Some("billing"),
..synth_model("invoices", "Invoice")
};
let buckets = project_from_iters([&m], [], [&billing], "2026-04-25T00:00:00Z".to_string())
.expect("ok");
let billing_bucket = BucketKey {
database: "main".to_string(),
app: "billing".to_string(),
};
let bb = buckets
.get(&billing_bucket)
.expect("billing bucket present");
assert_eq!(bb.models.len(), 1);
let global = buckets.get(&empty_global()).expect("global still present");
assert!(global.models.is_empty());
}
#[test]
fn proxy_descriptors_skipped_from_projection() {
let parent = synth_model("vehicles", "Vehicle");
let active = ModelDescriptor {
proxy_for: Some("Vehicle"),
default_filter_sql: Some("active = TRUE"),
..synth_model("vehicles", "ActiveVehicle")
};
let archived = ModelDescriptor {
proxy_for: Some("Vehicle"),
default_filter_sql: Some("archived = TRUE"),
..synth_model("vehicles", "ArchivedVehicle")
};
let buckets = project_from_iters(
[&parent, &active, &archived],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("proxies coexist with parent without DDL collisions");
let global = buckets.get(&empty_global()).expect("global");
assert_eq!(
global.models.len(),
1,
"expected parent-only projection, got {} entries",
global.models.len(),
);
assert!(global.models.contains_key("vehicles"));
}
#[test]
fn proxy_skip_independent_of_iteration_order() {
let parent = synth_model("vehicles", "Vehicle");
let proxy = ModelDescriptor {
proxy_for: Some("Vehicle"),
default_filter_sql: Some("active = TRUE"),
..synth_model("vehicles", "ActiveVehicle")
};
let buckets = project_from_iters(
[&proxy, &parent],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let global = buckets.get(&empty_global()).expect("global");
assert_eq!(global.models.len(), 1);
assert!(global.models.contains_key("vehicles"));
}
#[test]
fn separate_databases_yield_separate_buckets() {
let crud = synth_app("crud_log_app", "crud_log");
let m_main = synth_model("widgets", "Widget");
let m_crud = ModelDescriptor {
app: Some("crud_log_app"),
..synth_model("audit_rows", "AuditRow")
};
let buckets = project_from_iters(
[&m_main, &m_crud],
[],
[&crud],
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
assert_eq!(
buckets
.keys()
.map(|b| (b.database.as_str(), b.app.as_str()))
.collect::<Vec<_>>(),
vec![("crud_log", "crud_log_app"), ("main", "")]
);
}
#[test]
fn duplicate_type_name_errors() {
let a = synth_model("widgets_a", "Widget");
let b = synth_model("widgets_b", "Widget");
let err = project_from_iters(
[&a, &b],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect_err("must reject");
match err {
ProjectionError::DuplicateModelTypeName { type_name, .. } => {
assert_eq!(type_name, "Widget");
}
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn duplicate_table_in_bucket_errors() {
let a = synth_model("widgets", "WidgetA");
let b = synth_model("widgets", "WidgetB");
let err = project_from_iters(
[&a, &b],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect_err("must reject");
match err {
ProjectionError::DuplicateTableInBucket { table, .. } => {
assert_eq!(table, "widgets");
}
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn same_table_name_in_different_buckets_is_fine() {
let billing = synth_app("billing", "main");
let users = synth_app("users", "main");
let m_billing = ModelDescriptor {
app: Some("billing"),
..synth_model("settings", "BillingSettings")
};
let m_users = ModelDescriptor {
app: Some("users"),
..synth_model("settings", "UserSettings")
};
let buckets = project_from_iters(
[&m_billing, &m_users],
[],
[&billing, &users],
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok — distinct buckets so `settings` is unique within each");
let bk_billing = BucketKey {
database: "main".to_string(),
app: "billing".to_string(),
};
let bk_users = BucketKey {
database: "main".to_string(),
app: "users".to_string(),
};
assert!(buckets[&bk_billing].models.contains_key("settings"));
assert!(buckets[&bk_users].models.contains_key("settings"));
}
#[test]
fn duplicate_enum_postgres_type_errors() {
let e1 = EnumDescriptor {
type_name: "VehicleStatus",
postgres_type: "vehicle_status",
variants: &["active"],
};
let e2 = EnumDescriptor {
type_name: "OtherEnum",
postgres_type: "vehicle_status",
variants: &["pending"],
};
let err = project_from_iters(
std::iter::empty::<&ModelDescriptor>(),
[&e1, &e2],
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect_err("must reject");
match err {
ProjectionError::DuplicateEnumPostgresType { postgres_type, .. } => {
assert_eq!(postgres_type, "vehicle_status");
}
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn unknown_app_label_errors() {
let m = ModelDescriptor {
app: Some("nonexistent"),
..synth_model("widgets", "Widget")
};
let err = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect_err("must reject");
match err {
ProjectionError::UnknownAppLabel { app_label, .. } => {
assert_eq!(app_label, "nonexistent");
}
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn fk_target_resolves_to_table_name() {
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
indexed: true,
relation_kind: Some(RelationKind::ForeignKey),
on_delete: Some(OnDelete::Restrict),
target_type_name: Some("Owner"),
..field_descriptor("owner_id", FieldSqlType::BigInt, false)
}];
let owner = synth_model("owners", "Owner");
let vehicle = ModelDescriptor {
fields: FIELDS,
..synth_model("vehicles", "Vehicle")
};
let buckets = project_from_iters(
[&owner, &vehicle],
[],
[],
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let global = &buckets[&empty_global()];
let owner_id = &global.models["vehicles"].columns[0];
let fk = owner_id.foreign_key.as_ref().expect("fk present");
assert_eq!(fk.ref_table, "owners");
}
const fn fk_field_descriptor(on_delete: OnDelete) -> FieldDescriptor {
FieldDescriptor {
indexed: true,
relation_kind: Some(RelationKind::ForeignKey),
on_delete: Some(on_delete),
target_type_name: Some("Owner"),
..field_descriptor("owner_id", FieldSqlType::BigInt, false)
}
}
#[test]
fn fk_cascade_round_trips_through_foreign_key_schema() {
const RESTRICT: &[FieldDescriptor] = &[fk_field_descriptor(OnDelete::Restrict)];
const CASCADE: &[FieldDescriptor] = &[fk_field_descriptor(OnDelete::Cascade)];
const SET_NULL: &[FieldDescriptor] = &[fk_field_descriptor(OnDelete::SetNull)];
const SET_DEFAULT: &[FieldDescriptor] = &[fk_field_descriptor(OnDelete::SetDefault)];
const DO_NOTHING: &[FieldDescriptor] = &[fk_field_descriptor(OnDelete::DoNothing)];
const PROTECT: &[FieldDescriptor] = &[fk_field_descriptor(OnDelete::Protect)];
let owner = synth_model("owners", "Owner");
for (slice, expected_label, expected) in [
(RESTRICT, "Restrict", OnDeleteSchema::Restrict),
(CASCADE, "Cascade", OnDeleteSchema::Cascade),
(SET_NULL, "SetNull", OnDeleteSchema::SetNull),
(SET_DEFAULT, "SetDefault", OnDeleteSchema::SetDefault),
(
DO_NOTHING,
"DoNothing -> NoAction",
OnDeleteSchema::NoAction,
),
(PROTECT, "Protect -> Restrict", OnDeleteSchema::Restrict),
] {
let vehicle = ModelDescriptor {
fields: slice,
..synth_model("vehicles", "Vehicle")
};
let buckets = project_from_iters(
[&owner, &vehicle],
[],
[],
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let owner_id = &buckets[&empty_global()].models["vehicles"].columns[0];
let fk = owner_id.foreign_key.as_ref().expect("fk");
assert_eq!(
fk.on_delete, expected,
"{expected_label} must project to {expected:?}; got {:?}",
fk.on_delete
);
assert_eq!(owner_id.on_delete, Some(expected));
}
}
#[test]
fn fk_deferrability_round_trips_through_foreign_key_schema() {
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
indexed: true,
relation_kind: Some(RelationKind::ForeignKey),
on_delete: Some(OnDelete::Restrict),
target_type_name: Some("Owner"),
..field_descriptor("owner_id", FieldSqlType::BigInt, false)
}];
let owner = synth_model("owners", "Owner");
let vehicle = ModelDescriptor {
fields: FIELDS,
..synth_model("vehicles", "Vehicle")
};
static DEFERRABILITY: &[DeferrabilitySpec] = &[DeferrabilitySpec {
model_type_name: "Vehicle",
field_name: "owner_id",
deferrable: true,
initially_deferred: true,
}];
let buckets = project_from_iters_with_deferrability(
[&owner, &vehicle],
[],
[],
DEFERRABILITY,
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let fk = buckets[&empty_global()].models["vehicles"].columns[0]
.foreign_key
.as_ref()
.expect("fk");
assert!(fk.deferrable);
assert!(fk.initially_deferred);
}
#[test]
fn cross_bucket_fk_resolves_via_global_type_lookup() {
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
indexed: true,
relation_kind: Some(RelationKind::ForeignKey),
on_delete: Some(OnDelete::Restrict),
target_type_name: Some("User"),
..field_descriptor("user_id", FieldSqlType::BigInt, false)
}];
let billing = synth_app("billing", "main");
let users = synth_app("users", "main");
let user = ModelDescriptor {
app: Some("users"),
..synth_model("users", "User")
};
let invoice = ModelDescriptor {
app: Some("billing"),
fields: FIELDS,
..synth_model("invoices", "Invoice")
};
let buckets = project_from_iters(
[&user, &invoice],
[],
[&billing, &users],
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let bk_billing = BucketKey {
database: "main".to_string(),
app: "billing".to_string(),
};
let invoice_user_id = &buckets[&bk_billing].models["invoices"].columns[0];
let fk = invoice_user_id.foreign_key.as_ref().expect("fk");
assert_eq!(fk.ref_table, "users");
}
#[test]
fn pk_default_sql_uses_canonical_heeranjid_functions() {
assert_eq!(
pk_default_sql(&PkType::HeerId).as_deref(),
Some("heerid_next()")
);
assert_eq!(
pk_default_sql(&PkType::HeerIdDesc).as_deref(),
Some("heerid_next_desc()")
);
assert_eq!(
pk_default_sql(&PkType::RanjId).as_deref(),
Some("ranjid_next()")
);
assert_eq!(
pk_default_sql(&PkType::RanjIdDesc).as_deref(),
Some("ranjid_next_desc()")
);
}
#[test]
fn pk_default_sql_is_heerid_next_desc_for_heer_id_desc_projection() {
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
}];
let m = ModelDescriptor {
fields: FIELDS,
..synth_model("widgets", "Widget")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let id_col = &buckets[&empty_global()].models["widgets"].columns[0];
assert_eq!(id_col.default_sql.as_deref(), Some("heerid_next_desc()"));
}
#[test]
fn ddl_metadata_projects_from_model_and_field_descriptors() {
static FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
comment: Some("Stable adopter-facing identifier"),
..field_descriptor("name", FieldSqlType::Text, false)
},
FieldDescriptor {
..field_descriptor("weight_kg", FieldSqlType::DoublePrecision, true)
},
];
let m = ModelDescriptor {
fields: FIELDS,
table_comment: Some("Operational metadata table"),
storage_params: Some("fillfactor=70, autovacuum_enabled=false"),
tablespace: Some("fastspace"),
..synth_model("widgets", "Widget")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let table = &buckets[&empty_global()].models["widgets"];
assert_eq!(
table.table_comment.as_deref(),
Some("Operational metadata table")
);
assert_eq!(
table.storage_params.as_deref(),
Some("fillfactor=70, autovacuum_enabled=false")
);
assert_eq!(table.tablespace.as_deref(), Some("fastspace"));
assert_eq!(
table.columns[0].comment.as_deref(),
Some("Stable adopter-facing identifier")
);
assert_eq!(table.columns[1].comment, None);
}
#[test]
fn fts_projection_synthesizes_generated_column_and_gin_index() {
static FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
..field_descriptor("title", FieldSqlType::Text, false)
},
FieldDescriptor {
..field_descriptor("body", FieldSqlType::Text, false)
},
];
let m = ModelDescriptor {
fields: FIELDS,
fts: Some(FtsDescriptor {
column: "search",
source: "title, body",
dictionary: "english",
}),
..synth_model("book", "Book")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let global = &buckets[&empty_global()];
let table = &global.models["book"];
let search = table
.columns
.iter()
.find(|column| column.name == "search")
.expect("generated search column");
assert_eq!(search.sql_type, "TSVECTOR");
assert_eq!(
search.generated.as_ref().map(|g| g.expression.as_str()),
Some("to_tsvector('english', \"title\" || ' ' || \"body\")")
);
assert!(search.generated.as_ref().is_some_and(|g| g.stored));
assert_eq!(global.indexes.len(), 1);
let index = &global.indexes[0];
assert_eq!(index.name, "book_search_gin");
assert_eq!(index.index_type, IndexTypeSchema::Gin);
assert_eq!(
index.target,
IndexTargetSchema::Columns(vec![IndexColumnSchema {
name: "search".to_string(),
nulls: IndexNullsOrderSchema::Default,
opclass: None,
order: IndexOrderSchema::Asc,
}])
);
}
#[test]
fn outbox_projection_synthesizes_sibling_table_for_heerid_pk() {
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
}];
let m = ModelDescriptor {
pk_type: PkType::HeerId,
fields: FIELDS,
has_outbox: true,
..synth_model("widgets", "Widget")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let global = &buckets[&empty_global()];
let models = &global.models;
let names: Vec<&str> = models.keys().map(String::as_str).collect();
assert_eq!(names, vec!["widgets", "widgets_outbox"]);
let outbox = &models["widgets_outbox"];
assert_eq!(outbox.table, "widgets_outbox");
assert_eq!(outbox.columns[0].name, "id");
assert_eq!(outbox.columns[0].sql_type, "BIGINT");
assert_eq!(
outbox.columns[0].default_sql.as_deref(),
Some("heerid_next()")
);
assert_eq!(outbox.columns[1].name, "row_id");
assert_eq!(outbox.columns[1].sql_type, "BIGINT");
assert_eq!(outbox.columns[2].name, "action");
assert_eq!(outbox.columns[2].sql_type, "TEXT");
assert_eq!(
outbox.columns[2].check.as_deref(),
Some("action IN ('create', 'save', 'delete')"),
);
assert_eq!(outbox.columns[3].name, "payload");
assert_eq!(outbox.columns[3].sql_type, "JSONB");
assert_eq!(outbox.columns[4].name, "created_at");
assert_eq!(outbox.columns[4].sql_type, "TIMESTAMPTZ");
assert_eq!(outbox.columns[4].default_sql.as_deref(), Some("now()"));
assert_eq!(outbox.columns[5].name, "state");
assert_eq!(outbox.columns[5].sql_type, "TEXT");
assert_eq!(outbox.columns[5].default_sql.as_deref(), Some("'pending'"));
assert_eq!(
outbox.columns[5].check.as_deref(),
Some("state IN ('pending', 'processing', 'published', 'failed')"),
);
assert_eq!(outbox.columns[6].name, "leased_until");
assert!(outbox.columns[6].nullable);
assert_eq!(outbox.columns[7].name, "retry_count");
assert_eq!(outbox.columns[7].default_sql.as_deref(), Some("0"));
assert_eq!(outbox.columns[8].name, "failed_reason");
assert!(outbox.columns[8].nullable);
assert_eq!(global.indexes.len(), 1);
let idx = &global.indexes[0];
assert_eq!(idx.name, "widgets_outbox_pending_idx");
assert_eq!(idx.table, "widgets_outbox");
assert_eq!(idx.kind, IndexKindSchema::NonUnique);
assert_eq!(idx.index_type, IndexTypeSchema::BTree);
assert_eq!(idx.predicate.as_deref(), Some("state = 'pending'"));
assert_eq!(
idx.target,
IndexTargetSchema::Columns(vec![
IndexColumnSchema {
name: "state".to_string(),
nulls: IndexNullsOrderSchema::Default,
opclass: None,
order: IndexOrderSchema::Asc,
},
IndexColumnSchema {
name: "created_at".to_string(),
nulls: IndexNullsOrderSchema::Default,
opclass: None,
order: IndexOrderSchema::Asc,
},
])
);
}
#[test]
fn outbox_projection_uses_uuid_row_id_for_ranjid_pk() {
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor("id", FieldSqlType::Uuid, false)
}];
let m = ModelDescriptor {
pk_type: PkType::RanjId,
fields: FIELDS,
has_outbox: true,
..synth_model("events", "Event")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let outbox = &buckets[&empty_global()].models["events_outbox"];
assert_eq!(outbox.columns[1].name, "row_id");
assert_eq!(outbox.columns[1].sql_type, "UUID");
}
#[test]
fn outbox_projection_uses_custom_row_id_sql_type() {
const CUSTOM_PK: crate::descriptor::CustomPrimaryKeyKind =
crate::descriptor::CustomPrimaryKeyKind {
type_name: "crate::ids::WidgetId",
sql_type: "CITEXT",
default_sql: "make_widget_id()",
};
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor("id", FieldSqlType::Citext, false)
}];
let m = ModelDescriptor {
pk_type: PkType::Custom(CUSTOM_PK),
fields: FIELDS,
has_outbox: true,
..synth_model("custom_widgets", "CustomWidget")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let outbox = &buckets[&empty_global()].models["custom_widgets_outbox"];
assert_eq!(outbox.columns[1].name, "row_id");
assert_eq!(outbox.columns[1].sql_type, "CITEXT");
}
#[test]
fn serial_pk_emits_identity_field_on_id_column() {
use crate::migrate::schema::IdentityKindSchema;
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor("id", FieldSqlType::Integer, false)
}];
let m = ModelDescriptor {
pk_type: PkType::Serial,
fields: FIELDS,
..synth_model("countries", "Country")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let id_col = &buckets[&empty_global()].models["countries"].columns[0];
assert_eq!(
id_col.sql_type, "INTEGER",
"Serial PK id column sql_type must stay plain INTEGER (the IDENTITY clause lives on `identity`); got {}",
id_col.sql_type
);
assert_eq!(
id_col.identity,
Some(IdentityKindSchema::ByDefault),
"Serial PK id column must carry `identity = Some(ByDefault)`"
);
assert_eq!(
id_col.default_sql, None,
"IDENTITY columns must not also carry a DEFAULT expression"
);
}
#[test]
fn serial_pk_fk_references_stay_plain_integer_no_identity() {
static FK_TO_COUNTRY: &[FieldDescriptor] = &[FieldDescriptor {
indexed: true,
relation_kind: Some(crate::descriptor::RelationKind::ForeignKey),
on_delete: Some(crate::descriptor::OnDelete::Restrict),
target_type_name: Some("Country"),
..field_descriptor("country_id", FieldSqlType::Integer, false)
}];
let country = ModelDescriptor {
pk_type: PkType::Serial,
..synth_model("countries", "Country")
};
let herd = ModelDescriptor {
fields: FK_TO_COUNTRY,
..synth_model("herds", "Herd")
};
let buckets = project_from_iters(
[&country, &herd],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let country_id_col = &buckets[&empty_global()].models["herds"].columns[0];
assert_eq!(
country_id_col.sql_type, "INTEGER",
"FK to Serial PK keeps plain INTEGER sql_type; got {}",
country_id_col.sql_type
);
assert_eq!(
country_id_col.identity, None,
"FK to Serial PK must NOT carry an `identity` clause"
);
}
#[test]
fn indexes_sorted_by_table_then_name() {
static NAME_COLS: &[IndexColumnSpec] = &[IndexColumnSpec::simple("name")];
static COLOR_COLS: &[IndexColumnSpec] = &[IndexColumnSpec::simple("color")];
static IDX_PAIR: &[IndexSpec] = &[
IndexSpec {
name: "z_widget_idx",
target: IndexTarget::Columns(NAME_COLS),
kind: IndexKind::NonUnique,
index_type: IndexType::BTree,
predicate: None,
include: &[],
nulls_not_distinct: false,
requires_out_of_transaction: false,
extension_dependency: None,
},
IndexSpec {
name: "a_widget_idx",
target: IndexTarget::Columns(COLOR_COLS),
kind: IndexKind::NonUnique,
index_type: IndexType::BTree,
predicate: None,
include: &[],
nulls_not_distinct: false,
requires_out_of_transaction: false,
extension_dependency: None,
},
];
let m = ModelDescriptor {
indexes: IDX_PAIR,
..synth_model("widgets", "Widget")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let global = &buckets[&empty_global()];
assert_eq!(global.indexes[0].name, "a_widget_idx");
assert_eq!(global.indexes[1].name, "z_widget_idx");
}
#[test]
fn registered_apps_includes_synthetic_global_and_user_apps() {
let billing = synth_app("billing", "main");
let users = synth_app("users", "main");
let buckets = project_from_iters(
std::iter::empty::<&ModelDescriptor>(),
std::iter::empty::<&EnumDescriptor>(),
[&users, &billing],
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let global = &buckets[&empty_global()];
assert_eq!(
global.registered_apps,
vec!["".to_string(), "billing".to_string(), "users".to_string()]
);
}
#[test]
fn rfc3339_now_has_z_suffix_and_no_subseconds() {
let s = rfc3339_now_seconds();
assert!(
s.ends_with('Z') || s.ends_with("+00:00"),
"must be UTC: {s}"
);
assert!(!s.contains('.'), "must not have sub-second precision: {s}");
}
#[test]
fn cross_database_fk_rejected_at_projection() {
let main_app = AppDescriptor {
label: "billing",
database: "main",
renamed_from: None,
tombstone: false,
};
let crud_app = AppDescriptor {
label: "audit",
database: "crud_log",
renamed_from: None,
tombstone: false,
};
let target = ModelDescriptor {
app: Some("audit"),
..synth_model("audit_rows", "AuditRow")
};
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
indexed: true,
relation_kind: Some(RelationKind::ForeignKey),
on_delete: Some(OnDelete::Restrict),
target_type_name: Some("AuditRow"),
..field_descriptor("audit_id", FieldSqlType::BigInt, false)
}];
let source = ModelDescriptor {
app: Some("billing"),
fields: FIELDS,
..synth_model("invoices", "Invoice")
};
let err = project_from_iters(
[&target, &source],
std::iter::empty::<&EnumDescriptor>(),
[&main_app, &crud_app],
"2026-04-25T00:00:00Z".to_string(),
)
.expect_err("must reject cross-DB FK");
match err {
ProjectionError::CrossDatabaseForeignKey {
source_table,
target_table,
source_bucket,
target_bucket,
..
} => {
assert_eq!(source_table, "invoices");
assert_eq!(target_table, "audit_rows");
assert_eq!(source_bucket.database, "main");
assert_eq!(target_bucket.database, "crud_log");
}
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn fk_to_proxy_resolves_through_to_parent_for_cross_db_check() {
let main_app = AppDescriptor {
label: "billing",
database: "main",
renamed_from: None,
tombstone: false,
};
let crud_app = AppDescriptor {
label: "audit",
database: "crud_log",
renamed_from: None,
tombstone: false,
};
let parent = ModelDescriptor {
app: Some("audit"),
..synth_model("audit_rows", "AuditRow")
};
let proxy = ModelDescriptor {
app: Some("audit"),
proxy_for: Some("AuditRow"),
..synth_model("audit_rows", "ActiveAuditRow")
};
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
indexed: true,
relation_kind: Some(RelationKind::ForeignKey),
on_delete: Some(OnDelete::Restrict),
target_type_name: Some("ActiveAuditRow"),
..field_descriptor("audit_id", FieldSqlType::BigInt, false)
}];
let source = ModelDescriptor {
app: Some("billing"),
fields: FIELDS,
..synth_model("invoices", "Invoice")
};
let err = project_from_iters(
[&parent, &proxy, &source],
std::iter::empty::<&EnumDescriptor>(),
[&main_app, &crud_app],
"2026-04-25T00:00:00Z".to_string(),
)
.expect_err("must reject cross-DB FK even when the FK targets a proxy");
match err {
ProjectionError::CrossDatabaseForeignKey {
source_table,
target_table,
source_bucket,
target_bucket,
..
} => {
assert_eq!(source_table, "invoices");
assert_eq!(target_table, "audit_rows");
assert_eq!(source_bucket.database, "main");
assert_eq!(target_bucket.database, "crud_log");
}
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn fk_to_proxy_with_unregistered_parent_rejected() {
let proxy = ModelDescriptor {
proxy_for: Some("MissingParent"),
..synth_model("vehicles", "ActiveVehicle")
};
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
indexed: true,
relation_kind: Some(RelationKind::ForeignKey),
on_delete: Some(OnDelete::Restrict),
target_type_name: Some("ActiveVehicle"),
..field_descriptor("vehicle_id", FieldSqlType::BigInt, false)
}];
let source = ModelDescriptor {
fields: FIELDS,
..synth_model("invoices", "Invoice")
};
let err = project_from_iters(
[&proxy, &source],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect_err("must reject FK whose proxy parent is unregistered");
match err {
ProjectionError::ProxyParentNotRegistered {
source_table,
proxy_type,
parent_type,
..
} => {
assert_eq!(source_table, "invoices");
assert_eq!(proxy_type, "ActiveVehicle");
assert_eq!(parent_type, "MissingParent");
}
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn fk_to_proxy_same_database_passes() {
let parent = synth_model("vehicles", "Vehicle");
let proxy = ModelDescriptor {
proxy_for: Some("Vehicle"),
..synth_model("vehicles", "ActiveVehicle")
};
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
indexed: true,
relation_kind: Some(RelationKind::ForeignKey),
on_delete: Some(OnDelete::Restrict),
target_type_name: Some("ActiveVehicle"),
..field_descriptor("vehicle_id", FieldSqlType::BigInt, false)
}];
let source = ModelDescriptor {
fields: FIELDS,
..synth_model("invoices", "Invoice")
};
let buckets = project_from_iters(
[&parent, &proxy, &source],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("same-DB proxy FK passes validation");
let global = buckets.get(&empty_global()).expect("global");
let invoice = global
.models
.get("invoices")
.expect("invoices model present");
let fk_col = invoice
.columns
.iter()
.find(|c| c.name == "vehicle_id")
.expect("FK column emitted");
let fk = fk_col.foreign_key.as_ref().expect("FK metadata present");
assert_eq!(fk.ref_table, "vehicles");
}
#[test]
fn framework_timestamp_cols_get_now_default() {
const FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("created_at", FieldSqlType::Timestamptz, false)
},
FieldDescriptor {
..field_descriptor("updated_at", FieldSqlType::Timestamptz, false)
},
];
let m = ModelDescriptor {
fields: FIELDS,
..synth_model("widgets", "Widget")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let columns = &buckets[&empty_global()].models["widgets"].columns;
let created = columns.iter().find(|c| c.name == "created_at").unwrap();
let updated = columns.iter().find(|c| c.name == "updated_at").unwrap();
assert_eq!(
created.default_sql.as_deref(),
Some("now()"),
"created_at must get DEFAULT now()"
);
assert_eq!(
updated.default_sql.as_deref(),
Some("now()"),
"updated_at must get DEFAULT now()"
);
}
#[test]
fn non_framework_timestamptz_col_has_no_default() {
const FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor("shipped_at", FieldSqlType::Timestamptz, true)
}];
let m = ModelDescriptor {
fields: FIELDS,
..synth_model("widgets", "Widget")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let shipped = &buckets[&empty_global()].models["widgets"].columns[0];
assert_eq!(
shipped.default_sql, None,
"user-declared Timestamptz must NOT get the framework default"
);
}
#[test]
fn fk_column_sql_type_substituted_from_target_pk() {
const FK_TO_OWNER: &[FieldDescriptor] = &[FieldDescriptor {
indexed: true,
relation_kind: Some(RelationKind::ForeignKey),
on_delete: Some(OnDelete::Restrict),
target_type_name: Some("Owner"),
..field_descriptor("owner_id", FieldSqlType::BigInt, false)
}];
const CUSTOM_PK: crate::descriptor::CustomPrimaryKeyKind =
crate::descriptor::CustomPrimaryKeyKind {
type_name: "crate::ids::OwnerId",
sql_type: "CITEXT",
default_sql: "",
};
let cases: [(PkType, &str); 6] = [
(PkType::HeerId, "BIGINT"),
(PkType::HeerIdDesc, "BIGINT"),
(PkType::RanjId, "UUID"),
(PkType::RanjIdDesc, "UUID"),
(PkType::Serial, "INTEGER"),
(PkType::Custom(CUSTOM_PK), "CITEXT"),
];
for (pk, expected_sql) in cases {
let owner = ModelDescriptor {
pk_type: pk,
..synth_model("owners", "Owner")
};
let vehicle = ModelDescriptor {
fields: FK_TO_OWNER,
..synth_model("vehicles", "Vehicle")
};
let buckets = project_from_iters(
[&owner, &vehicle],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let owner_id_col = &buckets[&empty_global()].models["vehicles"].columns[0];
assert_eq!(
owner_id_col.sql_type, expected_sql,
"FK to Owner with PK {pk:?} must project owner_id.sql_type = {expected_sql}; got {}",
owner_id_col.sql_type
);
}
}
#[test]
fn non_fk_column_sql_type_passes_through_verbatim() {
const FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
indexed: true,
relation_kind: Some(RelationKind::ForeignKey),
on_delete: Some(OnDelete::Restrict),
target_type_name: Some("Owner"),
..field_descriptor("owner_id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
..field_descriptor("sort_order", FieldSqlType::SmallInt, false)
},
FieldDescriptor {
..field_descriptor("name", FieldSqlType::Text, false)
},
];
let owner = ModelDescriptor {
pk_type: PkType::RanjId,
..synth_model("owners", "Owner")
};
let widget = ModelDescriptor {
fields: FIELDS,
..synth_model("widgets", "Widget")
};
let buckets = project_from_iters(
[&owner, &widget],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let cols = &buckets[&empty_global()].models["widgets"].columns;
let owner_id = cols.iter().find(|c| c.name == "owner_id").unwrap();
let sort_order = cols.iter().find(|c| c.name == "sort_order").unwrap();
let name = cols.iter().find(|c| c.name == "name").unwrap();
assert_eq!(
owner_id.sql_type, "UUID",
"FK to Owner (RanjId PK) must substitute to UUID; got {}",
owner_id.sql_type
);
assert_eq!(
sort_order.sql_type, "SMALLINT",
"non-FK SmallInt column must pass through unchanged; got {}",
sort_order.sql_type
);
assert_eq!(
name.sql_type, "TEXT",
"non-FK Text column must pass through unchanged; got {}",
name.sql_type
);
}
#[test]
fn project_from_inventory_wraps_relation_collision_into_projection_error() {
let markers = [
synth_collision_marker(
RegistryRelationKind::FK,
"Owner",
"cars",
"Vehicle",
"owner_id",
),
synth_collision_marker(
RegistryRelationKind::M2M,
"Owner",
"cars",
"Garage",
"owner_id",
),
];
let registry_err: RelationRegistryError =
validate_relation_accessor_collisions(markers.iter())
.expect_err("synthetic FK + M2M markers must collide");
let result = project_from_inventory_with_relation_validator(|| Err(registry_err));
let err = result.expect_err("validator failure must short-circuit projection");
assert!(
matches!(err, ProjectionError::RelationAccessorCollisions(_)),
"expected RelationAccessorCollisions, got {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("relation-accessor collisions detected before projection"),
"missing projection-side anchor: {msg}"
);
assert!(msg.contains("GH #158"), "missing issue number: {msg}");
assert!(msg.contains("Owner"), "missing source: {msg}");
assert!(msg.contains("cars"), "missing accessor name: {msg}");
assert!(msg.contains("FK"), "missing FK kind: {msg}");
assert!(msg.contains("M2M"), "missing M2M kind: {msg}");
}
#[test]
fn project_from_inventory_with_clean_validator_does_not_short_circuit() {
let result = project_from_inventory_with_relation_validator(|| Ok(()));
if let Err(ref e) = result {
assert!(
!matches!(e, ProjectionError::RelationAccessorCollisions(_)),
"validator returned Ok(()) but projection still produced \
RelationAccessorCollisions: {e}"
);
}
}
#[test]
fn projection_error_relation_accessor_collisions_display_is_actionable() {
let markers = [
synth_collision_marker(
RegistryRelationKind::FK,
"Account",
"subscriptions",
"Subscription",
"account_id",
),
synth_collision_marker(
RegistryRelationKind::M2M,
"Account",
"subscriptions",
"Plan",
"account_id",
),
];
let registry_err = validate_relation_accessor_collisions(markers.iter()).unwrap_err();
let projection_err = ProjectionError::RelationAccessorCollisions(registry_err);
let msg = projection_err.to_string();
assert!(msg.contains("Account"), "missing source: {msg}");
assert!(msg.contains("subscriptions"), "missing accessor: {msg}");
assert!(msg.contains("Subscription"), "missing FK target: {msg}");
assert!(msg.contains("Plan"), "missing M2M target: {msg}");
assert!(msg.contains("GH #158"), "missing issue anchor: {msg}");
}
#[test]
fn field_type_check_for_date_emits_year_upper_bound() {
let expr = field_type_check(&FieldSqlType::Date, None, "birthday")
.expect("DATE field must carry the time::Date year-bound CHECK");
assert!(
expr.contains("\"birthday\" <= DATE '9999-12-31'"),
"DATE CHECK upper bound: {expr}"
);
assert!(
!expr.contains(">= DATE"),
"DATE CHECK is one-sided upper bound (no lower-bound clause): {expr}"
);
assert!(
expr.contains("pg_catalog.isfinite(\"birthday\")"),
"DATE CHECK must reject ±infinity via pg_catalog.isfinite(<col>): {expr}"
);
}
#[test]
fn field_type_check_for_timestamptz_emits_year_upper_bound() {
let expr = field_type_check(&FieldSqlType::Timestamptz, None, "occurred_at")
.expect("TIMESTAMPTZ field must carry the OffsetDateTime year-bound CHECK");
assert!(
expr.contains("\"occurred_at\" <= TIMESTAMPTZ '9999-12-31 23:59:59.999999+00'"),
"TIMESTAMPTZ CHECK must use explicit UTC offset +00, not bare TIMESTAMP: {expr}"
);
assert!(
!expr.contains(">= TIMESTAMPTZ"),
"TIMESTAMPTZ CHECK is one-sided upper bound (no lower-bound clause): {expr}"
);
assert!(
!expr.contains("<= TIMESTAMP '"),
"TIMESTAMPTZ CHECK must not use plain TIMESTAMP literal (timezone-sensitive): {expr}"
);
assert!(
expr.contains("pg_catalog.isfinite(\"occurred_at\")"),
"TIMESTAMPTZ CHECK must reject ±infinity via pg_catalog.isfinite(<col>): {expr}"
);
}
#[test]
fn field_type_check_for_date_rejects_infinity_special_values() {
let expr = field_type_check(&FieldSqlType::Date, None, "birthday")
.expect("DATE field must carry the time::Date representability CHECK");
assert!(
expr.contains("pg_catalog.isfinite(\"birthday\") AND"),
"DATE CHECK must place the finite guard before the year bound under AND: {expr}"
);
assert_eq!(
expr, "pg_catalog.isfinite(\"birthday\") AND \"birthday\" <= DATE '9999-12-31'",
"DATE CHECK expression shape (full): {expr}"
);
}
#[test]
fn field_type_check_for_timestamptz_rejects_infinity_special_values() {
let expr = field_type_check(&FieldSqlType::Timestamptz, None, "occurred_at")
.expect("TIMESTAMPTZ field must carry the OffsetDateTime representability CHECK");
assert!(
expr.contains("pg_catalog.isfinite(\"occurred_at\") AND"),
"TIMESTAMPTZ CHECK must place the finite guard before the year bound under AND: \
{expr}"
);
let normalized = expr.split_whitespace().collect::<Vec<_>>().join(" ");
assert_eq!(
normalized,
"pg_catalog.isfinite(\"occurred_at\") AND \
\"occurred_at\" <= TIMESTAMPTZ '9999-12-31 23:59:59.999999+00'",
"TIMESTAMPTZ CHECK expression shape (full, whitespace-normalized): {expr}"
);
}
#[test]
fn field_type_check_for_timestamptz_is_utc_explicit() {
let expr1 = field_type_check(&FieldSqlType::Timestamptz, None, "ts")
.expect("TIMESTAMPTZ must produce a CHECK");
let expr2 = field_type_check(&FieldSqlType::Timestamptz, None, "ts")
.expect("repeated call must produce the same CHECK");
assert_eq!(expr1, expr2, "CHECK expression must be deterministic");
assert!(
expr1.contains("+00'"),
"CHECK literal must carry explicit +00 UTC offset: {expr1}"
);
}
#[test]
fn field_type_check_quotes_reserved_word_columns() {
let expr = field_type_check(&FieldSqlType::Date, None, "order")
.expect("DATE field must carry CHECK regardless of column name");
assert!(
expr.contains("\"order\""),
"CHECK expression must quote reserved-word column names: {expr}"
);
}
#[test]
fn field_type_check_returns_none_for_identity_widths() {
for ty in [
FieldSqlType::Text,
FieldSqlType::Boolean,
FieldSqlType::Real,
FieldSqlType::DoublePrecision,
FieldSqlType::Numeric,
FieldSqlType::Uuid,
FieldSqlType::Jsonb,
FieldSqlType::Bytea,
FieldSqlType::TextArray,
FieldSqlType::SmallIntArray,
FieldSqlType::IntegerArray,
FieldSqlType::BigIntArray,
FieldSqlType::RealArray,
FieldSqlType::DoublePrecisionArray,
FieldSqlType::BoolArray,
FieldSqlType::UuidArray,
FieldSqlType::Citext,
] {
assert!(
field_type_check(&ty, None, "col").is_none(),
"non-widened SQL type {ty:?} must not carry a Rust-derived CHECK",
);
}
}
#[test]
fn field_type_check_returns_none_for_direct_integer_widths_without_discriminator() {
for ty in [
FieldSqlType::SmallInt,
FieldSqlType::Integer,
FieldSqlType::BigInt,
FieldSqlType::Numeric,
] {
assert!(
field_type_check(&ty, None, "col").is_none(),
"direct-mapped integer/numeric SQL type {ty:?} with no rust_source_type \
must not carry a Rust-derived CHECK",
);
}
}
#[test]
fn field_type_check_for_i8_smallint_emits_signed_byte_bounds() {
let expr = field_type_check(
&FieldSqlType::SmallInt,
Some(RustSourceType::I8),
"byte_col",
)
.expect("i8 → SmallInt must carry a range CHECK");
assert!(
expr.contains("\"byte_col\" >= -128 AND \"byte_col\" <= 127"),
"i8 CHECK must cover -128..=127: {expr}"
);
}
#[test]
fn field_type_check_for_u8_smallint_emits_unsigned_byte_bounds() {
let expr = field_type_check(&FieldSqlType::SmallInt, Some(RustSourceType::U8), "count")
.expect("u8 → SmallInt must carry a range CHECK");
assert!(
expr.contains("\"count\" >= 0 AND \"count\" <= 255"),
"u8 CHECK must cover 0..=255: {expr}"
);
}
#[test]
fn field_type_check_for_u16_integer_emits_unsigned_short_bounds() {
let expr = field_type_check(&FieldSqlType::Integer, Some(RustSourceType::U16), "port")
.expect("u16 → Integer must carry a range CHECK");
assert!(
expr.contains("\"port\" >= 0 AND \"port\" <= 65535"),
"u16 CHECK must cover 0..=65535: {expr}"
);
}
#[test]
fn field_type_check_for_u32_bigint_emits_unsigned_int_bounds() {
let expr = field_type_check(&FieldSqlType::BigInt, Some(RustSourceType::U32), "qty")
.expect("u32 → BigInt must carry a range CHECK");
assert!(
expr.contains("\"qty\" >= 0 AND \"qty\" <= 4294967295"),
"u32 CHECK must cover 0..=4294967295: {expr}"
);
}
#[test]
fn field_type_check_for_u64_numeric_emits_unsigned_long_bounds_with_integrality() {
let expr = field_type_check(
&FieldSqlType::Numeric,
Some(RustSourceType::U64),
"huge_count",
)
.expect("u64 → Numeric must carry a range+integrality CHECK");
assert!(
expr.contains("\"huge_count\" >= 0"),
"u64 CHECK must have lower bound 0: {expr}"
);
assert!(
expr.contains("\"huge_count\" <= 18446744073709551615"),
"u64 CHECK must cover 0..=u64::MAX: {expr}"
);
assert!(
expr.contains("\"huge_count\" = trunc(\"huge_count\")"),
"u64 CHECK must reject fractional values via trunc: {expr}"
);
}
#[test]
fn field_type_check_source_type_mismatch_returns_none() {
assert!(
field_type_check(&FieldSqlType::SmallInt, Some(RustSourceType::U16), "col").is_none(),
"U16 discriminator on SmallInt must return None (wrong carrier)"
);
assert!(
field_type_check(&FieldSqlType::Integer, Some(RustSourceType::I8), "col").is_none(),
"I8 discriminator on Integer must return None (wrong carrier)"
);
}
#[test]
fn project_column_no_check_for_non_fk_bigint_id_column_without_discriminator() {
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
}];
let m = ModelDescriptor {
fields: FIELDS,
..synth_model("widgets", "Widget")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let id_col = &buckets[&empty_global()].models["widgets"].columns[0];
assert!(
id_col.check.is_none(),
"BigInt id column without rust_source_type must have check=None; got {:?}",
id_col.check
);
}
#[test]
fn project_column_no_check_for_non_fk_bigint_amount_column_without_discriminator() {
static FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
..field_descriptor("amount", FieldSqlType::BigInt, false)
},
];
let m = ModelDescriptor {
fields: FIELDS,
..synth_model("ledgers", "Ledger")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let amount_col = &buckets[&empty_global()].models["ledgers"].columns[1];
assert_eq!(amount_col.name, "amount");
assert!(
amount_col.check.is_none(),
"BigInt amount column without rust_source_type must have check=None; got {:?}",
amount_col.check
);
}
#[test]
fn project_column_no_check_for_non_fk_smallint_column_without_discriminator() {
static FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
..field_descriptor("byte_count", FieldSqlType::SmallInt, false)
},
];
let m = ModelDescriptor {
fields: FIELDS,
..synth_model("widgets", "Widget")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let byte_col = &buckets[&empty_global()].models["widgets"].columns[1];
assert_eq!(byte_col.name, "byte_count");
assert!(
byte_col.check.is_none(),
"SmallInt column without rust_source_type must have check=None; got {:?}",
byte_col.check
);
}
#[test]
fn project_column_emits_check_for_u8_smallint() {
use crate::descriptor::RustSourceType;
static FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
rust_source_type: Some(RustSourceType::U8),
..field_descriptor("count", FieldSqlType::SmallInt, false)
},
];
let m = ModelDescriptor {
fields: FIELDS,
..synth_model("counters", "Counter")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-05-15T00:00:00Z".to_string(),
)
.expect("ok");
let count_col = &buckets[&empty_global()].models["counters"].columns[1];
assert_eq!(count_col.name, "count");
let check = count_col
.check
.as_deref()
.expect("u8 → SmallInt column with RustSourceType::U8 must have a range CHECK");
assert!(
check.contains(">= 0") && check.contains("<= 255"),
"u8 CHECK must cover 0..=255: {check}"
);
}
#[test]
fn project_column_emits_check_for_u32_bigint() {
use crate::descriptor::RustSourceType;
static FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
rust_source_type: Some(RustSourceType::U32),
..field_descriptor("medium_count", FieldSqlType::BigInt, false)
},
];
let m = ModelDescriptor {
fields: FIELDS,
..synth_model("things", "Thing")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-05-15T00:00:00Z".to_string(),
)
.expect("ok");
let col = &buckets[&empty_global()].models["things"].columns[1];
assert_eq!(col.name, "medium_count");
let check = col
.check
.as_deref()
.expect("u32 → BigInt column with RustSourceType::U32 must have a range CHECK");
assert!(
check.contains(">= 0") && check.contains("<= 4294967295"),
"u32 CHECK must cover 0..=4294967295: {check}"
);
}
#[test]
fn project_column_emits_check_for_u64_numeric() {
use crate::descriptor::RustSourceType;
static FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
rust_source_type: Some(RustSourceType::U64),
..field_descriptor("huge_count", FieldSqlType::Numeric, false)
},
];
let m = ModelDescriptor {
fields: FIELDS,
..synth_model("metrics", "Metric")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-05-15T00:00:00Z".to_string(),
)
.expect("ok");
let col = &buckets[&empty_global()].models["metrics"].columns[1];
assert_eq!(col.name, "huge_count");
let check = col
.check
.as_deref()
.expect("u64 → Numeric with RustSourceType::U64 must have a range+integrality CHECK");
assert!(
check.contains(">= 0") && check.contains("<= 18446744073709551615"),
"u64 CHECK must cover 0..=u64::MAX: {check}"
);
assert!(
check.contains("= trunc("),
"u64 CHECK must include integrality clause (col = trunc(col)): {check}"
);
}
#[test]
fn project_column_emits_year_check_for_non_fk_date_column() {
static FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
..field_descriptor("launch_date", FieldSqlType::Date, false)
},
];
let m = ModelDescriptor {
fields: FIELDS,
..synth_model("products", "Product")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let date_col = &buckets[&empty_global()].models["products"].columns[1];
assert_eq!(date_col.name, "launch_date");
let check = date_col
.check
.as_ref()
.expect("DATE column must carry the time::Date representability CHECK (djogi#187)");
assert!(
check.contains("\"launch_date\" <= DATE '9999-12-31'"),
"DATE column CHECK upper bound: {check}"
);
assert!(
check.contains("pg_catalog.isfinite(\"launch_date\")"),
"DATE column CHECK must reject ±infinity via the finite guard: {check}"
);
}
#[test]
fn project_column_emits_year_check_for_non_fk_timestamptz_column() {
static FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
..field_descriptor("occurred_at", FieldSqlType::Timestamptz, false)
},
];
let m = ModelDescriptor {
fields: FIELDS,
..synth_model("events", "Event")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let ts_col = &buckets[&empty_global()].models["events"].columns[1];
assert_eq!(ts_col.name, "occurred_at");
let check = ts_col.check.as_ref().expect(
"TIMESTAMPTZ column must carry the OffsetDateTime representability CHECK (djogi#187)",
);
assert!(
check.contains("\"occurred_at\" <= TIMESTAMPTZ '9999-12-31 23:59:59.999999+00'"),
"TIMESTAMPTZ column CHECK upper bound: {check}"
);
assert!(
check.contains("pg_catalog.isfinite(\"occurred_at\")"),
"TIMESTAMPTZ column CHECK must reject ±infinity via the finite guard: {check}"
);
}
#[test]
fn project_column_no_year_check_for_fk_column_even_if_temporal() {
static OWNER_FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
}];
static VEHICLE_FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
relation_kind: Some(crate::descriptor::RelationKind::ForeignKey),
target_type_name: Some("Owner"),
..field_descriptor("owner_id", FieldSqlType::Timestamptz, false)
},
];
let owner = ModelDescriptor {
fields: OWNER_FIELDS,
..synth_model("owners", "Owner")
};
let vehicle = ModelDescriptor {
fields: VEHICLE_FIELDS,
..synth_model("vehicles", "Vehicle")
};
let buckets = project_from_iters(
[&owner, &vehicle],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let owner_fk = &buckets[&empty_global()].models["vehicles"].columns[1];
assert_eq!(owner_fk.name, "owner_id");
assert!(
owner_fk.foreign_key.is_some(),
"owner_id must project as FK: {owner_fk:?}"
);
assert!(
owner_fk.check.is_none(),
"FK column must never carry a Rust-derived CHECK \
regardless of declared sql_type: {:?}",
owner_fk.check
);
}
#[test]
fn field_type_check_for_decimal_numeric_emits_structural_bounds() {
let expr = field_type_check(
&FieldSqlType::Numeric,
Some(RustSourceType::Decimal),
"price",
)
.expect("Decimal → Numeric must carry the rust_decimal structural CHECK");
assert!(
expr.contains("scale(\"price\") <= 28"),
"Decimal CHECK must cap scale at 28: {expr}"
);
assert!(
expr.contains("abs(\"price\") * power(10::numeric, scale(\"price\"))"),
"Decimal CHECK must scale value back to integer coefficient form: {expr}"
);
assert!(
expr.contains("79228162514264337593543950335"),
"Decimal CHECK upper-bound must be 2^96 - 1 (79228162514264337593543950335): {expr}"
);
}
#[test]
fn field_type_check_decimal_arm_quotes_reserved_word_column() {
let expr = field_type_check(
&FieldSqlType::Numeric,
Some(RustSourceType::Decimal),
"order",
)
.expect("Decimal CHECK must fire regardless of column name");
assert_eq!(
expr.matches("\"order\"").count(),
5,
"Decimal CHECK references the column five times; all must be quoted: {expr}"
);
}
#[test]
fn field_type_check_decimal_arm_rejects_numeric_special_values_via_scale_guard() {
let expr = field_type_check(
&FieldSqlType::Numeric,
Some(RustSourceType::Decimal),
"price",
)
.expect("Decimal CHECK must carry the scale IS NOT NULL guard");
assert!(
expr.contains("scale(\"price\") IS NOT NULL"),
"Decimal CHECK must carry the `scale(...) IS NOT NULL` guard to reject NaN / \
Infinity / -Infinity: {expr}"
);
assert!(
expr.contains("(\"price\") IS NULL OR ("),
"Decimal CHECK must wrap with `(<col>) IS NULL OR (...)` so nullable Decimal \
columns are unaffected by the special-value guard: {expr}"
);
}
#[test]
fn field_type_check_for_range_num_carries_scale_guard_on_both_endpoints() {
let expr = field_type_check(
&FieldSqlType::Range {
subtype: RangeSubtypeKind::Num,
},
None,
"price_range",
)
.expect("NUMRANGE must carry endpoint Decimal CHECKs");
assert!(
expr.contains("scale(lower(\"price_range\")) IS NOT NULL"),
"NUMRANGE lower endpoint must carry the special-value guard: {expr}"
);
assert!(
expr.contains("scale(upper(\"price_range\")) IS NOT NULL"),
"NUMRANGE upper endpoint must carry the special-value guard: {expr}"
);
}
#[test]
fn project_column_emits_decimal_structural_check_for_non_fk_numeric_column() {
static FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
rust_source_type: Some(RustSourceType::Decimal),
..field_descriptor("price", FieldSqlType::Numeric, false)
},
];
let m = ModelDescriptor {
fields: FIELDS,
..synth_model("products", "Product")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-05-16T00:00:00Z".to_string(),
)
.expect("ok");
let price_col = &buckets[&empty_global()].models["products"].columns[1];
assert_eq!(price_col.name, "price");
let check = price_col
.check
.as_deref()
.expect("Decimal column with RustSourceType::Decimal must carry the structural CHECK");
assert!(
check.contains("scale(\"price\") <= 28"),
"Decimal CHECK must cap scale at 28: {check}"
);
assert!(
check.contains("79228162514264337593543950335"),
"Decimal CHECK must reference 2^96 - 1 as upper coefficient bound: {check}"
);
}
#[test]
fn field_type_check_for_range_num_emits_endpoint_decimal_bounds() {
let expr = field_type_check(
&FieldSqlType::Range {
subtype: RangeSubtypeKind::Num,
},
None,
"price_range",
)
.expect("NUMRANGE must carry Decimal bound checks on finite lower and upper endpoints");
assert!(
expr.contains("scale(lower(\"price_range\")) <= 28"),
"NUMRANGE lower endpoint must get Decimal scale bound: {expr}"
);
assert!(
expr.contains("scale(upper(\"price_range\")) <= 28"),
"NUMRANGE upper endpoint must get Decimal scale bound: {expr}"
);
assert!(
expr.contains("scale(lower(\"price_range\")) <= 28 AND abs(lower(\"price_range\")) * power(10::numeric, scale(lower(\"price_range\"))) <= 79228162514264337593543950335"),
"NUMRANGE lower endpoint should reuse Decimal element checks: {expr}"
);
assert!(
expr.contains("scale(upper(\"price_range\")) <= 28 AND abs(upper(\"price_range\")) * power(10::numeric, scale(upper(\"price_range\"))) <= 79228162514264337593543950335"),
"NUMRANGE upper endpoint should reuse Decimal element checks: {expr}"
);
assert!(
expr.contains("lower(\"price_range\") IS NULL OR"),
"NUMRANGE lower endpoint check must keep NULL/unbounded pass-through: {expr}"
);
assert!(
expr.contains("upper(\"price_range\") IS NULL OR"),
"NUMRANGE upper endpoint check must keep NULL/unbounded pass-through: {expr}"
);
}
#[test]
fn field_type_check_for_range_tstz_emits_endpoint_timestamptz_bounds() {
let expr = field_type_check(
&FieldSqlType::Range {
subtype: RangeSubtypeKind::Tstz,
},
None,
"booking_window",
)
.expect(
"TSTZRANGE must carry Timestamptz upper checks on finite lower and upper endpoints",
);
assert!(
expr.contains(
"lower(\"booking_window\") <= TIMESTAMPTZ '9999-12-31 23:59:59.999999+00'"
),
"TSTZRANGE lower endpoint bound must be UTC-explicit TIMESTAMPTZ: {expr}"
);
assert!(
expr.contains(
"upper(\"booking_window\") <= TIMESTAMPTZ '9999-12-31 23:59:59.999999+00'"
),
"TSTZRANGE upper endpoint bound must be UTC-explicit TIMESTAMPTZ: {expr}"
);
assert!(
expr.contains("lower(\"booking_window\") IS NULL OR"),
"TSTZRANGE lower endpoint check must keep NULL/unbounded pass-through: {expr}"
);
assert!(
expr.contains("upper(\"booking_window\") IS NULL OR"),
"TSTZRANGE upper endpoint check must keep NULL/unbounded pass-through: {expr}"
);
assert!(
expr.contains("pg_catalog.isfinite(lower(\"booking_window\"))"),
"TSTZRANGE lower endpoint must reject ±infinity via the finite guard: {expr}"
);
assert!(
expr.contains("pg_catalog.isfinite(upper(\"booking_window\"))"),
"TSTZRANGE upper endpoint must reject ±infinity via the finite guard: {expr}"
);
}
#[test]
fn field_type_check_for_range_ts_emits_endpoint_timestamp_bounds() {
let expr = field_type_check(
&FieldSqlType::Range {
subtype: RangeSubtypeKind::Ts,
},
None,
"local_window",
)
.expect("TSRANGE must carry timestamp upper checks on finite lower and upper endpoints");
assert!(
expr.contains("lower(\"local_window\") <= TIMESTAMP '9999-12-31 23:59:59.999999'"),
"TSRANGE lower endpoint bound must be timestamp-without-timezone: {expr}"
);
assert!(
expr.contains("upper(\"local_window\") <= TIMESTAMP '9999-12-31 23:59:59.999999'"),
"TSRANGE upper endpoint bound must be timestamp-without-timezone: {expr}"
);
assert!(
expr.contains("lower(\"local_window\") IS NULL OR"),
"TSRANGE lower endpoint check must keep NULL/unbounded pass-through: {expr}"
);
assert!(
expr.contains("upper(\"local_window\") IS NULL OR"),
"TSRANGE upper endpoint check must keep NULL/unbounded pass-through: {expr}"
);
assert!(
expr.contains("pg_catalog.isfinite(lower(\"local_window\"))"),
"TSRANGE lower endpoint must reject infinity via the finite guard: {expr}"
);
assert!(
expr.contains("pg_catalog.isfinite(upper(\"local_window\"))"),
"TSRANGE upper endpoint must reject infinity via the finite guard: {expr}"
);
}
#[test]
fn field_type_check_for_range_date_emits_endpoint_date_bounds() {
let expr = field_type_check(
&FieldSqlType::Range {
subtype: RangeSubtypeKind::Date,
},
None,
"validity",
)
.expect("DATERANGE must carry Date upper checks on finite lower and upper endpoints");
assert!(
expr.contains("lower(\"validity\") <= DATE '9999-12-31'"),
"DATERANGE lower endpoint bound must be finite upper check: {expr}"
);
assert!(
expr.contains("upper(\"validity\") <= DATE '9999-12-31'"),
"DATERANGE upper endpoint bound must be finite upper check: {expr}"
);
assert!(
expr.contains("lower(\"validity\") IS NULL OR"),
"DATERANGE lower endpoint check must keep NULL/unbounded pass-through: {expr}"
);
assert!(
expr.contains("upper(\"validity\") IS NULL OR"),
"DATERANGE upper endpoint check must keep NULL/unbounded pass-through: {expr}"
);
assert!(
expr.contains("pg_catalog.isfinite(lower(\"validity\"))"),
"DATERANGE lower endpoint must reject ±infinity via the finite guard: {expr}"
);
assert!(
expr.contains("pg_catalog.isfinite(upper(\"validity\"))"),
"DATERANGE upper endpoint must reject ±infinity via the finite guard: {expr}"
);
}
#[test]
fn field_type_check_for_range_tstz_endpoint_rejects_infinity_special_values() {
let expr = field_type_check(
&FieldSqlType::Range {
subtype: RangeSubtypeKind::Tstz,
},
None,
"booking_window",
)
.expect("TSTZRANGE must carry Timestamptz representability checks on both endpoints");
assert!(
expr.contains(
"lower(\"booking_window\") IS NULL OR (pg_catalog.isfinite(lower(\"booking_window\")) AND lower(\"booking_window\") <= TIMESTAMPTZ '9999-12-31 23:59:59.999999+00')"
),
"TSTZRANGE lower endpoint conjunction shape: {expr}"
);
assert!(
expr.contains(
"upper(\"booking_window\") IS NULL OR (pg_catalog.isfinite(upper(\"booking_window\")) AND upper(\"booking_window\") <= TIMESTAMPTZ '9999-12-31 23:59:59.999999+00')"
),
"TSTZRANGE upper endpoint conjunction shape: {expr}"
);
}
#[test]
fn field_type_check_for_range_date_endpoint_rejects_infinity_special_values() {
let expr = field_type_check(
&FieldSqlType::Range {
subtype: RangeSubtypeKind::Date,
},
None,
"validity",
)
.expect("DATERANGE must carry Date representability checks on both endpoints");
assert!(
expr.contains(
"lower(\"validity\") IS NULL OR (pg_catalog.isfinite(lower(\"validity\")) AND lower(\"validity\") <= DATE '9999-12-31')"
),
"DATERANGE lower endpoint conjunction shape: {expr}"
);
assert!(
expr.contains(
"upper(\"validity\") IS NULL OR (pg_catalog.isfinite(upper(\"validity\")) AND upper(\"validity\") <= DATE '9999-12-31')"
),
"DATERANGE upper endpoint conjunction shape: {expr}"
);
}
#[test]
fn field_type_check_for_range_int4_is_noop() {
assert!(
field_type_check(
&FieldSqlType::Range {
subtype: RangeSubtypeKind::Int4,
},
None,
"slot",
)
.is_none(),
"INT4RANGE has no projection CHECK (identity-mapped i32 bounds)"
);
}
#[test]
fn field_type_check_for_range_int8_is_noop() {
assert!(
field_type_check(
&FieldSqlType::Range {
subtype: RangeSubtypeKind::Int8,
},
None,
"slot",
)
.is_none(),
"INT8RANGE has no projection CHECK (identity-mapped i64 bounds)"
);
}
#[test]
fn field_type_check_for_timestamptz_array_elements_enforces_representability() {
let expr = field_type_check(&FieldSqlType::TimestamptzArray, None, "slots")
.expect("TIMESTAMPTZ[] must carry per-element representability checks");
assert!(
expr.contains("\"slots\" IS NULL OR"),
"TIMESTAMPTZ[] outer NULL should pass through: {expr}"
);
assert!(
expr.contains("djogi.__djogi_tstz_array_is_finite_v1(\"slots\")"),
"TIMESTAMPTZ[] check must use the isfinite helper to reject both ±infinity: {expr}"
);
assert!(
!expr.contains("NOT EXISTS (SELECT 1 FROM unnest(\"slots\")"),
"TIMESTAMPTZ[] check should not emit a subquery CHECK: {expr}"
);
assert!(
!expr.contains(">= ALL(\"slots\")"),
"TIMESTAMPTZ[] check must not fall back to upper-bound-only ALL strategy \
(admits -infinity): {expr}"
);
}
#[test]
fn field_type_check_for_date_array_elements_enforces_representability() {
let expr = field_type_check(&FieldSqlType::DateArray, None, "validity")
.expect("DATE[] must carry per-element representability checks");
assert!(
expr.contains("\"validity\" IS NULL OR"),
"DATE[] outer NULL should pass through: {expr}"
);
assert!(
expr.contains("djogi.__djogi_date_array_is_finite_v1(\"validity\")"),
"DATE[] check must use the isfinite helper to reject both ±infinity: {expr}"
);
assert!(
!expr.contains("NOT EXISTS (SELECT 1 FROM unnest(\"validity\")"),
"DATE[] check should not emit a subquery CHECK: {expr}"
);
assert!(
!expr.contains(">= ALL(\"validity\")"),
"DATE[] check must not fall back to upper-bound-only ALL strategy \
(admits -infinity): {expr}"
);
}
#[test]
fn field_type_check_for_decimal_array_elements_enforces_representability() {
let expr = field_type_check(&FieldSqlType::NumericArray, None, "metrics")
.expect("NUMERIC[] must carry per-element representability checks");
assert!(
expr.contains("\"metrics\" IS NULL OR"),
"NUMERIC[] outer NULL should pass through: {expr}"
);
assert!(
expr.contains("djogi.__djogi_numeric_array_is_rust_decimal_v1(\"metrics\")"),
"NUMERIC[] check should use helper-backed representability CHECK: {expr}"
);
assert!(
!expr.contains("NOT EXISTS (SELECT 1 FROM unnest(\"metrics\")"),
"NUMERIC[] check should not emit a subquery CHECK: {expr}"
);
assert!(
!expr.contains("scale(element)"),
"NUMERIC[] check should centralize decimal logic in helper: {expr}"
);
assert!(
expr.contains("djogi.__djogi_numeric_array_is_rust_decimal_v1(\"metrics\")"),
"NUMERIC[] check should reuse scalar decimal representability policy: {expr}"
);
}
#[test]
fn project_column_emits_range_endpoint_checks_and_noops_for_int_ranged() {
static FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
..field_descriptor(
"slots",
FieldSqlType::Range {
subtype: RangeSubtypeKind::Int4,
},
false,
)
},
FieldDescriptor {
..field_descriptor(
"money",
FieldSqlType::Range {
subtype: RangeSubtypeKind::Num,
},
false,
)
},
FieldDescriptor {
..field_descriptor(
"window",
FieldSqlType::Range {
subtype: RangeSubtypeKind::Ts,
},
false,
)
},
FieldDescriptor {
..field_descriptor(
"booking_window",
FieldSqlType::Range {
subtype: RangeSubtypeKind::Tstz,
},
false,
)
},
FieldDescriptor {
..field_descriptor(
"validity",
FieldSqlType::Range {
subtype: RangeSubtypeKind::Date,
},
false,
)
},
];
let m = ModelDescriptor {
fields: FIELDS,
..synth_model("offers", "Offer")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-05-17T00:00:00Z".to_string(),
)
.expect("ok");
let rows = &buckets[&empty_global()].models["offers"].columns;
let slots = &rows[1];
let money = &rows[2];
let window = &rows[3];
let booking_window = &rows[4];
let validity = &rows[5];
assert!(slots.check.is_none(), "INT4RANGE should stay no-op");
assert!(
money
.check
.as_deref()
.expect("NUMRANGE must carry endpoint checks")
.contains("lower(\"money\") IS NULL OR (scale(lower(\"money\")) IS NOT NULL AND scale(lower(\"money\")) <= 28"),
"NUMRANGE lower endpoint must use DECIMAL element check with special-value guard"
);
assert!(
money
.check
.as_deref()
.expect("NUMRANGE must carry endpoint checks")
.contains("upper(\"money\") IS NULL OR (scale(upper(\"money\")) IS NOT NULL AND scale(upper(\"money\")) <= 28"),
"NUMRANGE upper endpoint must use DECIMAL element check with special-value guard"
);
assert!(
window.check.as_deref().expect("TSRANGE must carry endpoint checks").contains(
"lower(\"window\") IS NULL OR (pg_catalog.isfinite(lower(\"window\")) AND lower(\"window\") <= TIMESTAMP '9999-12-31 23:59:59.999999'"
),
"TSRANGE lower endpoint must use finite-guarded TIMESTAMP upper bound"
);
assert!(
window
.check
.as_deref()
.expect("TSRANGE must carry endpoint checks")
.contains("upper(\"window\") IS NULL OR (pg_catalog.isfinite(upper(\"window\")) AND upper(\"window\") <= TIMESTAMP '9999-12-31 23:59:59.999999'"),
"TSRANGE upper endpoint must use finite-guarded TIMESTAMP upper bound"
);
assert!(
booking_window.check.as_deref().expect("TSTZRANGE must carry endpoint checks").contains(
"lower(\"booking_window\") IS NULL OR (pg_catalog.isfinite(lower(\"booking_window\")) AND lower(\"booking_window\") <= TIMESTAMPTZ '9999-12-31 23:59:59.999999+00'"
),
"TSTZRANGE lower endpoint must use finite-guarded TIMESTAMPTZ upper bound"
);
assert!(
booking_window
.check
.as_deref()
.expect("TSTZRANGE must carry endpoint checks")
.contains("upper(\"booking_window\") IS NULL OR (pg_catalog.isfinite(upper(\"booking_window\")) AND upper(\"booking_window\") <= TIMESTAMPTZ '9999-12-31 23:59:59.999999+00'"),
"TSTZRANGE upper endpoint must use finite-guarded TIMESTAMPTZ upper bound"
);
assert!(
validity
.check
.as_deref()
.expect("DATERANGE must carry endpoint checks")
.contains(
"lower(\"validity\") IS NULL OR (pg_catalog.isfinite(lower(\"validity\")) AND lower(\"validity\") <= DATE '9999-12-31'"
),
"DATERANGE lower endpoint must use finite-guarded DATE upper bound"
);
assert!(
validity
.check
.as_deref()
.expect("DATERANGE must carry endpoint checks")
.contains(
"upper(\"validity\") IS NULL OR (pg_catalog.isfinite(upper(\"validity\")) AND upper(\"validity\") <= DATE '9999-12-31'"
),
"DATERANGE upper endpoint must use finite-guarded DATE upper bound"
);
}
#[test]
fn project_column_emits_array_element_representability_checks() {
static FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
..field_descriptor("slots", FieldSqlType::TimestamptzArray, false)
},
FieldDescriptor {
..field_descriptor("validity", FieldSqlType::DateArray, false)
},
FieldDescriptor {
..field_descriptor("metrics", FieldSqlType::NumericArray, false)
},
];
let m = ModelDescriptor {
fields: FIELDS,
..synth_model("offers", "Offer")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-05-17T00:00:00Z".to_string(),
)
.expect("ok");
let rows = &buckets[&empty_global()].models["offers"].columns;
let slots = &rows[1];
let validity = &rows[2];
let metrics = &rows[3];
let slot_check = slots
.check
.as_deref()
.expect("TIMESTAMPTZ[] column must carry per-element representability check");
let validity_check = validity
.check
.as_deref()
.expect("DATE[] column must carry per-element representability check");
let metrics_check = metrics
.check
.as_deref()
.expect("NUMERIC[] column must carry per-element representability check");
assert!(
slot_check.contains("djogi.__djogi_tstz_array_is_finite_v1(\"slots\")"),
"TIMESTAMPTZ[] projection must use isfinite helper (rejects ±infinity): {slot_check}"
);
assert!(
!slot_check.contains(">= ALL(\"slots\")"),
"TIMESTAMPTZ[] projection must not use upper-bound-only ALL (admits -infinity): \
{slot_check}"
);
assert!(
validity_check.contains("djogi.__djogi_date_array_is_finite_v1(\"validity\")"),
"DATE[] projection must use isfinite helper (rejects ±infinity): {validity_check}"
);
assert!(
!validity_check.contains(">= ALL(\"validity\")"),
"DATE[] projection must not use upper-bound-only ALL (admits -infinity): \
{validity_check}"
);
assert!(
metrics_check.contains("djogi.__djogi_numeric_array_is_rust_decimal_v1(\"metrics\")"),
"NUMERIC[] projection should use helper-backed check: {metrics_check}"
);
assert!(
!metrics_check.contains("element"),
"NUMERIC[] projection should not emit per-element aliases: {metrics_check}"
);
}
#[test]
fn combine_check_expressions_neither_present_returns_none() {
assert!(combine_check_expressions(None, None).is_none());
}
#[test]
fn combine_check_expressions_only_type_derived() {
let combined = combine_check_expressions(Some("\"qty\" >= 0".into()), None);
assert_eq!(combined.as_deref(), Some("\"qty\" >= 0"));
}
#[test]
fn combine_check_expressions_only_adopter() {
let combined = combine_check_expressions(None, Some("weight_kg > 0"));
assert_eq!(combined.as_deref(), Some("weight_kg > 0"));
}
#[test]
fn combine_check_expressions_both_present_combines_with_and() {
let combined = combine_check_expressions(
Some("\"port\" >= 0 AND \"port\" <= 65535".into()),
Some("port <> 0"),
);
assert_eq!(
combined.as_deref(),
Some("(\"port\" >= 0 AND \"port\" <= 65535) AND (port <> 0)")
);
}
#[test]
fn combine_check_expressions_trims_whitespace_for_stable_diff() {
let combined =
combine_check_expressions(Some(" type_clause ".into()), Some(" adopter_clause "));
assert_eq!(
combined.as_deref(),
Some("(type_clause) AND (adopter_clause)")
);
}
#[test]
fn project_column_propagates_adopter_check_sql_only() {
static FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
check_sql: Some("weight_kg > 0"),
..field_descriptor("weight_kg", FieldSqlType::DoublePrecision, false)
},
];
let m = ModelDescriptor {
fields: FIELDS,
..synth_model("animals", "Animal")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-05-16T00:00:00Z".to_string(),
)
.expect("ok");
let col = &buckets[&empty_global()].models["animals"].columns[1];
assert_eq!(col.name, "weight_kg");
assert_eq!(
col.check.as_deref(),
Some("weight_kg > 0"),
"adopter #[field(check)] on a DoublePrecision column lands verbatim"
);
}
#[test]
fn project_column_combines_type_check_and_adopter_check_on_u32() {
static FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
rust_source_type: Some(RustSourceType::U32),
check_sql: Some("port > 0"),
..field_descriptor("port", FieldSqlType::BigInt, false)
},
];
let m = ModelDescriptor {
fields: FIELDS,
..synth_model("listeners", "Listener")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-05-16T00:00:00Z".to_string(),
)
.expect("ok");
let col = &buckets[&empty_global()].models["listeners"].columns[1];
let check = col
.check
.as_deref()
.expect("u32 column with adopter check must carry the combined type+adopter CHECK");
assert!(
check.contains("\"port\" >= 0 AND \"port\" <= 4294967295"),
"combined CHECK must include the u32 range bound: {check}"
);
assert!(
check.contains("port > 0"),
"combined CHECK must include the adopter expression verbatim: {check}"
);
assert!(
check.starts_with("("),
"combined CHECK must wrap each clause in parens: {check}"
);
assert!(
check.contains(") AND ("),
"combined CHECK must AND the two clauses: {check}"
);
}
#[test]
fn project_column_combines_range_type_check_and_adopter_check() {
static FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
check_sql: Some("window IS NOT NULL"),
..field_descriptor(
"window",
FieldSqlType::Range {
subtype: RangeSubtypeKind::Date,
},
false,
)
},
];
let m = ModelDescriptor {
fields: FIELDS,
..synth_model("offers", "Offer")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-05-17T00:00:00Z".to_string(),
)
.expect("ok");
let col = &buckets[&empty_global()].models["offers"].columns[1];
let check = col
.check
.as_deref()
.expect("range column with adopter check must carry the combined constraint");
assert!(
check.contains("window IS NOT NULL"),
"combined CHECK must include adopter predicate verbatim: {check}"
);
assert!(
check.contains("lower(\"window\") <= DATE '9999-12-31'"),
"combined CHECK must include DATE endpoint bound predicate: {check}"
);
assert!(
check.contains(") AND (window IS NOT NULL)"),
"combined CHECK must merge with logical AND inside one constraint slot: {check}"
);
}
#[test]
fn project_column_combines_array_type_check_and_adopter_check() {
static FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
check_sql: Some("CARDINALITY(\"times\") > 0"),
..field_descriptor("times", FieldSqlType::TimestamptzArray, false)
},
];
let m = ModelDescriptor {
fields: FIELDS,
..synth_model("offers", "Offer")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-05-17T00:00:00Z".to_string(),
)
.expect("ok");
let col = &buckets[&empty_global()].models["offers"].columns[1];
let check = col
.check
.as_deref()
.expect("array column with adopter check must carry the combined constraint");
assert!(
check.contains("CARDINALITY(\"times\") > 0"),
"combined CHECK should include adopter predicate verbatim: {check}"
);
assert!(
check.contains("djogi.__djogi_tstz_array_is_finite_v1(\"times\")"),
"combined CHECK should include isfinite helper for TIMESTAMPTZ[] type bound: {check}"
);
assert!(
check.contains(") AND (CARDINALITY(\"times\") > 0)"),
"combined CHECK should include logical AND between clauses: {check}"
);
}
#[test]
fn project_column_emits_adopter_check_on_fk_column() {
static OWNER_FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
}];
static VEHICLE_FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
relation_kind: Some(crate::descriptor::RelationKind::ForeignKey),
target_type_name: Some("Owner"),
check_sql: Some("owner_id > 0"),
..field_descriptor("owner_id", FieldSqlType::BigInt, false)
},
];
let owner = ModelDescriptor {
fields: OWNER_FIELDS,
..synth_model("owners", "Owner")
};
let vehicle = ModelDescriptor {
fields: VEHICLE_FIELDS,
..synth_model("vehicles", "Vehicle")
};
let buckets = project_from_iters(
[&owner, &vehicle],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-05-16T00:00:00Z".to_string(),
)
.expect("ok");
let owner_fk = &buckets[&empty_global()].models["vehicles"].columns[1];
assert_eq!(owner_fk.name, "owner_id");
assert!(
owner_fk.foreign_key.is_some(),
"owner_id must project as FK: {owner_fk:?}"
);
assert_eq!(
owner_fk.check.as_deref(),
Some("owner_id > 0"),
"adopter #[field(check)] on FK column must reach ColumnSchema.check"
);
}
#[test]
fn project_column_emits_year_check_for_framework_timestamps() {
static FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
..field_descriptor("created_at", FieldSqlType::Timestamptz, false)
},
FieldDescriptor {
..field_descriptor("updated_at", FieldSqlType::Timestamptz, false)
},
];
let m = ModelDescriptor {
fields: FIELDS,
..synth_model("widgets", "Widget")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let cols = &buckets[&empty_global()].models["widgets"].columns;
let created_at = cols
.iter()
.find(|c| c.name == "created_at")
.expect("explicit created_at column projected");
let updated_at = cols
.iter()
.find(|c| c.name == "updated_at")
.expect("explicit updated_at column projected");
for col in [created_at, updated_at] {
assert_eq!(
col.default_sql.as_deref(),
Some("now()"),
"{} keeps DEFAULT now() alongside the year CHECK",
col.name
);
let check = col.check.as_ref().unwrap_or_else(|| {
panic!(
"framework Timestamptz column {} must carry the \
OffsetDateTime representability CHECK (djogi#187)",
col.name
)
});
assert!(
check.contains("TIMESTAMPTZ '9999-12-31 23:59:59.999999+00'"),
"{} CHECK upper bound must use UTC-explicit TIMESTAMPTZ form: {check}",
col.name
);
assert!(
check.contains(&format!("pg_catalog.isfinite(\"{}\")", col.name)),
"{} framework column CHECK must reject ±infinity via the finite guard: {check}",
col.name
);
}
}
#[test]
fn strict_id_check_expr_for_heerid_family_emits_nonneg_bound() {
let check = strict_id_check_expr(StrictIdFamily::HeerId, "owner_id")
.expect("HeerId family must receive the structural CHECK");
assert_eq!(check, "\"owner_id\" >= 0");
}
#[test]
fn strict_id_check_expr_for_ranjid_family_emits_version_variant() {
let check = strict_id_check_expr(StrictIdFamily::RanjId, "plate_id")
.expect("RanjId family must receive the structural CHECK");
assert!(
check.contains("pg_catalog.substring(\"plate_id\"::text, 15, 1) = '8'"),
"RanjId CHECK must constrain version nibble to UUIDv8 (`8`): {check}"
);
assert!(
check.contains("pg_catalog.substring(\"plate_id\"::text, 20, 1) IN ('8','9','a','b')"),
"RanjId CHECK must constrain variant nibble to RFC 4122 (`10xx`): {check}"
);
}
#[test]
fn strict_id_check_expr_for_none_family_returns_none() {
assert!(strict_id_check_expr(StrictIdFamily::None, "any_id").is_none());
}
#[test]
fn strict_id_family_of_pk_maps_heeranjid_variants_correctly() {
assert_eq!(
strict_id_family_of_pk(&PkType::HeerId),
StrictIdFamily::HeerId
);
assert_eq!(
strict_id_family_of_pk(&PkType::HeerIdDesc),
StrictIdFamily::HeerId
);
assert_eq!(
strict_id_family_of_pk(&PkType::RanjId),
StrictIdFamily::RanjId
);
assert_eq!(
strict_id_family_of_pk(&PkType::RanjIdDesc),
StrictIdFamily::RanjId
);
assert_eq!(
strict_id_family_of_pk(&PkType::Serial),
StrictIdFamily::None
);
assert_eq!(strict_id_family_of_pk(&PkType::None), StrictIdFamily::None);
const COMPOSITE_COLS: &[&str] = &["a", "b"];
assert_eq!(
strict_id_family_of_pk(&PkType::Composite(COMPOSITE_COLS)),
StrictIdFamily::None
);
const CUSTOM_BIGINT: crate::descriptor::CustomPrimaryKeyKind =
crate::descriptor::CustomPrimaryKeyKind {
type_name: "crate::ids::CustomBigInt",
sql_type: "BIGINT",
default_sql: "make_custom()",
};
assert_eq!(
strict_id_family_of_pk(&PkType::Custom(CUSTOM_BIGINT)),
StrictIdFamily::None,
"Custom PK with BIGINT carrier must NOT inherit HeerId family"
);
const CUSTOM_UUID: crate::descriptor::CustomPrimaryKeyKind =
crate::descriptor::CustomPrimaryKeyKind {
type_name: "crate::ids::CustomUuid",
sql_type: "UUID",
default_sql: "uuid_generate_v4()",
};
assert_eq!(
strict_id_family_of_pk(&PkType::Custom(CUSTOM_UUID)),
StrictIdFamily::None,
"Custom PK with UUID carrier must NOT inherit RanjId family"
);
}
#[test]
fn project_column_default_off_no_strict_check_on_heerid_id() {
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
}];
let m = ModelDescriptor {
fields: FIELDS,
..synth_model("widgets", "Widget")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-05-19T00:00:00Z".to_string(),
)
.expect("ok");
let id_col = &buckets[&empty_global()].models["widgets"].columns[0];
assert_eq!(id_col.name, "id");
assert!(
id_col.check.is_none(),
"default-off HeerId id column must carry no CHECK; found: {:?}",
id_col.check
);
}
#[test]
fn project_column_strict_id_check_on_heerid_id() {
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
strict_id_check: true,
..field_descriptor("id", FieldSqlType::BigInt, false)
}];
let m = ModelDescriptor {
fields: FIELDS,
..synth_model("widgets", "Widget")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-05-19T00:00:00Z".to_string(),
)
.expect("ok");
let id_col = &buckets[&empty_global()].models["widgets"].columns[0];
assert_eq!(id_col.name, "id");
let check = id_col
.check
.as_deref()
.expect("strict_id_check on a BigInt column must project a CHECK");
assert_eq!(check, "\"id\" >= 0");
}
#[test]
fn project_column_strict_id_check_on_ranjid_id() {
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
strict_id_check: true,
..field_descriptor("id", FieldSqlType::Uuid, false)
}];
let m = ModelDescriptor {
fields: FIELDS,
pk_type: PkType::RanjId,
..synth_model("plates", "Plate")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-05-19T00:00:00Z".to_string(),
)
.expect("ok");
let id_col = &buckets[&empty_global()].models["plates"].columns[0];
let check = id_col
.check
.as_deref()
.expect("strict_id_check on a Uuid column must project a CHECK");
assert!(
check.contains("version") || check.contains("substring"),
"RanjId CHECK should use substring/version-extraction shape: {check}"
);
assert!(
check.contains("= '8'"),
"RanjId CHECK must constrain version to UUIDv8: {check}"
);
assert!(
check.contains("IN ('8','9','a','b')"),
"RanjId CHECK must constrain variant to RFC 4122: {check}"
);
}
#[test]
fn project_column_strict_id_check_propagates_to_fk_on_heerid_target() {
static OWNER_FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
}];
static VEHICLE_FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
relation_kind: Some(crate::descriptor::RelationKind::ForeignKey),
target_type_name: Some("Owner"),
strict_id_check: true,
..field_descriptor("owner_id", FieldSqlType::BigInt, false)
},
];
let owner = ModelDescriptor {
fields: OWNER_FIELDS,
..synth_model("owners", "Owner")
};
let vehicle = ModelDescriptor {
fields: VEHICLE_FIELDS,
..synth_model("vehicles", "Vehicle")
};
let buckets = project_from_iters(
[&owner, &vehicle],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-05-19T00:00:00Z".to_string(),
)
.expect("ok");
let fk_col = &buckets[&empty_global()].models["vehicles"].columns[1];
assert!(fk_col.foreign_key.is_some(), "owner_id must project as FK");
assert_eq!(
fk_col.check.as_deref(),
Some("\"owner_id\" >= 0"),
"strict_id_check on an FK to a HeerId-PK target must emit the BIGINT CHECK"
);
}
#[test]
fn project_column_strict_id_check_skipped_on_fk_to_serial_target() {
static OWNER_FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor("id", FieldSqlType::Integer, false)
}];
static VEHICLE_FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
relation_kind: Some(crate::descriptor::RelationKind::ForeignKey),
target_type_name: Some("Owner"),
strict_id_check: true,
..field_descriptor("owner_id", FieldSqlType::BigInt, false)
},
];
let owner = ModelDescriptor {
fields: OWNER_FIELDS,
pk_type: PkType::Serial,
..synth_model("owners", "Owner")
};
let vehicle = ModelDescriptor {
fields: VEHICLE_FIELDS,
..synth_model("vehicles", "Vehicle")
};
let buckets = project_from_iters(
[&owner, &vehicle],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-05-19T00:00:00Z".to_string(),
)
.expect("ok");
let fk_col = &buckets[&empty_global()].models["vehicles"].columns[1];
assert!(fk_col.foreign_key.is_some(), "owner_id must project as FK");
assert!(
fk_col.check.is_none(),
"strict_id_check on an FK to a Serial-PK target must skip the CHECK; found: {:?}",
fk_col.check
);
}
#[test]
fn project_column_strict_id_check_skipped_on_custom_bigint_pk_id() {
const CUSTOM_BIGINT_PK: crate::descriptor::CustomPrimaryKeyKind =
crate::descriptor::CustomPrimaryKeyKind {
type_name: "crate::ids::WidgetId",
sql_type: "BIGINT",
default_sql: "make_widget_id()",
};
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
strict_id_check: true,
..field_descriptor("id", FieldSqlType::Custom("BIGINT"), false)
}];
let m = ModelDescriptor {
fields: FIELDS,
pk_type: PkType::Custom(CUSTOM_BIGINT_PK),
..synth_model("widgets", "Widget")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-05-19T00:00:00Z".to_string(),
)
.expect("ok");
let id_col = &buckets[&empty_global()].models["widgets"].columns[0];
assert_eq!(id_col.name, "id");
assert!(
id_col.check.is_none(),
"Custom BIGINT-shaped PK must NOT receive the HeerId positivity CHECK; found: {:?}",
id_col.check
);
}
#[test]
fn project_column_strict_id_check_skipped_on_custom_uuid_pk_id() {
const CUSTOM_UUID_PK: crate::descriptor::CustomPrimaryKeyKind =
crate::descriptor::CustomPrimaryKeyKind {
type_name: "crate::ids::PlateId",
sql_type: "UUID",
default_sql: "uuid_generate_v4()",
};
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
strict_id_check: true,
..field_descriptor("id", FieldSqlType::Custom("UUID"), false)
}];
let m = ModelDescriptor {
fields: FIELDS,
pk_type: PkType::Custom(CUSTOM_UUID_PK),
..synth_model("plates", "Plate")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-05-19T00:00:00Z".to_string(),
)
.expect("ok");
let id_col = &buckets[&empty_global()].models["plates"].columns[0];
assert!(
id_col.check.is_none(),
"Custom UUID-shaped PK must NOT receive the RanjId UUIDv8 CHECK; found: {:?}",
id_col.check
);
}
#[test]
fn project_column_strict_id_check_skipped_on_fk_to_custom_bigint_target() {
const CUSTOM_BIGINT_PK: crate::descriptor::CustomPrimaryKeyKind =
crate::descriptor::CustomPrimaryKeyKind {
type_name: "crate::ids::OwnerId",
sql_type: "BIGINT",
default_sql: "make_owner_id()",
};
static OWNER_FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor("id", FieldSqlType::Custom("BIGINT"), false)
}];
static VEHICLE_FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
relation_kind: Some(crate::descriptor::RelationKind::ForeignKey),
target_type_name: Some("Owner"),
strict_id_check: true,
..field_descriptor("owner_id", FieldSqlType::BigInt, false)
},
];
let owner = ModelDescriptor {
fields: OWNER_FIELDS,
pk_type: PkType::Custom(CUSTOM_BIGINT_PK),
..synth_model("owners", "Owner")
};
let vehicle = ModelDescriptor {
fields: VEHICLE_FIELDS,
..synth_model("vehicles", "Vehicle")
};
let buckets = project_from_iters(
[&owner, &vehicle],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-05-19T00:00:00Z".to_string(),
)
.expect("ok");
let fk_col = &buckets[&empty_global()].models["vehicles"].columns[1];
assert!(fk_col.foreign_key.is_some(), "owner_id must project as FK");
assert_eq!(
fk_col.sql_type, "BIGINT",
"FK SQL type must still inherit from Custom.sql_type"
);
assert!(
fk_col.check.is_none(),
"FK to a Custom BIGINT-shaped PK must NOT receive the HeerId positivity CHECK; \
found: {:?}",
fk_col.check
);
}
#[test]
fn project_column_strict_id_check_skipped_on_fk_to_custom_uuid_target() {
const CUSTOM_UUID_PK: crate::descriptor::CustomPrimaryKeyKind =
crate::descriptor::CustomPrimaryKeyKind {
type_name: "crate::ids::TenantId",
sql_type: "UUID",
default_sql: "uuid_generate_v4()",
};
static TENANT_FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor("id", FieldSqlType::Custom("UUID"), false)
}];
static USER_FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
relation_kind: Some(crate::descriptor::RelationKind::ForeignKey),
target_type_name: Some("Tenant"),
strict_id_check: true,
..field_descriptor("tenant_id", FieldSqlType::BigInt, false)
},
];
let tenant = ModelDescriptor {
fields: TENANT_FIELDS,
pk_type: PkType::Custom(CUSTOM_UUID_PK),
..synth_model("tenants", "Tenant")
};
let user = ModelDescriptor {
fields: USER_FIELDS,
..synth_model("users", "User")
};
let buckets = project_from_iters(
[&tenant, &user],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-05-19T00:00:00Z".to_string(),
)
.expect("ok");
let fk_col = &buckets[&empty_global()].models["users"].columns[1];
assert!(fk_col.foreign_key.is_some(), "tenant_id must project as FK");
assert_eq!(
fk_col.sql_type, "UUID",
"FK SQL type must still inherit from Custom.sql_type (UUID)"
);
assert!(
fk_col.check.is_none(),
"FK to a Custom UUID-shaped PK must NOT receive the RanjId UUIDv8 CHECK; \
found: {:?}",
fk_col.check
);
}
#[test]
fn project_column_combines_strict_id_check_with_adopter_check() {
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
strict_id_check: true,
check_sql: Some("\"id\" <> 0"),
..field_descriptor("id", FieldSqlType::BigInt, false)
}];
let m = ModelDescriptor {
fields: FIELDS,
..synth_model("widgets", "Widget")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-05-19T00:00:00Z".to_string(),
)
.expect("ok");
let id_col = &buckets[&empty_global()].models["widgets"].columns[0];
let check = id_col
.check
.as_deref()
.expect("combined strict_id_check + adopter check must reach ColumnSchema.check");
assert!(
check.contains("\"id\" >= 0"),
"combined CHECK must include the HeerId structural bound: {check}"
);
assert!(
check.contains("\"id\" <> 0"),
"combined CHECK must include the adopter predicate verbatim: {check}"
);
assert!(
check.contains(") AND ("),
"combined CHECK must AND-merge the two clauses: {check}"
);
}
#[test]
fn framework_pk_does_not_synthesize_id_idx_on_fresh_addtable() {
static FRAMEWORK_ID: &[FieldDescriptor] = &[FieldDescriptor {
unique: true,
indexed: false, ..field_descriptor("id", FieldSqlType::BigInt, false)
}];
let m = ModelDescriptor {
pk_type: PkType::HeerIdDesc,
fields: FRAMEWORK_ID,
..synth_model("trackers", "Tracker")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let global = &buckets[&empty_global()];
assert!(
global.indexes.is_empty(),
"expected no indexes but got: {:?}",
global
.indexes
.iter()
.map(|i| i.name.as_str())
.collect::<Vec<_>>()
);
}
#[test]
fn explicit_index_declaration_survives_when_field_level_synthetic_has_same_name() {
static STATUS_COL: &[IndexColumnSpec] = &[IndexColumnSpec::simple("status")];
static EXPLICIT_IDX: &[IndexSpec] = &[IndexSpec {
name: "orders_status_idx", target: IndexTarget::Columns(STATUS_COL),
kind: IndexKind::NonUnique,
index_type: IndexType::BTree,
predicate: Some("status != 'closed'"),
include: &[],
nulls_not_distinct: false,
requires_out_of_transaction: false,
extension_dependency: None,
}];
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
indexed: true,
..field_descriptor("status", FieldSqlType::Text, false)
}];
let m = ModelDescriptor {
indexes: EXPLICIT_IDX,
fields: FIELDS,
..synth_model("orders", "Order")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let global = &buckets[&empty_global()];
assert_eq!(
global.indexes.len(),
1,
"expected exactly one index but got: {:?}",
global
.indexes
.iter()
.map(|i| i.name.as_str())
.collect::<Vec<_>>()
);
let idx = &global.indexes[0];
assert_eq!(idx.name, "orders_status_idx");
assert_eq!(
idx.predicate.as_deref(),
Some("status != 'closed'"),
"explicit partial-index predicate must survive when field-level synthetic has the same canonical name"
);
}
#[test]
fn field_indexed_true_synthesises_one_canonical_index_in_global() {
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
indexed: true,
..field_descriptor("label", FieldSqlType::Text, false)
}];
let m = ModelDescriptor {
fields: FIELDS,
..synth_model("tags", "Tag")
};
let buckets = project_from_iters(
[&m],
std::iter::empty::<&EnumDescriptor>(),
std::iter::empty::<&AppDescriptor>(),
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let global = &buckets[&empty_global()];
assert_eq!(
global.indexes.len(),
1,
"expected exactly one synthetic index but got: {:?}",
global
.indexes
.iter()
.map(|i| i.name.as_str())
.collect::<Vec<_>>()
);
assert_eq!(
global.indexes[0].name, "tags_label_idx",
"synthetic field-level index must follow the <table>_<col>_idx naming convention"
);
assert_eq!(
global.indexes[0].table, "tags",
"synthetic index must be owned by the `tags` table"
);
}
#[test]
fn project_from_provider_runs_validator_and_projects_empty() {
struct EmptyProvider;
impl crate::migrate::DescriptorProvider for EmptyProvider {
fn models(&self) -> Vec<&'static crate::descriptor::ModelDescriptor> {
Vec::new()
}
fn enums(&self) -> Vec<&'static crate::descriptor::EnumDescriptor> {
Vec::new()
}
fn apps(&self) -> &'static [crate::apps::AppDescriptor] {
crate::apps::AppRegistry::all()
}
fn deferrability_specs(&self) -> Vec<&'static crate::descriptor::DeferrabilitySpec> {
Vec::new()
}
}
let out = project_from_provider(&EmptyProvider).expect("clean registry projects");
let global = BucketKey {
database: "main".to_string(),
app: String::new(),
};
assert!(out.contains_key(&global), "global bucket always present");
}
#[test]
fn bucket_enums_scoped_to_referencing_tables() {
static USERS_FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
..field_descriptor("status", FieldSqlType::Custom("mood"), true)
},
];
static SYSTEM_FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor("priority", FieldSqlType::Custom("level"), true)
}];
let users_app = synth_app("users", "main");
let system_app = synth_app("system", "main");
let user_model = ModelDescriptor {
app: Some("users"),
fields: USERS_FIELDS,
..synth_model("users", "User")
};
let system_model = ModelDescriptor {
app: Some("system"),
fields: SYSTEM_FIELDS,
..synth_model("system_config", "SystemConfig")
};
let mood_enum = EnumDescriptor {
type_name: "Mood",
postgres_type: "mood",
variants: &["happy", "sad", "neutral"],
};
let level_enum = EnumDescriptor {
type_name: "Level",
postgres_type: "level",
variants: &["low", "medium", "high"],
};
let buckets = project_from_iters(
[&user_model, &system_model],
[&mood_enum, &level_enum],
[&users_app, &system_app],
"2026-04-25T00:00:00Z".to_string(),
)
.expect("ok");
let users_bucket = BucketKey {
database: "main".to_string(),
app: "users".to_string(),
};
let system_bucket = BucketKey {
database: "main".to_string(),
app: "system".to_string(),
};
let users_schema = buckets.get(&users_bucket).expect("users bucket present");
assert!(
users_schema.enums.contains_key("mood"),
"users bucket must contain mood enum (referenced by users.status)"
);
assert!(
!users_schema.enums.contains_key("level"),
"users bucket must NOT contain level enum (not referenced by any users table)"
);
let system_schema = buckets.get(&system_bucket).expect("system bucket present");
assert!(
system_schema.enums.contains_key("level"),
"system bucket must contain level enum (referenced by system_config.priority)"
);
assert!(
!system_schema.enums.contains_key("mood"),
"system bucket must NOT contain mood enum (not referenced by any system table)"
);
let global = buckets.get(&empty_global()).expect("global bucket present");
assert!(
global.enums.is_empty(),
"global bucket must have no enums (no models reference any enum)"
);
}
}