use std::collections::BTreeMap;
use crate::store_schema::StoreColumnType;
use crate::store_schema_manifest::{
Manifest, ManifestColumn, ManifestStore,
};
pub fn udt_to_canonical_type(pg_udt: &str) -> Option<StoreColumnType> {
use StoreColumnType as C;
match pg_udt.to_ascii_lowercase().as_str() {
"uuid" => Some(C::Uuid),
"text" | "varchar" | "bpchar" | "name" => Some(C::Text),
"int4" | "integer" => Some(C::Int),
"int8" | "bigint" => Some(C::BigInt),
"float4" | "real" => Some(C::Float),
"float8" | "double precision" => Some(C::Double),
"bool" | "boolean" => Some(C::Bool),
"timestamptz" => Some(C::Timestamptz),
"timestamp" => Some(C::Timestamp),
"date" => Some(C::Date),
"time" => Some(C::Time),
"jsonb" => Some(C::Jsonb),
"json" => Some(C::Json),
"bytea" => Some(C::Bytea),
"numeric" | "decimal" => Some(C::Numeric),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IntrospectionRow {
pub column_name: String,
pub pg_udt: String,
pub not_null: bool,
pub primary_key: bool,
pub unique: bool,
pub default_expression: String,
pub identity_kind: Option<char>,
}
pub fn detect_auto_increment(default_expression: &str) -> bool {
default_expression
.to_ascii_lowercase()
.contains("nextval(")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OmittedColumn {
pub name: String,
pub pg_udt: String,
pub reason: String,
}
impl OmittedColumn {
pub fn as_comment_line(&self) -> String {
format!(
"# omitted: column `{}` (pg type `{}`) — {}",
self.name, self.pg_udt, self.reason
)
}
}
pub fn build_manifest_store(
rows: &[IntrospectionRow],
) -> (ManifestStore, Vec<OmittedColumn>) {
let mut columns: BTreeMap<String, ManifestColumn> = BTreeMap::new();
let mut omissions: Vec<OmittedColumn> = Vec::new();
for row in rows {
let Some(col_type) = udt_to_canonical_type(&row.pg_udt) else {
omissions.push(OmittedColumn {
name: row.column_name.clone(),
pg_udt: row.pg_udt.clone(),
reason:
"outside the v1.38.0 closed type catalog \
(enum/domain/array/citext/PostGIS/custom \
composites are honest-omitted, never silently \
lossily mapped — `tier_enum` ≠ `Text` even \
though they look alike at the wire)"
.to_string(),
});
continue;
};
let auto_increment = detect_auto_increment(&row.default_expression);
let identity = row.identity_kind.is_some();
columns.insert(
row.column_name.clone(),
ManifestColumn {
col_type,
primary_key: row.primary_key,
auto_increment,
not_null: row.not_null,
unique: row.unique,
default_value: if auto_increment {
String::new()
} else {
row.default_expression.clone()
},
identity,
},
);
}
(ManifestStore { columns }, omissions)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ColumnDelta {
Added {
column: String,
new_type: StoreColumnType,
},
Removed {
column: String,
old_type: StoreColumnType,
},
TypeChanged {
column: String,
old_type: StoreColumnType,
new_type: StoreColumnType,
},
ConstraintChanged {
column: String,
facet: &'static str,
old: String,
new: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ManifestDiff {
pub per_store: BTreeMap<String, Vec<ColumnDelta>>,
pub added_stores: Vec<String>,
pub removed_stores: Vec<String>,
}
impl ManifestDiff {
pub fn is_empty(&self) -> bool {
self.per_store.values().all(|deltas| deltas.is_empty())
&& self.added_stores.is_empty()
&& self.removed_stores.is_empty()
}
}
pub fn manifest_diff(old: &Manifest, new: &Manifest) -> ManifestDiff {
let mut diff = ManifestDiff::default();
for store_name in new.stores.keys() {
if !old.stores.contains_key(store_name) {
diff.added_stores.push(store_name.clone());
}
}
for store_name in old.stores.keys() {
if !new.stores.contains_key(store_name) {
diff.removed_stores.push(store_name.clone());
}
}
diff.added_stores.sort();
diff.removed_stores.sort();
for store_name in new.stores.keys() {
let Some(new_store) = new.stores.get(store_name) else { continue };
let Some(old_store) = old.stores.get(store_name) else { continue };
let deltas = diff_store_columns(old_store, new_store);
if !deltas.is_empty() {
diff.per_store.insert(store_name.clone(), deltas);
}
}
diff
}
fn diff_store_columns(old: &ManifestStore, new: &ManifestStore) -> Vec<ColumnDelta> {
let mut deltas: Vec<ColumnDelta> = Vec::new();
for (col_name, new_col) in &new.columns {
if !old.columns.contains_key(col_name) {
deltas.push(ColumnDelta::Added {
column: col_name.clone(),
new_type: new_col.col_type,
});
}
}
for (col_name, old_col) in &old.columns {
if !new.columns.contains_key(col_name) {
deltas.push(ColumnDelta::Removed {
column: col_name.clone(),
old_type: old_col.col_type,
});
}
}
for (col_name, new_col) in &new.columns {
let Some(old_col) = old.columns.get(col_name) else { continue };
if new_col.col_type != old_col.col_type {
deltas.push(ColumnDelta::TypeChanged {
column: col_name.clone(),
old_type: old_col.col_type,
new_type: new_col.col_type,
});
continue; }
if old_col.primary_key != new_col.primary_key {
deltas.push(ColumnDelta::ConstraintChanged {
column: col_name.clone(),
facet: "primary_key",
old: old_col.primary_key.to_string(),
new: new_col.primary_key.to_string(),
});
}
if old_col.not_null != new_col.not_null {
deltas.push(ColumnDelta::ConstraintChanged {
column: col_name.clone(),
facet: "not_null",
old: old_col.not_null.to_string(),
new: new_col.not_null.to_string(),
});
}
if old_col.unique != new_col.unique {
deltas.push(ColumnDelta::ConstraintChanged {
column: col_name.clone(),
facet: "unique",
old: old_col.unique.to_string(),
new: new_col.unique.to_string(),
});
}
if old_col.auto_increment != new_col.auto_increment {
deltas.push(ColumnDelta::ConstraintChanged {
column: col_name.clone(),
facet: "auto_increment",
old: old_col.auto_increment.to_string(),
new: new_col.auto_increment.to_string(),
});
}
if old_col.default_value != new_col.default_value {
deltas.push(ColumnDelta::ConstraintChanged {
column: col_name.clone(),
facet: "default_value",
old: old_col.default_value.clone(),
new: new_col.default_value.clone(),
});
}
}
deltas
}
pub fn format_manifest_diff(diff: &ManifestDiff) -> String {
if diff.is_empty() {
return String::new();
}
let mut out = String::new();
for store in &diff.added_stores {
out.push_str(&format!("+ store `{store}` (added)\n"));
}
for store in &diff.removed_stores {
out.push_str(&format!("- store `{store}` (removed)\n"));
}
for (store, deltas) in &diff.per_store {
out.push_str(&format!("~ store `{store}`:\n"));
for d in deltas {
match d {
ColumnDelta::Added { column, new_type } => out.push_str(&format!(
" + column `{column}` ({})\n",
new_type.canonical_name()
)),
ColumnDelta::Removed { column, old_type } => out.push_str(&format!(
" - column `{column}` (was {})\n",
old_type.canonical_name()
)),
ColumnDelta::TypeChanged {
column,
old_type,
new_type,
} => out.push_str(&format!(
" ~ column `{column}` type: {} → {}\n",
old_type.canonical_name(),
new_type.canonical_name()
)),
ColumnDelta::ConstraintChanged {
column,
facet,
old,
new,
} => out.push_str(&format!(
" ~ column `{column}` {facet}: {old} → {new}\n"
)),
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn row(name: &str, udt: &str) -> IntrospectionRow {
IntrospectionRow {
column_name: name.to_string(),
pg_udt: udt.to_string(),
not_null: false,
primary_key: false,
unique: false,
default_expression: String::new(),
identity_kind: None,
}
}
#[test]
fn udt_recognises_every_catalog_type_canonically() {
for (udt, expected) in [
("uuid", StoreColumnType::Uuid),
("text", StoreColumnType::Text),
("varchar", StoreColumnType::Text),
("bpchar", StoreColumnType::Text),
("name", StoreColumnType::Text),
("int4", StoreColumnType::Int),
("integer", StoreColumnType::Int),
("int8", StoreColumnType::BigInt),
("bigint", StoreColumnType::BigInt),
("float4", StoreColumnType::Float),
("real", StoreColumnType::Float),
("float8", StoreColumnType::Double),
("double precision", StoreColumnType::Double),
("bool", StoreColumnType::Bool),
("boolean", StoreColumnType::Bool),
("timestamptz", StoreColumnType::Timestamptz),
("timestamp", StoreColumnType::Timestamp),
("date", StoreColumnType::Date),
("time", StoreColumnType::Time),
("jsonb", StoreColumnType::Jsonb),
("json", StoreColumnType::Json),
("bytea", StoreColumnType::Bytea),
("numeric", StoreColumnType::Numeric),
("decimal", StoreColumnType::Numeric),
] {
assert_eq!(
udt_to_canonical_type(udt),
Some(expected),
"expected `{udt}` → `{}`",
expected.canonical_name()
);
}
}
#[test]
fn udt_recognition_is_case_insensitive() {
assert_eq!(udt_to_canonical_type("UUID"), Some(StoreColumnType::Uuid));
assert_eq!(udt_to_canonical_type("TEXT"), Some(StoreColumnType::Text));
assert_eq!(udt_to_canonical_type("Int4"), Some(StoreColumnType::Int));
}
#[test]
fn udt_outside_catalog_returns_none() {
for udt in [
"enum", "geometry", "citext", "tier_enum", "_int4",
"money", "interval", "cidr", "macaddr", "geography",
] {
assert_eq!(udt_to_canonical_type(udt), None, "`{udt}` must be unmapped");
}
}
#[test]
fn detect_auto_increment_recognises_nextval_call() {
for expr in [
"nextval('users_id_seq'::regclass)",
"nextval('public.events_id_seq'::regclass)",
"NEXTVAL('s')",
] {
assert!(
detect_auto_increment(expr),
"expected `{expr}` to indicate auto_increment"
);
}
}
#[test]
fn detect_auto_increment_rejects_static_defaults() {
for expr in [
"",
"0",
"'standard'::text",
"now()",
"CURRENT_TIMESTAMP",
"gen_random_uuid()",
"'{}'::jsonb",
] {
assert!(!detect_auto_increment(expr), "`{expr}` must NOT be auto");
}
}
#[test]
fn build_manifest_store_maps_every_catalog_udt_to_canonical_type() {
let rows = vec![
row("id", "uuid"),
row("name", "varchar"),
row("count", "int4"),
row("balance", "numeric"),
row("active", "boolean"),
];
let (store, omitted) = build_manifest_store(&rows);
assert_eq!(store.columns.len(), 5);
assert!(omitted.is_empty());
assert_eq!(
store.columns.get("id").unwrap().col_type,
StoreColumnType::Uuid
);
assert_eq!(
store.columns.get("name").unwrap().col_type,
StoreColumnType::Text
);
}
#[test]
fn build_manifest_store_omits_unmappable_types_with_reason() {
let rows = vec![
row("id", "uuid"),
row("tier", "tier_enum"), row("shape", "geometry"), ];
let (store, omitted) = build_manifest_store(&rows);
assert_eq!(store.columns.len(), 1, "only `id` survives");
assert_eq!(omitted.len(), 2);
let names: Vec<&str> = omitted.iter().map(|o| o.name.as_str()).collect();
assert!(names.contains(&"tier"));
assert!(names.contains(&"shape"));
assert!(omitted[0].reason.contains("closed type catalog"));
}
#[test]
fn build_manifest_store_threads_constraints_through() {
let rows = vec![IntrospectionRow {
column_name: "id".into(),
pg_udt: "uuid".into(),
not_null: true,
primary_key: true,
unique: true,
default_expression: "gen_random_uuid()".into(),
identity_kind: None,
}];
let (store, _) = build_manifest_store(&rows);
let col = store.columns.get("id").unwrap();
assert!(col.primary_key);
assert!(col.not_null);
assert!(col.unique);
assert!(!col.auto_increment); assert!(!col.identity); assert_eq!(col.default_value, "gen_random_uuid()");
}
#[test]
fn build_manifest_store_marks_serial_columns_auto_increment_and_drops_nextval_expr() {
let rows = vec![IntrospectionRow {
column_name: "id".into(),
pg_udt: "int4".into(),
not_null: true,
primary_key: true,
unique: false,
default_expression:
"nextval('public.users_id_seq'::regclass)".into(),
identity_kind: None,
}];
let (store, _) = build_manifest_store(&rows);
let col = store.columns.get("id").unwrap();
assert!(col.auto_increment);
assert!(!col.identity, "SERIAL is auto_increment, NOT identity");
assert!(col.default_value.is_empty(), "auto_increment drops the sequence expr");
}
#[test]
fn build_manifest_store_preserves_static_defaults() {
let rows = vec![IntrospectionRow {
column_name: "tier".into(),
pg_udt: "text".into(),
not_null: true,
primary_key: false,
unique: false,
default_expression: "'standard'::text".into(),
identity_kind: None,
}];
let (store, _) = build_manifest_store(&rows);
assert_eq!(
store.columns.get("tier").unwrap().default_value,
"'standard'::text"
);
}
#[test]
fn build_manifest_store_columns_sort_alphabetically() {
let rows = vec![
row("tier", "text"),
row("active", "boolean"),
row("tenant_id", "uuid"),
];
let (store, _) = build_manifest_store(&rows);
let order: Vec<&str> = store.columns.keys().map(|s| s.as_str()).collect();
assert_eq!(order, vec!["active", "tenant_id", "tier"]);
}
#[test]
fn build_manifest_store_empty_rows_yields_empty_store() {
let (store, omitted) = build_manifest_store(&[]);
assert!(store.columns.is_empty());
assert!(omitted.is_empty());
}
#[test]
fn omitted_column_renders_as_human_readable_comment_line() {
let o = OmittedColumn {
name: "tier".into(),
pg_udt: "tier_enum".into(),
reason: "outside the v1.38.0 closed type catalog".into(),
};
let line = o.as_comment_line();
assert!(line.starts_with("# omitted: "));
assert!(line.contains("`tier`"));
assert!(line.contains("`tier_enum`"));
assert!(line.contains("closed type catalog"));
}
fn manifest_from_json(src: &str) -> Manifest {
Manifest::parse_json(src).expect("parse manifest fixture")
}
#[test]
fn manifest_diff_empty_when_manifests_match() {
let m = manifest_from_json(
r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Uuid","primary_key":true}}}}}"#,
);
let diff = manifest_diff(&m, &m);
assert!(diff.is_empty());
assert_eq!(format_manifest_diff(&diff), "");
}
#[test]
fn manifest_diff_detects_added_store() {
let old = manifest_from_json(
r#"{"version":1,"stores":{"a":{"columns":{"id":{"type":"Uuid"}}}}}"#,
);
let new = manifest_from_json(
r#"{"version":1,"stores":{"a":{"columns":{"id":{"type":"Uuid"}}},"b":{"columns":{"x":{"type":"Int"}}}}}"#,
);
let diff = manifest_diff(&old, &new);
assert!(!diff.is_empty());
assert_eq!(diff.added_stores, vec!["b"]);
assert!(diff.removed_stores.is_empty());
}
#[test]
fn manifest_diff_detects_removed_store() {
let old = manifest_from_json(
r#"{"version":1,"stores":{"a":{"columns":{"id":{"type":"Uuid"}}},"b":{"columns":{"x":{"type":"Int"}}}}}"#,
);
let new = manifest_from_json(
r#"{"version":1,"stores":{"a":{"columns":{"id":{"type":"Uuid"}}}}}"#,
);
let diff = manifest_diff(&old, &new);
assert_eq!(diff.removed_stores, vec!["b"]);
}
#[test]
fn manifest_diff_detects_added_column() {
let old = manifest_from_json(
r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Uuid"}}}}}"#,
);
let new = manifest_from_json(
r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Uuid"},"tier":{"type":"Text"}}}}}"#,
);
let diff = manifest_diff(&old, &new);
let deltas = diff.per_store.get("t").expect("t store has deltas");
assert_eq!(deltas.len(), 1);
matches!(
&deltas[0],
ColumnDelta::Added { column, new_type }
if column == "tier" && *new_type == StoreColumnType::Text
);
}
#[test]
fn manifest_diff_detects_removed_column() {
let old = manifest_from_json(
r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Uuid"},"tier":{"type":"Text"}}}}}"#,
);
let new = manifest_from_json(
r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Uuid"}}}}}"#,
);
let diff = manifest_diff(&old, &new);
let deltas = diff.per_store.get("t").expect("t store has deltas");
assert!(matches!(
&deltas[0],
ColumnDelta::Removed { column, .. } if column == "tier"
));
}
#[test]
fn manifest_diff_detects_column_type_change() {
let old = manifest_from_json(
r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Int"}}}}}"#,
);
let new = manifest_from_json(
r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Uuid"}}}}}"#,
);
let diff = manifest_diff(&old, &new);
let deltas = diff.per_store.get("t").unwrap();
assert!(matches!(
&deltas[0],
ColumnDelta::TypeChanged { column, old_type, new_type }
if column == "id"
&& *old_type == StoreColumnType::Int
&& *new_type == StoreColumnType::Uuid
));
}
#[test]
fn manifest_diff_type_change_subsumes_constraint_changes_on_same_column() {
let old = manifest_from_json(
r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Int","primary_key":true}}}}}"#,
);
let new = manifest_from_json(
r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Uuid"}}}}}"#,
);
let diff = manifest_diff(&old, &new);
let deltas = diff.per_store.get("t").unwrap();
assert_eq!(deltas.len(), 1, "exactly one delta — type change");
}
#[test]
fn manifest_diff_detects_each_constraint_facet_independently() {
let old = manifest_from_json(
r#"{
"version": 1,
"stores": { "t": { "columns": {
"x": { "type": "Int", "primary_key": false, "not_null": false,
"unique": false }
}}}
}"#,
);
let new = manifest_from_json(
r#"{
"version": 1,
"stores": { "t": { "columns": {
"x": { "type": "Int", "primary_key": true, "not_null": true,
"unique": true }
}}}
}"#,
);
let diff = manifest_diff(&old, &new);
let deltas = diff.per_store.get("t").unwrap();
let facets: std::collections::BTreeSet<&str> = deltas
.iter()
.filter_map(|d| match d {
ColumnDelta::ConstraintChanged { facet, .. } => Some(*facet),
_ => None,
})
.collect();
assert!(facets.contains("primary_key"));
assert!(facets.contains("not_null"));
assert!(facets.contains("unique"));
}
#[test]
fn format_manifest_diff_emits_a_human_readable_summary() {
let old = manifest_from_json(
r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Int"}}}}}"#,
);
let new = manifest_from_json(
r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Uuid"},"tier":{"type":"Text"}}}}}"#,
);
let diff = manifest_diff(&old, &new);
let text = format_manifest_diff(&diff);
assert!(text.contains("~ store `t`"));
assert!(text.contains("+ column `tier` (Text)"));
assert!(text.contains("~ column `id` type: Int → Uuid"));
}
#[test]
fn format_manifest_diff_empty_diff_yields_empty_string() {
let diff = ManifestDiff::default();
assert_eq!(format_manifest_diff(&diff), "");
}
}