pub use crate::relation::{OnDelete, RelationKind};
pub use crate::fts::FtsDescriptor;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum GeographySubtype {
Point,
LineString,
Polygon,
MultiPoint,
MultiLineString,
MultiPolygon,
}
impl std::fmt::Display for GeographySubtype {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Point => "Point",
Self::LineString => "LineString",
Self::Polygon => "Polygon",
Self::MultiPoint => "MultiPoint",
Self::MultiLineString => "MultiLineString",
Self::MultiPolygon => "MultiPolygon",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FieldSqlType {
Text,
Varchar(u32),
SmallInt,
Integer,
BigInt,
Real,
DoublePrecision,
Boolean,
Timestamptz,
Date,
Numeric,
NumericPrecision {
precision: u8,
scale: u8,
},
Uuid,
Jsonb,
TextArray,
IntegerArray,
BigIntArray,
BoolArray,
SmallIntArray,
RealArray,
DoublePrecisionArray,
TimestamptzArray,
DateArray,
UuidArray,
NumericArray,
Citext,
Geography {
subtype: GeographySubtype,
srid: u32,
},
Interval,
Inet,
Cidr,
Macaddr,
Range {
subtype: RangeSubtypeKind,
},
Domain {
name: &'static str,
base: &'static FieldSqlType,
},
Custom(&'static str),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RangeSubtypeKind {
Int4,
Int8,
Num,
Ts,
Tstz,
Date,
}
impl std::fmt::Display for RangeSubtypeKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RangeSubtypeKind::Int4 => write!(f, "int4range"),
RangeSubtypeKind::Int8 => write!(f, "int8range"),
RangeSubtypeKind::Num => write!(f, "numrange"),
RangeSubtypeKind::Ts => write!(f, "tsrange"),
RangeSubtypeKind::Tstz => write!(f, "tstzrange"),
RangeSubtypeKind::Date => write!(f, "daterange"),
}
}
}
pub trait DjogiSqlType {
const SQL_TYPE: &'static str;
}
impl std::fmt::Display for FieldSqlType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FieldSqlType::Text => write!(f, "TEXT"),
FieldSqlType::Varchar(n) => write!(f, "VARCHAR({n})"),
FieldSqlType::SmallInt => write!(f, "SMALLINT"),
FieldSqlType::Integer => write!(f, "INTEGER"),
FieldSqlType::BigInt => write!(f, "BIGINT"),
FieldSqlType::Real => write!(f, "REAL"),
FieldSqlType::DoublePrecision => write!(f, "DOUBLE PRECISION"),
FieldSqlType::Boolean => write!(f, "BOOLEAN"),
FieldSqlType::Timestamptz => write!(f, "TIMESTAMPTZ"),
FieldSqlType::Date => write!(f, "DATE"),
FieldSqlType::Numeric => write!(f, "NUMERIC"),
FieldSqlType::NumericPrecision { precision, scale } => {
write!(f, "NUMERIC({precision}, {scale})")
}
FieldSqlType::Uuid => write!(f, "UUID"),
FieldSqlType::Jsonb => write!(f, "JSONB"),
FieldSqlType::TextArray => write!(f, "TEXT[]"),
FieldSqlType::IntegerArray => write!(f, "INTEGER[]"),
FieldSqlType::BigIntArray => write!(f, "BIGINT[]"),
FieldSqlType::BoolArray => write!(f, "BOOLEAN[]"),
FieldSqlType::SmallIntArray => write!(f, "SMALLINT[]"),
FieldSqlType::RealArray => write!(f, "REAL[]"),
FieldSqlType::DoublePrecisionArray => write!(f, "DOUBLE PRECISION[]"),
FieldSqlType::TimestamptzArray => write!(f, "TIMESTAMPTZ[]"),
FieldSqlType::DateArray => write!(f, "DATE[]"),
FieldSqlType::UuidArray => write!(f, "UUID[]"),
FieldSqlType::NumericArray => write!(f, "NUMERIC[]"),
FieldSqlType::Citext => write!(f, "CITEXT"),
FieldSqlType::Geography { subtype, srid } => {
write!(f, "geography({subtype}, {srid})")
}
FieldSqlType::Interval => write!(f, "INTERVAL"),
FieldSqlType::Inet => write!(f, "INET"),
FieldSqlType::Cidr => write!(f, "CIDR"),
FieldSqlType::Macaddr => write!(f, "MACADDR"),
FieldSqlType::Range { subtype } => write!(f, "{subtype}"),
FieldSqlType::Domain { name, .. } => write!(f, "{name}"),
FieldSqlType::Custom(s) => write!(f, "{s}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PartitionSpec {
Range { column: &'static str },
Hash {
column: &'static str,
partitions: u16,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndexType {
BTree,
Gist,
Gin,
Hash,
Spgist,
Brin,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndexKind {
NonUnique,
UniqueConstraint,
UniqueIndex,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndexOrder {
Asc,
Desc,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndexNullsOrder {
Default,
First,
Last,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IndexColumnSpec {
pub name: &'static str,
pub opclass: Option<&'static str>,
pub order: IndexOrder,
pub nulls: IndexNullsOrder,
}
impl IndexColumnSpec {
pub const fn simple(name: &'static str) -> Self {
Self {
name,
opclass: None,
order: IndexOrder::Asc,
nulls: IndexNullsOrder::Default,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndexTarget {
Columns(&'static [IndexColumnSpec]),
Expression(&'static str),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexSpec {
pub name: &'static str,
pub target: IndexTarget,
pub kind: IndexKind,
pub index_type: IndexType,
pub predicate: Option<&'static str>,
pub include: &'static [&'static str],
pub nulls_not_distinct: bool,
pub requires_out_of_transaction: bool,
pub extension_dependency: Option<&'static str>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ExclusionElement {
pub expr: &'static str,
pub with_operator: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ExclusionConstraintSpec {
pub name: &'static str,
pub using: &'static str,
pub elements: &'static [ExclusionElement],
pub where_clause: Option<&'static str>,
pub deferrable: bool,
pub initially_deferred: bool,
pub extension_dependency: Option<&'static str>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndexNameKind {
NonUnique,
UniqueConstraint,
UniqueIndex,
}
#[derive(Debug, Clone, Copy)]
pub enum IndexNameTarget<'a> {
Columns(&'a [&'a str]),
Expression,
}
pub fn index_name(table: &str, kind: IndexNameKind, target: IndexNameTarget<'_>) -> String {
let suffix = match kind {
IndexNameKind::NonUnique => "idx",
IndexNameKind::UniqueConstraint => "key",
IndexNameKind::UniqueIndex => "uidx",
};
let body = match target {
IndexNameTarget::Columns(cols) => cols.join("_"),
IndexNameTarget::Expression => "expr".to_string(),
};
let full = format!("{table}_{body}_{suffix}");
if full.len() <= 63 {
return full;
}
let digest = {
use std::hash::{BuildHasher, BuildHasherDefault, Hasher};
let mut h = BuildHasherDefault::<std::collections::hash_map::DefaultHasher>::default()
.build_hasher();
h.write(full.as_bytes());
let raw = h.finish();
format!("{:08x}", (raw as u32))
};
let stem: String = full.as_bytes()[..55].iter().map(|b| *b as char).collect();
format!("{stem}_{digest}")
}
impl IndexSpec {
pub fn simple(
name: &'static str,
columns: &'static [&'static str],
unique: bool,
index_type: IndexType,
) -> Self {
assert!(
!unique || matches!(index_type, IndexType::BTree),
"IndexSpec::simple: PostgreSQL unique indexes are btree-only; \
cannot construct a unique index with index_type {index_type:?}. \
Drop `unique` for a non-unique non-btree lookup index, or use \
IndexType::BTree."
);
let lifted: Box<[IndexColumnSpec]> =
columns.iter().map(|c| IndexColumnSpec::simple(c)).collect();
let leaked: &'static [IndexColumnSpec] = Box::leak(lifted);
let kind = if unique {
IndexKind::UniqueConstraint
} else {
IndexKind::NonUnique
};
Self {
name,
target: IndexTarget::Columns(leaked),
kind,
index_type,
predicate: None,
include: &[],
nulls_not_distinct: false,
requires_out_of_transaction: false,
extension_dependency: None,
}
}
}
#[cfg(test)]
mod tests {
use super::{
ComputedFieldDescriptor, ExclusionConstraintSpec, ExclusionElement, FieldDescriptor,
FieldSqlType, GeographySubtype, IndexSpec, IndexType, ModelDescriptor, PkType,
RangeSubtypeKind, field_descriptor, migration_shape::MigrationShape, model_descriptor,
};
#[test]
fn geography_point_subtype_displays_unchanged_from_phase_6() {
let ft = FieldSqlType::Geography {
subtype: GeographySubtype::Point,
srid: 4326,
};
assert_eq!(format!("{ft}"), "geography(Point, 4326)");
}
#[test]
fn geography_linestring_subtype_displays_correctly() {
let ft = FieldSqlType::Geography {
subtype: GeographySubtype::LineString,
srid: 4326,
};
assert_eq!(format!("{ft}"), "geography(LineString, 4326)");
}
#[test]
fn geography_polygon_subtype_displays_correctly() {
let ft = FieldSqlType::Geography {
subtype: GeographySubtype::Polygon,
srid: 4326,
};
assert_eq!(format!("{ft}"), "geography(Polygon, 4326)");
}
#[test]
fn geography_multipoint_subtype_displays_correctly() {
let ft = FieldSqlType::Geography {
subtype: GeographySubtype::MultiPoint,
srid: 4326,
};
assert_eq!(format!("{ft}"), "geography(MultiPoint, 4326)");
}
#[test]
fn geography_multipolygon_subtype_displays_correctly() {
let ft = FieldSqlType::Geography {
subtype: GeographySubtype::MultiPolygon,
srid: 4326,
};
assert_eq!(format!("{ft}"), "geography(MultiPolygon, 4326)");
}
#[test]
fn inet_field_sql_type_displays_as_upper_inet() {
assert_eq!(format!("{}", FieldSqlType::Inet), "INET");
}
#[test]
fn cidr_field_sql_type_displays_as_upper_cidr() {
assert_eq!(format!("{}", FieldSqlType::Cidr), "CIDR");
}
#[test]
fn macaddr_field_sql_type_displays_as_upper_macaddr() {
assert_eq!(format!("{}", FieldSqlType::Macaddr), "MACADDR");
}
#[test]
fn tsrange_field_sql_type_displays_as_lower_tsrange() {
let ty = FieldSqlType::Range {
subtype: RangeSubtypeKind::Ts,
};
assert_eq!(format!("{ty}"), "tsrange");
}
#[test]
fn domain_sql_type_displays_as_domain_name() {
static BASE: FieldSqlType = FieldSqlType::Numeric;
let ft = FieldSqlType::Domain {
name: "positive_amount",
base: &BASE,
};
assert_eq!(format!("{ft}"), "positive_amount");
}
#[test]
fn domain_variant_clone_and_eq_round_trip() {
static BASE_NUMERIC: FieldSqlType = FieldSqlType::Numeric;
static BASE_TEXT: FieldSqlType = FieldSqlType::Text;
let a = FieldSqlType::Domain {
name: "positive_amount",
base: &BASE_NUMERIC,
};
let b = a.clone();
assert_eq!(a, b);
let with_text_base = FieldSqlType::Domain {
name: "positive_amount",
base: &BASE_TEXT,
};
assert_ne!(a, with_text_base);
assert_eq!(format!("{a}"), format!("{with_text_base}"));
}
#[test]
fn field_descriptor_const_fn_unchanged_for_non_domain() {
const PLAIN: FieldDescriptor = field_descriptor("amt", FieldSqlType::Numeric, false);
const _: () = {
assert!(matches!(PLAIN.sql_type, FieldSqlType::Numeric));
assert!(!PLAIN.nullable);
assert!(!PLAIN.unique);
assert!(!PLAIN.indexed);
};
assert_eq!(PLAIN.name, "amt");
}
#[test]
fn field_descriptor_const_fn_compiles_with_domain_sql_type() {
static BASE: FieldSqlType = FieldSqlType::Numeric;
const DOM: FieldDescriptor = field_descriptor(
"amount",
FieldSqlType::Domain {
name: "positive_amount",
base: &BASE,
},
false,
);
assert_eq!(DOM.name, "amount");
assert_eq!(format!("{}", DOM.sql_type), "positive_amount");
}
#[test]
fn simple_constructor_defaults_policy_fields_to_benign() {
let spec = IndexSpec::simple("idx", &["col"], false, IndexType::BTree);
assert!(
!spec.requires_out_of_transaction,
"simple() must default requires_out_of_transaction to false"
);
assert_eq!(
spec.extension_dependency, None,
"simple() must default extension_dependency to None"
);
assert_eq!(spec.name, "idx");
assert!(matches!(spec.kind, super::IndexKind::NonUnique));
assert_eq!(spec.index_type, IndexType::BTree);
match spec.target {
super::IndexTarget::Columns(cols) => {
assert_eq!(cols.len(), 1);
assert_eq!(cols[0].name, "col");
}
super::IndexTarget::Expression(_) => panic!("expected Columns target"),
}
assert_eq!(spec.predicate, None);
assert!(spec.include.is_empty());
assert!(!spec.nulls_not_distinct);
}
#[test]
fn index_kind_has_three_variants() {
use super::IndexKind;
let variants = [
IndexKind::NonUnique,
IndexKind::UniqueConstraint,
IndexKind::UniqueIndex,
];
for v in &variants {
match v {
IndexKind::NonUnique | IndexKind::UniqueConstraint | IndexKind::UniqueIndex => {}
}
}
assert_eq!(variants.len(), 3);
}
#[test]
fn index_order_and_nulls_order_variants_exist() {
use super::{IndexNullsOrder, IndexOrder};
let orders = [IndexOrder::Asc, IndexOrder::Desc];
for o in &orders {
match o {
IndexOrder::Asc | IndexOrder::Desc => {}
}
}
assert_eq!(orders.len(), 2);
let nulls = [
IndexNullsOrder::Default,
IndexNullsOrder::First,
IndexNullsOrder::Last,
];
for n in &nulls {
match n {
IndexNullsOrder::Default | IndexNullsOrder::First | IndexNullsOrder::Last => {}
}
}
assert_eq!(nulls.len(), 3);
}
#[test]
fn index_column_spec_simple_has_benign_defaults() {
use super::{IndexColumnSpec, IndexNullsOrder, IndexOrder};
let c = IndexColumnSpec::simple("last");
assert_eq!(c.name, "last");
assert_eq!(c.opclass, None);
assert!(matches!(c.order, IndexOrder::Asc));
assert!(matches!(c.nulls, IndexNullsOrder::Default));
}
#[test]
fn index_target_is_mutually_exclusive_enum() {
use super::{IndexColumnSpec, IndexTarget};
static COLS: &[IndexColumnSpec] = &[IndexColumnSpec::simple("a")];
let columns = IndexTarget::Columns(COLS);
let expr = IndexTarget::Expression("lower(email)");
assert!(matches!(columns, IndexTarget::Columns(_)));
assert!(matches!(expr, IndexTarget::Expression(_)));
}
#[test]
fn index_spec_simple_lifts_str_slice_into_column_specs() {
use super::{IndexKind, IndexSpec, IndexTarget, IndexType};
let spec = IndexSpec::simple("idx", &["first", "last"], false, IndexType::BTree);
match spec.target {
IndexTarget::Columns(cols) => {
assert_eq!(cols.len(), 2);
assert_eq!(cols[0].name, "first");
assert_eq!(cols[1].name, "last");
assert_eq!(cols[0].opclass, None);
assert_eq!(cols[1].opclass, None);
}
IndexTarget::Expression(_) => panic!("expected Columns target"),
}
assert!(matches!(spec.kind, IndexKind::NonUnique));
let reverse = IndexSpec::simple("idx", &["last", "first"], false, IndexType::BTree);
match (spec.target, reverse.target) {
(IndexTarget::Columns(a), IndexTarget::Columns(b)) => {
assert_eq!(a[0].name, "first");
assert_eq!(b[0].name, "last");
}
_ => unreachable!(),
}
}
#[test]
fn index_spec_simple_maps_unique_to_unique_constraint() {
use super::{IndexKind, IndexSpec, IndexType};
let spec = IndexSpec::simple("uix", &["email"], true, IndexType::BTree);
assert!(matches!(spec.kind, IndexKind::UniqueConstraint));
}
#[test]
#[should_panic(expected = "PostgreSQL unique indexes are btree-only")]
fn index_spec_simple_panics_on_unique_with_gin() {
use super::{IndexSpec, IndexType};
let _ = IndexSpec::simple("u", &["payload"], true, IndexType::Gin);
}
#[test]
#[should_panic(expected = "PostgreSQL unique indexes are btree-only")]
fn index_spec_simple_panics_on_unique_with_gist() {
use super::{IndexSpec, IndexType};
let _ = IndexSpec::simple("u", &["loc"], true, IndexType::Gist);
}
#[test]
#[should_panic(expected = "PostgreSQL unique indexes are btree-only")]
fn index_spec_simple_panics_on_unique_with_hash() {
use super::{IndexSpec, IndexType};
let _ = IndexSpec::simple("u", &["slug"], true, IndexType::Hash);
}
#[test]
#[should_panic(expected = "PostgreSQL unique indexes are btree-only")]
fn index_spec_simple_panics_on_unique_with_brin() {
use super::{IndexSpec, IndexType};
let _ = IndexSpec::simple("u", &["happened_at"], true, IndexType::Brin);
}
#[test]
#[should_panic(expected = "PostgreSQL unique indexes are btree-only")]
fn index_spec_simple_panics_on_unique_with_spgist() {
use super::{IndexSpec, IndexType};
let _ = IndexSpec::simple("u", &["path"], true, IndexType::Spgist);
}
#[test]
fn index_spec_simple_accepts_non_unique_with_any_method() {
use super::{IndexSpec, IndexType};
for ty in [
IndexType::BTree,
IndexType::Gin,
IndexType::Gist,
IndexType::Hash,
IndexType::Brin,
IndexType::Spgist,
] {
let spec = IndexSpec::simple("n", &["col"], false, ty);
assert!(matches!(spec.kind, super::IndexKind::NonUnique));
assert_eq!(spec.index_type, ty);
}
}
#[test]
fn index_spec_equality_and_clone_preserve_new_fields() {
use super::{
IndexColumnSpec, IndexKind, IndexNullsOrder, IndexOrder, IndexSpec, IndexTarget,
IndexType,
};
static COLS: &[IndexColumnSpec] = &[IndexColumnSpec {
name: "email",
opclass: Some("text_pattern_ops"),
order: IndexOrder::Desc,
nulls: IndexNullsOrder::First,
}];
let a = IndexSpec {
name: "uniq_email_active",
target: IndexTarget::Columns(COLS),
kind: IndexKind::UniqueIndex,
index_type: IndexType::BTree,
predicate: Some("deleted_at IS NULL"),
include: &["tenant_id"],
nulls_not_distinct: true,
requires_out_of_transaction: true,
extension_dependency: Some("postgis"),
};
let b = a.clone();
assert_eq!(a, b);
assert_eq!(b.predicate, Some("deleted_at IS NULL"));
assert_eq!(b.include, &["tenant_id"]);
assert!(b.nulls_not_distinct);
assert!(matches!(b.kind, IndexKind::UniqueIndex));
match b.target {
IndexTarget::Columns(cs) => {
assert_eq!(cs[0].opclass, Some("text_pattern_ops"));
assert!(matches!(cs[0].order, IndexOrder::Desc));
assert!(matches!(cs[0].nulls, IndexNullsOrder::First));
}
IndexTarget::Expression(_) => panic!("expected Columns target"),
}
let c = IndexSpec::simple("idx", &["col"], false, IndexType::BTree);
let d = c.clone();
assert_eq!(c, d);
}
#[test]
fn index_spec_field_set_is_frozen_to_v3_shape() {
use super::{IndexSpec, IndexType};
let spec = IndexSpec::simple("idx", &["col"], false, IndexType::BTree);
let IndexSpec {
name: _,
target: _,
kind: _,
index_type: _,
predicate: _,
include: _,
nulls_not_distinct: _,
requires_out_of_transaction: _,
extension_dependency: _,
} = spec;
}
#[test]
fn index_name_short_non_unique_is_verbatim() {
use super::{IndexNameKind, IndexNameTarget, index_name};
assert_eq!(
index_name(
"users",
IndexNameKind::NonUnique,
IndexNameTarget::Columns(&["email"])
),
"users_email_idx"
);
}
#[test]
fn index_name_short_unique_constraint_uses_key_stem() {
use super::{IndexNameKind, IndexNameTarget, index_name};
assert_eq!(
index_name(
"orgs",
IndexNameKind::UniqueConstraint,
IndexNameTarget::Columns(&["org_id", "external_id"])
),
"orgs_org_id_external_id_key"
);
}
#[test]
fn index_name_short_unique_index_uses_uidx_stem() {
use super::{IndexNameKind, IndexNameTarget, index_name};
assert_eq!(
index_name(
"accounts",
IndexNameKind::UniqueIndex,
IndexNameTarget::Columns(&["email"])
),
"accounts_email_uidx"
);
}
#[test]
fn index_name_expression_target_uses_expr_stem() {
use super::{IndexNameKind, IndexNameTarget, index_name};
assert_eq!(
index_name(
"users",
IndexNameKind::NonUnique,
IndexNameTarget::Expression
),
"users_expr_idx"
);
assert_eq!(
index_name(
"users",
IndexNameKind::UniqueIndex,
IndexNameTarget::Expression
),
"users_expr_uidx"
);
}
#[test]
fn index_name_column_order_is_semantic() {
use super::{IndexNameKind, IndexNameTarget, index_name};
let a = index_name(
"people",
IndexNameKind::NonUnique,
IndexNameTarget::Columns(&["last", "first"]),
);
let b = index_name(
"people",
IndexNameKind::NonUnique,
IndexNameTarget::Columns(&["first", "last"]),
);
assert_ne!(
a, b,
"column order must produce different names byte-for-byte"
);
assert_eq!(a, "people_last_first_idx");
assert_eq!(b, "people_first_last_idx");
}
#[test]
fn index_name_long_input_truncates_to_55_plus_8hex_suffix() {
use super::{IndexNameKind, IndexNameTarget, index_name};
let table = "very_long_table_with_many_underscore_separated_words";
let cols = ["first_column_name", "second_column_name"];
let name = index_name(
table,
IndexNameKind::NonUnique,
IndexNameTarget::Columns(&cols),
);
assert_eq!(
name.len(),
55 + 1 + 8,
"truncated name layout: 55-byte stem + `_` + 8-char hex digest; got '{name}'"
);
let naive = format!("{}_{}_{}_{}", table, cols[0], cols[1], "idx");
assert!(
naive.as_bytes().starts_with(name.as_bytes()[..55].as_ref()),
"truncated stem must be a prefix of the pre-truncation full name"
);
let tail = &name[name.len() - 8..];
assert!(
tail.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
"hash suffix must be 8 lowercase hex chars; got '{tail}'"
);
}
#[test]
fn index_name_near_duplicate_long_inputs_do_not_collide() {
use super::{IndexNameKind, IndexNameTarget, index_name};
let table = "very_long_table_with_many_underscore_separated_words";
let a = index_name(
table,
IndexNameKind::NonUnique,
IndexNameTarget::Columns(&["payload_one_extra_suffix_a"]),
);
let b = index_name(
table,
IndexNameKind::NonUnique,
IndexNameTarget::Columns(&["payload_one_extra_suffix_b"]),
);
assert_ne!(a, b, "hash suffix must break near-duplicate collisions");
assert_eq!(a.len(), 55 + 1 + 8);
assert_eq!(b.len(), 55 + 1 + 8);
}
#[test]
fn pk_type_desc_variants_resolve_to_id_column() {
use super::super::relation::{OnDelete, RelationKind};
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
unique: true,
indexed: true,
..field_descriptor("id", FieldSqlType::BigInt, false)
}];
let _ = (OnDelete::Restrict, RelationKind::ForeignKey);
for pk in [PkType::HeerIdDesc, PkType::RanjIdDesc] {
let desc = ModelDescriptor {
..model_descriptor("Desc", "descs", pk, FIELDS)
};
assert_eq!(desc.pk_column(), Some("id"));
}
}
#[test]
fn migration_shape_from_minimal_descriptor() {
use super::super::relation::{OnDelete, RelationKind};
static FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
unique: true,
indexed: true,
..field_descriptor("id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
..field_descriptor("label", FieldSqlType::Text, false)
},
];
let _ = (OnDelete::Restrict, RelationKind::ForeignKey);
let desc = ModelDescriptor {
..model_descriptor("Minimal", "minimals", PkType::HeerId, FIELDS)
};
let shape = MigrationShape::from_descriptor(&desc);
assert_eq!(shape.table_name, "minimals");
assert!(
shape.required_extensions.is_empty(),
"no Geography fields → no required extensions"
);
assert!(
shape.indexes.is_empty(),
"empty IndexSpec slice → no IndexShape entries"
);
assert_eq!(shape.columns.len(), 2);
assert_eq!(shape.columns[0].name, "id");
assert_eq!(shape.columns[0].sql_type_text, "BIGINT");
assert!(shape.columns[0].not_null);
assert_eq!(shape.columns[1].name, "label");
assert_eq!(shape.columns[1].sql_type_text, "TEXT");
assert!(shape.columns[1].not_null);
}
static T11_GEO_FIELD: FieldDescriptor = FieldDescriptor {
..field_descriptor(
"boundary",
FieldSqlType::Geography {
subtype: GeographySubtype::Polygon,
srid: 4326,
},
false,
)
};
static T11_TEXT_FIELD: FieldDescriptor = FieldDescriptor {
..field_descriptor("label", FieldSqlType::Text, false)
};
static T11_BOUNDARY_COLS: &[super::IndexColumnSpec] =
&[super::IndexColumnSpec::simple("boundary")];
static T11_LABEL_COLS: &[super::IndexColumnSpec] = &[super::IndexColumnSpec::simple("label")];
static T11_GIST_INDEX: IndexSpec = IndexSpec {
name: "idx_boundary_gist",
target: super::IndexTarget::Columns(T11_BOUNDARY_COLS),
kind: super::IndexKind::NonUnique,
index_type: IndexType::Gist,
predicate: None,
include: &[],
nulls_not_distinct: false,
requires_out_of_transaction: true,
extension_dependency: Some("postgis"),
};
static T11_BTREE_INDEX: IndexSpec = IndexSpec {
name: "idx_boundary_btree",
target: super::IndexTarget::Columns(T11_BOUNDARY_COLS),
kind: super::IndexKind::NonUnique,
index_type: IndexType::BTree,
predicate: None,
include: &[],
nulls_not_distinct: false,
requires_out_of_transaction: false,
extension_dependency: None,
};
static T11_GIST_ON_TEXT: IndexSpec = IndexSpec {
name: "idx_label_gist",
target: super::IndexTarget::Columns(T11_LABEL_COLS),
kind: super::IndexKind::NonUnique,
index_type: IndexType::Gist,
predicate: None,
include: &[],
nulls_not_distinct: false,
requires_out_of_transaction: false,
extension_dependency: None,
};
#[test]
fn has_gist_on_geography_returns_true_when_indexed() {
let desc = ModelDescriptor {
indexes: std::slice::from_ref(&T11_GIST_INDEX),
..model_descriptor(
"Region",
"regions",
PkType::HeerId,
std::slice::from_ref(&T11_GEO_FIELD),
)
};
assert!(
desc.has_gist_on_geography(),
"expected true: GiST index on a Geography field must be detected"
);
}
#[test]
fn has_gist_on_geography_returns_false_when_no_indexes() {
let desc = ModelDescriptor {
..model_descriptor(
"Region",
"regions",
PkType::HeerId,
std::slice::from_ref(&T11_GEO_FIELD),
)
};
assert!(
!desc.has_gist_on_geography(),
"expected false: no indexes means no GiST-on-Geography"
);
}
#[test]
fn has_gist_on_geography_returns_false_for_btree_on_geography() {
let desc = ModelDescriptor {
indexes: std::slice::from_ref(&T11_BTREE_INDEX),
..model_descriptor(
"Region",
"regions",
PkType::HeerId,
std::slice::from_ref(&T11_GEO_FIELD),
)
};
assert!(
!desc.has_gist_on_geography(),
"expected false: BTree index on Geography is not spatial acceleration"
);
}
#[test]
fn has_gist_on_geography_returns_false_for_gist_on_non_geo() {
let desc = ModelDescriptor {
indexes: std::slice::from_ref(&T11_GIST_ON_TEXT),
..model_descriptor(
"Region",
"regions",
PkType::HeerId,
std::slice::from_ref(&T11_TEXT_FIELD),
)
};
assert!(
!desc.has_gist_on_geography(),
"expected false: GiST on a text column is not spatial acceleration"
);
}
static EXCL_BTREE_GIST_ELEMENTS: &[ExclusionElement] = &[
ExclusionElement {
expr: "room_id",
with_operator: "=",
},
ExclusionElement {
expr: "period",
with_operator: "&&",
},
];
static EXCL_BTREE_GIST: ExclusionConstraintSpec = ExclusionConstraintSpec {
name: "bookings_no_overlap",
using: "gist",
elements: EXCL_BTREE_GIST_ELEMENTS,
where_clause: None,
deferrable: false,
initially_deferred: false,
extension_dependency: Some("btree_gist"),
};
#[test]
fn migration_shape_collects_btree_gist_from_exclusion_constraint() {
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
}];
let desc = ModelDescriptor {
exclusion_constraints: std::slice::from_ref(&EXCL_BTREE_GIST),
..model_descriptor("Booking", "bookings", PkType::HeerId, FIELDS)
};
let shape = MigrationShape::from_descriptor(&desc);
assert!(
shape.required_extensions.contains("btree_gist"),
"btree_gist must surface in required_extensions from exclusion spec; got {:?}",
shape.required_extensions,
);
assert_eq!(
shape.exclusion_constraints.len(),
1,
"shape.exclusion_constraints must carry the exclusion verbatim",
);
}
#[test]
fn migration_shape_skips_none_exclusion_extension() {
static ELEMENTS: &[ExclusionElement] = &[ExclusionElement {
expr: "period",
with_operator: "&&",
}];
static EXCL: ExclusionConstraintSpec = ExclusionConstraintSpec {
name: "period_overlap",
using: "gist",
elements: ELEMENTS,
where_clause: None,
deferrable: false,
initially_deferred: false,
extension_dependency: None,
};
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
}];
let desc = ModelDescriptor {
exclusion_constraints: std::slice::from_ref(&EXCL),
..model_descriptor("Reservation", "reservations", PkType::HeerId, FIELDS)
};
let shape = MigrationShape::from_descriptor(&desc);
assert!(
shape.required_extensions.is_empty(),
"extension_dependency=None must not register any extension; got {:?}",
shape.required_extensions,
);
}
#[test]
fn proxy_for_field_defaults_to_none() {
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
}];
let desc = ModelDescriptor {
..model_descriptor("V", "vs", PkType::HeerId, FIELDS)
};
assert!(desc.proxy_for.is_none());
}
#[test]
fn default_filter_sql_field_defaults_to_none() {
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
}];
let desc = ModelDescriptor {
..model_descriptor("V", "vs", PkType::HeerId, FIELDS)
};
assert!(desc.default_filter_sql.is_none());
}
#[test]
fn proxy_fields_round_trip_populated_values() {
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
}];
let desc = ModelDescriptor {
proxy_for: Some("Vehicle"),
default_filter_sql: Some("active = TRUE"),
..model_descriptor("ActiveVehicle", "vehicles", PkType::HeerId, FIELDS)
};
assert_eq!(desc.proxy_for, Some("Vehicle"));
assert_eq!(desc.default_filter_sql, Some("active = TRUE"));
}
#[test]
fn computed_fields_defaults_to_empty_slice() {
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
}];
let desc = ModelDescriptor {
..model_descriptor("V", "vs", PkType::HeerId, FIELDS)
};
assert!(desc.computed_fields.is_empty());
}
#[test]
fn computed_field_descriptor_struct_shape() {
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor("base_price", FieldSqlType::DoublePrecision, false)
}];
static COMPUTED: &[ComputedFieldDescriptor] = &[ComputedFieldDescriptor {
name: "total_price",
sql: "base_price * (1.0 + tax_rate)",
value_type: FieldSqlType::DoublePrecision,
}];
let desc = ModelDescriptor {
computed_fields: COMPUTED,
..model_descriptor("Vehicle", "vehicles", PkType::HeerId, FIELDS)
};
assert_eq!(desc.computed_fields.len(), 1);
let c = &desc.computed_fields[0];
assert_eq!(c.name, "total_price");
assert_eq!(c.sql, "base_price * (1.0 + tax_rate)");
assert_eq!(c.value_type, FieldSqlType::DoublePrecision);
}
#[test]
fn varchar_display_formats_with_bound() {
assert_eq!(FieldSqlType::Varchar(100).to_string(), "VARCHAR(100)");
assert_eq!(FieldSqlType::Varchar(1).to_string(), "VARCHAR(1)");
assert_eq!(
FieldSqlType::Varchar(10_485_760).to_string(),
"VARCHAR(10485760)"
);
}
#[test]
fn text_display_unchanged_after_varchar_addition() {
assert_eq!(FieldSqlType::Text.to_string(), "TEXT");
}
}
#[cfg(test)]
mod protected_field_metadata_tests {
use super::{
FieldDescriptor, FieldSqlType, ProtectedFieldMetadata, RedactionPolicy, RetentionLabel,
Sensitivity, field_descriptor,
};
#[test]
fn protected_field_metadata_round_trips_non_default_variants() {
let pfm = ProtectedFieldMetadata {
sensitivity: Sensitivity::Pii,
rationale: "GDPR Art. 6(1)(b) — notification delivery",
redaction: RedactionPolicy::Mask,
codec: Some("aes256_gcm_v1"),
retention: RetentionLabel::Extended,
};
assert_eq!(pfm.sensitivity, Sensitivity::Pii);
assert_eq!(pfm.rationale, "GDPR Art. 6(1)(b) — notification delivery");
assert_eq!(pfm.redaction, RedactionPolicy::Mask);
assert_eq!(pfm.codec, Some("aes256_gcm_v1"));
assert_eq!(pfm.retention, RetentionLabel::Extended);
}
#[test]
fn default_protected_field_metadata_is_neutral() {
let pfm = ProtectedFieldMetadata::default();
assert_eq!(pfm.sensitivity, Sensitivity::None);
assert_eq!(pfm.rationale, "");
assert_eq!(pfm.redaction, RedactionPolicy::None);
assert_eq!(pfm.codec, None);
assert_eq!(pfm.retention, RetentionLabel::Standard);
}
#[test]
fn enum_defaults_match_neutral_metadata() {
assert_eq!(Sensitivity::default(), Sensitivity::None);
assert_eq!(RedactionPolicy::default(), RedactionPolicy::None);
assert_eq!(RetentionLabel::default(), RetentionLabel::Standard);
}
#[test]
fn sensitivity_ordering_pins_least_to_most_sensitive() {
assert!(Sensitivity::None < Sensitivity::Internal);
assert!(Sensitivity::Internal < Sensitivity::Pii);
assert!(Sensitivity::Pii < Sensitivity::Sensitive);
assert!(Sensitivity::Sensitive < Sensitivity::Secret);
}
#[test]
fn field_descriptor_accepts_type_change_using_none_and_some() {
let no_using = FieldDescriptor {
..field_descriptor("ordinary", FieldSqlType::Text, false)
};
assert!(
no_using.type_change_using.is_none(),
"constructor must default `type_change_using` to None for every \
new descriptor — adding the attribute is opt-in",
);
let with_using = FieldDescriptor {
type_change_using: Some("kind::uuid"),
..field_descriptor("kind", FieldSqlType::Uuid, false)
};
assert_eq!(with_using.type_change_using, Some("kind::uuid"));
}
#[test]
fn field_descriptor_accepts_protected_none_and_some() {
let none_desc = FieldDescriptor {
..field_descriptor("ordinary", FieldSqlType::Text, false)
};
assert!(none_desc.protected.is_none());
let some_desc = FieldDescriptor {
unique: true,
indexed: true,
protected: Some(ProtectedFieldMetadata {
sensitivity: Sensitivity::Pii,
rationale: "Notification delivery",
redaction: RedactionPolicy::Mask,
codec: Some("aes256_gcm_v1"),
retention: RetentionLabel::Standard,
}),
..field_descriptor("email", FieldSqlType::Text, false)
};
let pfm = some_desc.protected.expect("constructed with Some(...)");
assert_eq!(pfm.sensitivity, Sensitivity::Pii);
assert_eq!(pfm.codec, Some("aes256_gcm_v1"));
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RustSourceType {
I8,
U8,
U16,
U32,
U64,
Decimal,
}
#[derive(Debug, Clone)]
pub struct FieldDescriptor {
pub name: &'static str,
pub sql_type: FieldSqlType,
pub nullable: bool,
pub unique: bool,
pub indexed: bool,
pub max_length: Option<u32>,
pub renamed_from: Option<&'static str>,
pub rationale: Option<&'static str>,
pub outbox_exclude: bool,
pub sequence_within: Option<&'static str>,
pub index_type: Option<IndexType>,
pub relation_kind: Option<RelationKind>,
pub on_delete: Option<OnDelete>,
pub target_type_name: Option<&'static str>,
pub is_self_fk: bool,
pub visage_map: &'static [(&'static str, &'static str)],
pub protected: Option<ProtectedFieldMetadata>,
pub default_volatility_override: Option<DefaultVolatility>,
pub generated: Option<GeneratedColumnSpec>,
pub composed_via: Option<&'static str>,
pub rust_source_type: Option<RustSourceType>,
pub check_sql: Option<&'static str>,
pub comment: Option<&'static str>,
pub strict_id_check: bool,
pub type_change_using: Option<&'static str>,
}
pub const fn field_descriptor(
name: &'static str,
sql_type: FieldSqlType,
nullable: bool,
) -> FieldDescriptor {
FieldDescriptor {
name,
sql_type,
nullable,
unique: false,
indexed: false,
max_length: None,
renamed_from: None,
rationale: None,
outbox_exclude: false,
sequence_within: None,
index_type: None,
relation_kind: None,
on_delete: None,
target_type_name: None,
is_self_fk: false,
visage_map: &[],
protected: None,
default_volatility_override: None,
generated: None,
composed_via: None,
rust_source_type: None,
check_sql: None,
comment: None,
strict_id_check: false,
type_change_using: None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum DefaultVolatility {
Immutable,
Stable,
Volatile,
}
impl Default for DefaultVolatility {
fn default() -> Self {
Self::Volatile
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub enum Sensitivity {
None,
Internal,
Pii,
Sensitive,
Secret,
}
impl Default for Sensitivity {
fn default() -> Self {
Sensitivity::None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum RedactionPolicy {
None,
HashId,
Mask,
Drop,
}
impl Default for RedactionPolicy {
fn default() -> Self {
RedactionPolicy::None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum RetentionLabel {
Transient,
Standard,
Extended,
Archival,
}
impl Default for RetentionLabel {
fn default() -> Self {
RetentionLabel::Standard
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ProtectedFieldMetadata {
pub sensitivity: Sensitivity,
pub rationale: &'static str,
pub redaction: RedactionPolicy,
pub codec: Option<&'static str>,
pub retention: RetentionLabel,
}
impl Default for ProtectedFieldMetadata {
fn default() -> Self {
Self {
sensitivity: Sensitivity::None,
rationale: "",
redaction: RedactionPolicy::None,
codec: None,
retention: RetentionLabel::Standard,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct GeneratedColumnSpec {
pub expression: &'static str,
pub stored: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DeferrabilitySpec {
pub model_type_name: &'static str,
pub field_name: &'static str,
pub deferrable: bool,
pub initially_deferred: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CustomPrimaryKeyKind {
pub type_name: &'static str,
pub sql_type: &'static str,
pub default_sql: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum PkType {
HeerId,
RanjId,
HeerIdDesc,
RanjIdDesc,
Serial,
None,
Composite(&'static [&'static str]),
Custom(CustomPrimaryKeyKind),
}
#[derive(Debug, Clone)]
pub struct ModelDescriptor {
pub type_name: &'static str,
pub table_name: &'static str,
pub pk_type: PkType,
pub fields: &'static [FieldDescriptor],
pub partition_by: Option<PartitionSpec>,
pub has_outbox: bool,
pub idempotency_key: Option<&'static str>,
pub tenant_key: Option<&'static str>,
pub cache_ttl: Option<u32>,
pub rationale: Option<&'static str>,
pub indexes: &'static [IndexSpec],
pub is_through: bool,
pub fts: Option<FtsDescriptor>,
pub app: Option<&'static str>,
pub moved_from_app: Option<&'static str>,
pub renamed_from: Option<&'static str>,
pub exclusion_constraints: &'static [ExclusionConstraintSpec],
pub tree_edge: Option<&'static str>,
pub proxy_for: Option<&'static str>,
pub default_filter_sql: Option<&'static str>,
pub computed_fields: &'static [ComputedFieldDescriptor],
pub table_comment: Option<&'static str>,
pub storage_params: Option<&'static str>,
pub tablespace: Option<&'static str>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComputedFieldDescriptor {
pub name: &'static str,
pub sql: &'static str,
pub value_type: FieldSqlType,
}
impl ModelDescriptor {
pub fn has_gist_on_geography(&self) -> bool {
for idx in self.indexes {
if !matches!(idx.index_type, IndexType::Gist) {
continue;
}
let cols = match idx.target {
IndexTarget::Columns(cs) => cs,
IndexTarget::Expression(_) => continue,
};
for col in cols {
let is_geo = self
.fields
.iter()
.find(|f| f.name == col.name)
.map(|f| matches!(f.sql_type, FieldSqlType::Geography { .. }))
.unwrap_or(false);
if is_geo {
return true;
}
}
}
false
}
fn self_fk_fields(&self) -> impl Iterator<Item = &FieldDescriptor> + '_ {
self.fields
.iter()
.filter(|f| f.relation_kind.is_some() && f.is_self_fk)
}
pub fn self_fk_count(&self) -> usize {
self.self_fk_fields().count()
}
pub fn self_fk_columns(&self) -> impl Iterator<Item = &'static str> + '_ {
self.self_fk_fields().map(|f| f.name)
}
pub fn pk_column(&self) -> Option<&'static str> {
match &self.pk_type {
PkType::HeerId
| PkType::RanjId
| PkType::HeerIdDesc
| PkType::RanjIdDesc
| PkType::Serial => Some("id"),
PkType::None => None,
PkType::Composite(cols) => cols.first().copied(),
PkType::Custom(_) => Some("id"),
}
}
pub fn migration_shape(&self) -> migration_shape::MigrationShape {
migration_shape::MigrationShape::from_descriptor(self)
}
}
pub const fn model_descriptor(
type_name: &'static str,
table_name: &'static str,
pk_type: PkType,
fields: &'static [FieldDescriptor],
) -> ModelDescriptor {
ModelDescriptor {
type_name,
table_name,
pk_type,
fields,
partition_by: None,
has_outbox: false,
idempotency_key: None,
tenant_key: None,
cache_ttl: None,
rationale: None,
indexes: &[],
is_through: false,
fts: None,
app: None,
moved_from_app: None,
renamed_from: None,
exclusion_constraints: &[],
tree_edge: None,
proxy_for: None,
default_filter_sql: None,
computed_fields: &[],
table_comment: None,
storage_params: None,
tablespace: None,
}
}
inventory::collect!(ModelDescriptor);
inventory::collect!(DeferrabilitySpec);
pub mod migration_shape {
use std::collections::BTreeSet;
use super::{
ExclusionConstraintSpec, FieldSqlType, GeneratedColumnSpec, IndexKind, IndexTarget,
IndexType, ModelDescriptor,
};
#[derive(Debug, Clone)]
pub struct MigrationShape {
pub table_name: &'static str,
pub columns: Vec<ColumnShape>,
pub indexes: Vec<IndexShape>,
pub required_extensions: BTreeSet<&'static str>,
pub exclusion_constraints: Vec<ExclusionConstraintSpec>,
}
#[derive(Debug, Clone)]
pub struct ColumnShape {
pub name: &'static str,
pub sql_type_text: String,
pub not_null: bool,
pub generated: Option<GeneratedColumnSpec>,
}
#[derive(Debug, Clone)]
pub struct IndexShape {
pub name: &'static str,
pub columns: Vec<&'static str>,
pub unique: bool,
pub requires_out_of_transaction: bool,
pub extension_dependency: Option<&'static str>,
pub sql_text: String,
}
impl MigrationShape {
pub fn from_descriptor(desc: &ModelDescriptor) -> Self {
let table = desc.table_name;
let columns: Vec<ColumnShape> = desc
.fields
.iter()
.map(|f| ColumnShape {
name: f.name,
sql_type_text: f.sql_type.to_string(),
not_null: !f.nullable,
generated: f.generated,
})
.collect();
let exclusion_constraints: Vec<ExclusionConstraintSpec> =
desc.exclusion_constraints.to_vec();
let mut required_extensions: BTreeSet<&'static str> = BTreeSet::new();
for f in desc.fields {
if matches!(f.sql_type, FieldSqlType::Geography { .. }) {
required_extensions.insert("postgis");
}
}
for spec in desc.exclusion_constraints {
if let Some(ext) = spec.extension_dependency {
required_extensions.insert(ext);
}
}
let indexes: Vec<IndexShape> = desc
.indexes
.iter()
.map(|spec| {
if let Some(ext) = spec.extension_dependency {
required_extensions.insert(ext);
}
let type_kw = index_type_keyword(spec.index_type);
let (columns, target_sql) = match spec.target {
IndexTarget::Columns(cs) => {
let names: Vec<&'static str> = cs.iter().map(|c| c.name).collect();
let joined = names.join(",");
(names, joined)
}
IndexTarget::Expression(expr) => (Vec::new(), expr.to_string()),
};
let is_unique = matches!(
spec.kind,
IndexKind::UniqueConstraint | IndexKind::UniqueIndex
);
let create_kw = if is_unique {
"CREATE UNIQUE INDEX"
} else {
"CREATE INDEX"
};
let concurrently = if spec.requires_out_of_transaction {
" CONCURRENTLY"
} else {
""
};
let sql_text = format!(
"{create_kw}{concurrently} IF NOT EXISTS {name} ON {table} USING {type_kw}({target_sql})",
name = spec.name,
);
IndexShape {
name: spec.name,
columns,
unique: is_unique,
requires_out_of_transaction: spec.requires_out_of_transaction,
extension_dependency: spec.extension_dependency,
sql_text,
}
})
.collect();
MigrationShape {
table_name: table,
columns,
indexes,
required_extensions,
exclusion_constraints,
}
}
}
fn index_type_keyword(t: IndexType) -> &'static str {
match t {
IndexType::BTree => "btree",
IndexType::Gist => "gist",
IndexType::Gin => "gin",
IndexType::Hash => "hash",
IndexType::Spgist => "spgist",
IndexType::Brin => "brin",
}
}
}
#[derive(Debug)]
pub struct EnumDescriptor {
pub type_name: &'static str,
pub postgres_type: &'static str,
pub variants: &'static [&'static str],
}
inventory::collect!(EnumDescriptor);
#[doc(hidden)]
pub type BoxedSqlBind = Box<dyn postgres_types::ToSql + Sync + Send>;
#[doc(hidden)]
pub type EnumPredicateValueBinder =
for<'a> fn(&'a (dyn std::any::Any + Send + Sync)) -> Option<BoxedSqlBind>;
#[doc(hidden)]
pub type EnumPredicateListBinder =
for<'a> fn(&'a (dyn std::any::Any + Send + Sync)) -> Option<Vec<BoxedSqlBind>>;
#[doc(hidden)]
pub type EnumPredicateOptionBinder =
for<'a> fn(&'a (dyn std::any::Any + Send + Sync)) -> Option<Option<BoxedSqlBind>>;
#[doc(hidden)]
pub type EnumPredicateOptionListBinder =
for<'a> fn(&'a (dyn std::any::Any + Send + Sync)) -> Option<Vec<Option<BoxedSqlBind>>>;
#[doc(hidden)]
pub type EnumPredicateFieldTypeMatcher = fn(std::any::TypeId) -> bool;
#[doc(hidden)]
pub struct EnumPredicateCodec {
pub type_name: &'static str,
pub postgres_type: &'static str,
pub matches_field_type: EnumPredicateFieldTypeMatcher,
pub bind_value: EnumPredicateValueBinder,
pub bind_list: EnumPredicateListBinder,
pub bind_option_value: EnumPredicateOptionBinder,
pub bind_option_list: EnumPredicateOptionListBinder,
}
inventory::collect!(EnumPredicateCodec);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DerivedProjection {
pub name: &'static str,
pub ty_path: &'static str,
pub sql: &'static str,
pub rust: &'static str,
pub doc: Option<&'static str>,
pub scopes: &'static [&'static str],
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VisageDescriptor {
pub model_name: &'static str,
pub scope: &'static str,
pub visage_name: &'static str,
pub derived: &'static [DerivedProjection],
}
inventory::collect!(VisageDescriptor);