use std::collections::BTreeMap;
use autumn_schema_core::{
Backend, CheckConstraint, Column, ColumnDefault, ColumnType, ForeignKey, Index, SerialKind,
Table,
};
use diesel::{Connection as _, PgConnection, QueryableByName, RunQueryDsl as _, sql_query};
#[derive(Debug, thiserror::Error)]
pub enum IntrospectError {
#[error(
"could not connect to Postgres at {host}:{port} — is the server running and reachable?"
)]
Connection {
host: String,
port: u16,
},
#[error("database introspection query failed: {0}")]
Query(String),
#[cfg(feature = "sqlite")]
#[error("could not open the SQLite database at {path} — is the file present and readable?")]
ConnectionSqlite {
path: String,
},
}
pub fn introspect_postgres(url: &str) -> Result<Vec<Table>, IntrospectError> {
let mut conn = PgConnection::establish(url).map_err(|_| {
let (host, port) = parse_host_port(url).unwrap_or_else(|| ("localhost".to_owned(), 5432));
IntrospectError::Connection { host, port }
})?;
let table_names = list_tables(&mut conn)?;
if table_names.is_empty() {
return Ok(Vec::new());
}
let columns_by_table = fetch_columns(&mut conn, &table_names)?;
let pks_by_table = fetch_primary_keys(&mut conn, &table_names)?;
let indexes_by_table = fetch_indexes(&mut conn, &table_names)?;
let fks_by_table = fetch_foreign_keys(&mut conn, &table_names)?;
let checks_by_table = fetch_checks(&mut conn, &table_names)?;
let mut tables = Vec::with_capacity(table_names.len());
for name in &table_names {
tables.push(build_table(
name,
columns_by_table.get(name).map_or(&[][..], Vec::as_slice),
pks_by_table.get(name).map_or(&[][..], Vec::as_slice),
indexes_by_table.get(name).map_or(&[][..], Vec::as_slice),
fks_by_table.get(name).map_or(&[][..], Vec::as_slice),
checks_by_table.get(name).map_or(&[][..], Vec::as_slice),
));
}
Ok(tables)
}
fn parse_host_port(url: &str) -> Option<(String, u16)> {
let parsed = url::Url::parse(url).ok()?;
let host = parsed.host_str().unwrap_or("localhost").to_owned();
let port = parsed.port().unwrap_or(5432);
Some((host, port))
}
#[derive(QueryableByName)]
struct NameRow {
#[diesel(sql_type = diesel::sql_types::Text)]
name: String,
}
#[derive(QueryableByName)]
struct ColumnRow {
#[diesel(sql_type = diesel::sql_types::Text)]
table_name: String,
#[diesel(sql_type = diesel::sql_types::Text)]
column_name: String,
#[diesel(sql_type = diesel::sql_types::Text)]
udt_name: String,
#[diesel(sql_type = diesel::sql_types::Text)]
is_nullable: String,
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
column_default: Option<String>,
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Integer>)]
numeric_precision: Option<i32>,
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Integer>)]
numeric_scale: Option<i32>,
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Integer>)]
character_maximum_length: Option<i32>,
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
domain_name: Option<String>,
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
domain_schema: Option<String>,
#[diesel(sql_type = diesel::sql_types::Bool)]
owns_sequence: bool,
#[diesel(sql_type = diesel::sql_types::Text)]
is_identity: String,
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
identity_generation: Option<String>,
}
#[derive(QueryableByName)]
struct TablePkRow {
#[diesel(sql_type = diesel::sql_types::Text)]
table_name: String,
#[diesel(sql_type = diesel::sql_types::Text)]
column_name: String,
#[diesel(sql_type = diesel::sql_types::Integer)]
ord: i32,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(QueryableByName)]
struct IndexRow {
#[diesel(sql_type = diesel::sql_types::Text)]
table_name: String,
#[diesel(sql_type = diesel::sql_types::Text)]
index_name: String,
#[diesel(sql_type = diesel::sql_types::Bool)]
is_unique: bool,
#[diesel(sql_type = diesel::sql_types::Text)]
definition: String,
#[diesel(sql_type = diesel::sql_types::Bool)]
is_partial: bool,
#[diesel(sql_type = diesel::sql_types::Bool)]
has_expression: bool,
#[diesel(sql_type = diesel::sql_types::Bool)]
has_include: bool,
#[diesel(sql_type = diesel::sql_types::Bool)]
is_constraint: bool,
#[diesel(sql_type = diesel::sql_types::Text)]
constraint_type: String,
#[diesel(sql_type = diesel::sql_types::Text)]
constraint_name: String,
#[diesel(sql_type = diesel::sql_types::Text)]
constraint_def: String,
#[diesel(sql_type = diesel::sql_types::Text)]
columns: String,
#[diesel(sql_type = diesel::sql_types::Text)]
key_columns: String,
#[diesel(sql_type = diesel::sql_types::Text)]
dep_columns: String,
}
#[derive(QueryableByName)]
struct ForeignKeyRow {
#[diesel(sql_type = diesel::sql_types::Text)]
table_name: String,
#[diesel(sql_type = diesel::sql_types::Text)]
column_name: String,
#[diesel(sql_type = diesel::sql_types::Text)]
foreign_table: String,
#[diesel(sql_type = diesel::sql_types::Text)]
foreign_schema: String,
#[diesel(sql_type = diesel::sql_types::Text)]
foreign_column: String,
}
#[derive(QueryableByName)]
struct CheckRow {
#[diesel(sql_type = diesel::sql_types::Text)]
table_name: String,
#[diesel(sql_type = diesel::sql_types::Text)]
constraint_name: String,
#[diesel(sql_type = diesel::sql_types::Text)]
definition: String,
}
fn list_tables(conn: &mut PgConnection) -> Result<Vec<String>, IntrospectError> {
let query = "SELECT table_name AS name FROM information_schema.tables \
WHERE table_schema = 'public' AND table_type = 'BASE TABLE' \
AND table_name <> '__diesel_schema_migrations' \
ORDER BY table_name";
let rows: Vec<NameRow> = sql_query(query)
.load(conn)
.map_err(|e| IntrospectError::Query(e.to_string()))?;
Ok(rows
.into_iter()
.map(|r| r.name)
.filter(|t| !is_framework_table(t))
.collect())
}
fn is_framework_table(table: &str) -> bool {
table.starts_with("autumn_")
|| table.starts_with("_autumn")
|| matches!(
table,
"api_tokens" | "feature_flag_changes" | "__diesel_schema_migrations"
)
}
fn quoted_in_list(tables: &[String]) -> String {
tables
.iter()
.map(|t| crate::db::quote_literal(t))
.collect::<Vec<_>>()
.join(", ")
}
fn fetch_columns(
conn: &mut PgConnection,
tables: &[String],
) -> Result<BTreeMap<String, Vec<ColumnRow>>, IntrospectError> {
let query = format!(
"SELECT c.table_name, c.column_name, c.udt_name, c.is_nullable, c.column_default, \
c.numeric_precision::integer AS numeric_precision, \
c.numeric_scale::integer AS numeric_scale, \
c.character_maximum_length::integer AS character_maximum_length, \
c.domain_name, c.domain_schema, \
c.is_identity, c.identity_generation, \
EXISTS ( \
SELECT 1 \
FROM pg_class tbl \
JOIN pg_namespace tn ON tn.oid = tbl.relnamespace \
JOIN pg_attribute att \
ON att.attrelid = tbl.oid AND att.attname = c.column_name \
JOIN pg_attrdef ad ON ad.adrelid = tbl.oid AND ad.adnum = att.attnum \
JOIN pg_depend owned \
ON owned.refobjid = tbl.oid AND owned.refobjsubid = att.attnum \
AND owned.refclassid = 'pg_class'::regclass \
AND owned.classid = 'pg_class'::regclass \
AND owned.deptype = 'a' \
JOIN pg_class seq ON seq.oid = owned.objid AND seq.relkind = 'S' \
JOIN pg_depend usedby \
ON usedby.objid = ad.oid AND usedby.classid = 'pg_attrdef'::regclass \
AND usedby.refobjid = seq.oid AND usedby.refclassid = 'pg_class'::regclass \
AND usedby.deptype = 'n' \
WHERE tn.nspname = 'public' \
AND tbl.relname = c.table_name \
AND att.attnum > 0 \
) AS owns_sequence \
FROM information_schema.columns c \
WHERE c.table_schema = 'public' AND c.table_name IN ({}) \
ORDER BY c.table_name, c.ordinal_position",
quoted_in_list(tables)
);
let rows: Vec<ColumnRow> = sql_query(query)
.load(conn)
.map_err(|e| IntrospectError::Query(e.to_string()))?;
let mut by_table: BTreeMap<String, Vec<ColumnRow>> = BTreeMap::new();
for row in rows {
by_table
.entry(row.table_name.clone())
.or_default()
.push(row);
}
Ok(by_table)
}
fn fetch_primary_keys(
conn: &mut PgConnection,
tables: &[String],
) -> Result<BTreeMap<String, Vec<String>>, IntrospectError> {
let query = format!(
"SELECT c.relname AS table_name, a.attname AS column_name, \
array_position(string_to_array(i.indkey::text, ' ')::int[], a.attnum::int) AS ord \
FROM pg_index i \
JOIN pg_class c ON c.oid = i.indrelid \
JOIN pg_namespace n ON n.oid = c.relnamespace \
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) \
WHERE n.nspname = 'public' AND i.indisprimary AND c.relname IN ({}) \
ORDER BY c.relname, ord",
quoted_in_list(tables)
);
let rows: Vec<TablePkRow> = sql_query(query)
.load(conn)
.map_err(|e| IntrospectError::Query(e.to_string()))?;
let mut by_table: BTreeMap<String, Vec<(i32, String)>> = BTreeMap::new();
for row in rows {
by_table
.entry(row.table_name)
.or_default()
.push((row.ord, row.column_name));
}
Ok(by_table
.into_iter()
.map(|(table, mut cols)| {
cols.sort_by_key(|(ord, _)| *ord);
(table, cols.into_iter().map(|(_, name)| name).collect())
})
.collect())
}
fn fetch_indexes(
conn: &mut PgConnection,
tables: &[String],
) -> Result<BTreeMap<String, Vec<IndexRow>>, IntrospectError> {
let query = format!(
"SELECT t.relname AS table_name, ic.relname AS index_name, \
i.indisunique AS is_unique, pg_get_indexdef(i.indexrelid) AS definition, \
(i.indpred IS NOT NULL) AS is_partial, \
(0 = ANY(string_to_array(i.indkey::text, ' ')::int[])) AS has_expression, \
(i.indnkeyatts < i.indnatts) AS has_include, \
EXISTS (SELECT 1 FROM pg_constraint c WHERE c.conindid = i.indexrelid) AS is_constraint, \
COALESCE((SELECT c.contype::text FROM pg_constraint c WHERE c.conindid = i.indexrelid LIMIT 1), '') AS constraint_type, \
COALESCE((SELECT c.conname FROM pg_constraint c WHERE c.conindid = i.indexrelid LIMIT 1), '') AS constraint_name, \
COALESCE((SELECT pg_get_constraintdef(c.oid) FROM pg_constraint c WHERE c.conindid = i.indexrelid LIMIT 1), '') AS constraint_def, \
COALESCE(( \
SELECT string_agg(a.attname, ',' ORDER BY k.ord) \
FROM unnest(string_to_array(i.indkey::text, ' ')::int[]) \
WITH ORDINALITY AS k(attnum, ord) \
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum \
WHERE k.attnum > 0 \
), '') AS columns, \
CASE \
WHEN 0 = ANY((string_to_array(i.indkey::text, ' ')::int[])[1:i.indnkeyatts]) \
THEN '' \
ELSE COALESCE(( \
SELECT string_agg(a.attname, ',' ORDER BY k.ord) \
FROM unnest((string_to_array(i.indkey::text, ' ')::int[])[1:i.indnkeyatts]) \
WITH ORDINALITY AS k(attnum, ord) \
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum \
), '') \
END AS key_columns, \
COALESCE(( \
SELECT string_agg(DISTINCT attname, ',' ORDER BY attname) FROM ( \
SELECT a.attname \
FROM pg_depend d \
JOIN pg_attribute a \
ON a.attrelid = d.refobjid AND a.attnum = d.refobjsubid \
WHERE d.classid = 'pg_class'::regclass \
AND d.objid = i.indexrelid \
AND d.refclassid = 'pg_class'::regclass \
AND d.refobjid = i.indrelid \
AND d.refobjsubid > 0 \
UNION \
SELECT a.attname \
FROM pg_constraint c \
JOIN unnest(c.conkey) AS ck(attnum) ON TRUE \
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ck.attnum \
WHERE c.conindid = i.indexrelid AND ck.attnum > 0 \
) deps \
), '') AS dep_columns \
FROM pg_index i \
JOIN pg_class t ON t.oid = i.indrelid \
JOIN pg_class ic ON ic.oid = i.indexrelid \
JOIN pg_namespace n ON n.oid = t.relnamespace \
WHERE n.nspname = 'public' AND NOT i.indisprimary AND t.relname IN ({}) \
ORDER BY t.relname, ic.relname",
quoted_in_list(tables)
);
let rows: Vec<IndexRow> = sql_query(query)
.load(conn)
.map_err(|e| IntrospectError::Query(e.to_string()))?;
let mut by_table: BTreeMap<String, Vec<IndexRow>> = BTreeMap::new();
for row in rows {
by_table
.entry(row.table_name.clone())
.or_default()
.push(row);
}
Ok(by_table)
}
fn fetch_foreign_keys(
conn: &mut PgConnection,
tables: &[String],
) -> Result<BTreeMap<String, Vec<ForeignKeyRow>>, IntrospectError> {
let query = format!(
"SELECT t.relname AS table_name, att.attname AS column_name, \
ft.relname AS foreign_table, fn.nspname AS foreign_schema, \
fatt.attname AS foreign_column \
FROM pg_constraint con \
JOIN pg_class t ON t.oid = con.conrelid \
JOIN pg_namespace n ON n.oid = t.relnamespace \
JOIN pg_class ft ON ft.oid = con.confrelid \
JOIN pg_namespace fn ON fn.oid = ft.relnamespace \
JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = con.conkey[1] \
JOIN pg_attribute fatt ON fatt.attrelid = con.confrelid AND fatt.attnum = con.confkey[1] \
WHERE con.contype = 'f' AND n.nspname = 'public' AND t.relname IN ({})",
quoted_in_list(tables)
);
let rows: Vec<ForeignKeyRow> = sql_query(query)
.load(conn)
.map_err(|e| IntrospectError::Query(e.to_string()))?;
let mut by_table: BTreeMap<String, Vec<ForeignKeyRow>> = BTreeMap::new();
for row in rows {
by_table
.entry(row.table_name.clone())
.or_default()
.push(row);
}
Ok(by_table)
}
fn fetch_checks(
conn: &mut PgConnection,
tables: &[String],
) -> Result<BTreeMap<String, Vec<CheckRow>>, IntrospectError> {
let query = format!(
"SELECT t.relname AS table_name, con.conname AS constraint_name, \
pg_get_constraintdef(con.oid) AS definition \
FROM pg_constraint con \
JOIN pg_class t ON t.oid = con.conrelid \
JOIN pg_namespace n ON n.oid = t.relnamespace \
WHERE con.contype = 'c' AND n.nspname = 'public' AND t.relname IN ({}) \
ORDER BY t.relname, con.conname",
quoted_in_list(tables)
);
let rows: Vec<CheckRow> = sql_query(query)
.load(conn)
.map_err(|e| IntrospectError::Query(e.to_string()))?;
let mut by_table: BTreeMap<String, Vec<CheckRow>> = BTreeMap::new();
for row in rows {
by_table
.entry(row.table_name.clone())
.or_default()
.push(row);
}
Ok(by_table)
}
fn strip_check_wrapper(definition: &str) -> String {
let trimmed = definition.trim();
let Some(rest) = trimmed
.strip_prefix("CHECK ")
.or_else(|| trimmed.strip_prefix("CHECK"))
.map(str::trim_start)
else {
return trimmed.to_owned();
};
let bytes = rest.as_bytes();
if bytes.first() != Some(&b'(') {
return trimmed.to_owned();
}
let mut depth = 0usize;
let mut in_string = false;
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if in_string {
if b == b'\'' {
if bytes.get(i + 1) == Some(&b'\'') {
i += 2;
continue;
}
in_string = false;
}
} else {
match b {
b'\'' => in_string = true,
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
return rest[1..i].trim().to_owned();
}
}
_ => {}
}
}
i += 1;
}
trimmed.to_owned()
}
fn build_table(
name: &str,
columns: &[ColumnRow],
primary_key: &[String],
indexes: &[IndexRow],
foreign_keys: &[ForeignKeyRow],
checks: &[CheckRow],
) -> Table {
let mut table = Table::new(name, Backend::Postgres);
table.managed = true;
table.primary_key = primary_key.to_vec();
let (ir_indexes, unique_columns) = collapse_indexes(indexes);
let fk_by_column: BTreeMap<&str, &ForeignKeyRow> = foreign_keys
.iter()
.map(|fk| (fk.column_name.as_str(), fk))
.collect();
for row in columns {
let ty = row.domain_name.as_ref().map_or_else(
|| {
ColumnType::from_pg_introspection(
&row.udt_name,
row.numeric_precision,
row.numeric_scale,
row.character_maximum_length,
)
},
|domain| {
let pg_type = match row.domain_schema.as_deref() {
Some(schema) if schema != "public" => format!("{schema}.{domain}"),
_ => domain.clone(),
};
ColumnType::Opaque { pg_type }
},
);
let is_pk = primary_key.iter().any(|c| c == &row.column_name);
let mut column = Column::new(row.column_name.clone(), ty.clone());
column.nullable = row.is_nullable.eq_ignore_ascii_case("YES");
column.primary_key = is_pk;
column.unique = unique_columns.contains(row.column_name.as_str());
column.default = normalize_default(
row.column_default.as_deref(),
&ty,
is_pk,
primary_key,
row.owns_sequence,
);
column.serial = serial_kind_for(
row.column_default.as_deref(),
&ty,
is_pk,
primary_key,
row.owns_sequence,
row.is_identity.eq_ignore_ascii_case("YES"),
);
if row.is_identity.eq_ignore_ascii_case("YES") {
column.identity = Some(
row.identity_generation
.clone()
.unwrap_or_else(|| "ALWAYS".to_owned()),
);
}
if let Some(fk) = fk_by_column.get(row.column_name.as_str()) {
let foreign_table = if fk.foreign_schema == "public" {
fk.foreign_table.clone()
} else {
format!("{}.{}", fk.foreign_schema, fk.foreign_table)
};
column.references = Some(ForeignKey::new(foreign_table, fk.foreign_column.clone()));
}
table.columns.push(column);
}
table.indexes = ir_indexes;
table.checks = checks
.iter()
.map(|c| CheckConstraint {
name: Some(c.constraint_name.clone()),
expression: strip_check_wrapper(&c.definition),
})
.collect();
table
}
fn collapse_indexes(rows: &[IndexRow]) -> (Vec<Index>, std::collections::BTreeSet<String>) {
let mut indexes = Vec::with_capacity(rows.len());
let mut unique_columns = std::collections::BTreeSet::new();
for row in rows {
let key_columns: Vec<String> = row
.columns
.split(',')
.filter(|c| !c.is_empty())
.map(str::to_owned)
.collect();
let nulls_not_distinct = row
.definition
.to_ascii_uppercase()
.contains("NULLS NOT DISTINCT");
let simple =
!row.is_partial && !row.has_expression && !row.has_include && !nulls_not_distinct;
if simple && row.is_unique && key_columns.len() == 1 {
unique_columns.insert(key_columns[0].clone());
}
let (columns, definition, key_cols) = if simple && !row.is_constraint {
(key_columns, None, Vec::new())
} else {
let dep_columns: Vec<String> = row
.dep_columns
.split(',')
.filter(|c| !c.is_empty())
.map(str::to_owned)
.collect();
if row.is_constraint && row.constraint_type == "x" {
let definition = format!(
"ALTER TABLE {} ADD CONSTRAINT {} {}",
row.table_name, row.constraint_name, row.constraint_def
);
(dep_columns, Some(definition), Vec::new())
} else {
let key_cols: Vec<String> = row
.key_columns
.split(',')
.filter(|c| !c.is_empty())
.map(str::to_owned)
.collect();
(dep_columns, Some(row.definition.clone()), key_cols)
}
};
indexes.push(Index {
name: row.index_name.clone(),
columns,
unique: row.is_unique,
definition,
is_partial: row.is_partial,
key_columns: key_cols,
});
}
(indexes, unique_columns)
}
fn strip_redundant_default_cast<'a>(raw: &'a str, ty: &ColumnType) -> &'a str {
let trimmed = raw.trim();
let Some((literal, cast_target)) = split_literal_cast(trimmed) else {
return raw;
};
if is_own_pg_type(cast_target, ty) {
literal
} else {
raw
}
}
fn split_literal_cast(raw: &str) -> Option<(&str, &str)> {
let (literal, rest) = if raw.starts_with('\'') {
let mut i = 1; let end = loop {
let rel = raw[i..].find('\'')?;
let q = i + rel;
if raw[q + 1..].starts_with('\'') {
i = q + 2; } else {
break q + 1; }
};
raw.split_at(end)
} else {
let bytes = raw.as_bytes();
let mut i = 0;
if i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') {
i += 1;
}
let int_start = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i == int_start {
return None;
}
if i < bytes.len() && bytes[i] == b'.' {
let dot = i;
i += 1;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i == dot + 1 {
return None; }
}
raw.split_at(i)
};
let target = rest.trim_start().strip_prefix("::")?.trim();
if target.is_empty() {
return None;
}
Some((literal, target))
}
fn is_own_pg_type(target: &str, ty: &ColumnType) -> bool {
let lowered = target.to_ascii_lowercase();
let target = lowered
.strip_prefix("pg_catalog.")
.unwrap_or(lowered.as_str());
if target.contains('(') {
return false;
}
let names: &[&str] = match ty {
ColumnType::Text | ColumnType::Enum { .. } => {
&["text", "character varying", "varchar", "character", "char"]
}
ColumnType::Int32 => &["integer", "int", "int4"],
ColumnType::Int64 => &["bigint", "int8"],
ColumnType::Bool => &["boolean", "bool"],
ColumnType::Float32 => &["real", "float4"],
ColumnType::Float64 => &["double precision", "float8"],
ColumnType::Uuid => &["uuid"],
ColumnType::Timestamp => &["timestamp", "timestamp without time zone"],
ColumnType::TimestampTz => &["timestamp with time zone", "timestamptz"],
ColumnType::Bytes => &["bytea"],
ColumnType::Attachment | ColumnType::Json => &["jsonb"],
ColumnType::Decimal { .. } => &["numeric", "decimal"],
ColumnType::Opaque { .. } => &[],
};
if names.contains(&target) {
return true;
}
if let ColumnType::Opaque { pg_type } = ty {
return target == pg_type.to_ascii_lowercase();
}
false
}
fn normalize_default(
raw: Option<&str>,
ty: &ColumnType,
is_primary_key: bool,
primary_key: &[String],
owns_sequence: bool,
) -> Option<ColumnDefault> {
let raw = raw?.trim();
if raw.is_empty() {
return None;
}
let lowered = raw.to_ascii_lowercase();
if lowered == "now()" || lowered == "current_timestamp" {
return Some(ColumnDefault::Now);
}
if normalize_serial_pk_default(&lowered, ty, is_primary_key, primary_key, owns_sequence) {
return None;
}
Some(ColumnDefault::Sql(
strip_redundant_default_cast(raw, ty).to_owned(),
))
}
fn normalize_serial_pk_default(
lowered_default: &str,
ty: &ColumnType,
is_primary_key: bool,
primary_key: &[String],
owns_sequence: bool,
) -> bool {
is_primary_key
&& primary_key.len() == 1
&& matches!(ty, ColumnType::Int64)
&& lowered_default.starts_with("nextval(")
&& owns_sequence
}
fn serial_kind_for(
raw_default: Option<&str>,
ty: &ColumnType,
is_primary_key: bool,
primary_key: &[String],
owns_sequence: bool,
is_identity: bool,
) -> Option<SerialKind> {
if !is_primary_key
|| primary_key.len() != 1
|| is_identity
|| !matches!(ty, ColumnType::Int32 | ColumnType::Int64)
{
return None;
}
if owns_sequence
&& raw_default
.map(|d| d.trim().to_ascii_lowercase())
.is_some_and(|d| d.starts_with("nextval("))
{
return Some(match ty {
ColumnType::Int32 => SerialKind::Serial,
_ => SerialKind::BigSerial,
});
}
Some(SerialKind::Plain)
}
#[cfg(feature = "sqlite")]
fn sqlite_target(url: &str) -> String {
if url.starts_with("file:") {
return url.to_owned();
}
let rest = url
.strip_prefix("sqlite://")
.or_else(|| url.strip_prefix("sqlite:"))
.unwrap_or(url);
if rest.is_empty() || rest == ":memory:" {
return ":memory:".to_owned();
}
rest.to_owned()
}
#[cfg(feature = "sqlite")]
fn sqlite_existence_check_path(target: &str) -> Option<String> {
if target.is_empty() || target == ":memory:" {
return None;
}
let Some(rest) = target.strip_prefix("file:") else {
return Some(target.to_owned());
};
let (path_part, query) = rest.split_once('?').unwrap_or((rest, ""));
let path_part = path_part.strip_prefix("//").map_or(path_part, |after| {
after.find('/').map_or(after, |idx| &after[idx..])
});
let is_memory = path_part == ":memory:"
|| query
.split('&')
.any(|kv| kv.trim().eq_ignore_ascii_case("mode=memory"));
if is_memory {
return None;
}
let decoded = percent_encoding::percent_decode_str(path_part)
.decode_utf8_lossy()
.into_owned();
Some(decoded)
}
#[cfg(all(test, feature = "sqlite"))]
mod target_tests {
use super::sqlite_existence_check_path;
#[test]
fn existence_check_path_parses_files_and_exempts_memory() {
for mem in [
":memory:",
"",
"file::memory:",
"file::memory:?cache=shared",
"file:app.db?mode=memory",
"file:app.db?cache=shared&mode=memory",
] {
assert_eq!(
sqlite_existence_check_path(mem),
None,
"{mem} is in-memory (exempt)"
);
}
assert_eq!(
sqlite_existence_check_path("/srv/app.db"),
Some("/srv/app.db".to_owned())
);
assert_eq!(
sqlite_existence_check_path("file:/srv/app.db"),
Some("/srv/app.db".to_owned())
);
assert_eq!(
sqlite_existence_check_path("file:///srv/app.db"),
Some("/srv/app.db".to_owned())
);
assert_eq!(
sqlite_existence_check_path("file://localhost/srv/app.db"),
Some("/srv/app.db".to_owned())
);
assert_eq!(
sqlite_existence_check_path("file:app.db?mode=ro"),
Some("app.db".to_owned())
);
assert_eq!(
sqlite_existence_check_path("file:/srv/my%20app.db"),
Some("/srv/my app.db".to_owned()),
"percent-decoded"
);
}
}
#[cfg(feature = "sqlite")]
pub fn introspect_sqlite(url: &str) -> Result<Vec<Table>, IntrospectError> {
use diesel::{Connection as _, SqliteConnection};
let target = sqlite_target(url);
if let Some(path) = sqlite_existence_check_path(&target)
&& !std::path::Path::new(&path).exists()
{
return Err(IntrospectError::ConnectionSqlite { path });
}
let mut conn = SqliteConnection::establish(&target)
.map_err(|_| IntrospectError::ConnectionSqlite { path: target })?;
sqlite::introspect(&mut conn)
}
#[cfg(feature = "sqlite")]
mod sqlite {
use std::collections::BTreeMap;
use autumn_schema_core::{
Backend, Column, ColumnDefault, ColumnType, ForeignKey, Index, SerialKind, Table,
};
use diesel::sql_types::{Integer, Nullable, Text};
use diesel::{QueryableByName, RunQueryDsl as _, SqliteConnection, sql_query};
use super::IntrospectError;
type IndexKey = (String, String);
#[derive(QueryableByName)]
struct TableRow {
#[diesel(sql_type = Text)]
name: String,
#[diesel(sql_type = Nullable<Text>)]
sql: Option<String>,
}
#[derive(QueryableByName)]
struct ColumnRow {
#[diesel(sql_type = Text)]
table_name: String,
#[diesel(sql_type = Text)]
column_name: String,
#[diesel(sql_type = Text)]
decl_type: String,
#[diesel(sql_type = Integer)]
not_null: i32,
#[diesel(sql_type = Nullable<Text>)]
dflt_value: Option<String>,
#[diesel(sql_type = Integer)]
pk: i32,
#[diesel(sql_type = Integer)]
hidden: i32,
}
#[derive(QueryableByName)]
struct IndexRow {
#[diesel(sql_type = Text)]
table_name: String,
#[diesel(sql_type = Text)]
index_name: String,
#[diesel(sql_type = Integer)]
is_unique: i32,
#[diesel(sql_type = Text)]
origin: String,
#[diesel(sql_type = Integer)]
partial: i32,
#[diesel(sql_type = Nullable<Text>)]
index_sql: Option<String>,
}
#[derive(QueryableByName)]
struct IndexColumnRow {
#[diesel(sql_type = Text)]
table_name: String,
#[diesel(sql_type = Text)]
index_name: String,
#[diesel(sql_type = Integer)]
seqno: i32,
#[diesel(sql_type = Nullable<Text>)]
column_name: Option<String>,
}
#[derive(QueryableByName)]
struct ForeignKeyRow {
#[diesel(sql_type = Text)]
table_name: String,
#[diesel(sql_type = Text)]
foreign_table: String,
#[diesel(sql_type = Text)]
column_name: String,
#[diesel(sql_type = Nullable<Text>)]
foreign_column: Option<String>,
}
fn query_err(e: &diesel::result::Error) -> IntrospectError {
IntrospectError::Query(e.to_string())
}
pub(super) fn introspect(conn: &mut SqliteConnection) -> Result<Vec<Table>, IntrospectError> {
let tables = list_tables(conn)?;
if tables.is_empty() {
return Ok(Vec::new());
}
let columns_by_table = fetch_columns(conn)?;
let indexes_by_table = fetch_indexes(conn)?;
let index_columns = fetch_index_columns(conn)?;
let fks_by_table = fetch_foreign_keys(conn)?;
let single_col_pks = single_column_pks(&columns_by_table);
let mut out = Vec::with_capacity(tables.len());
for (name, table_sql) in &tables {
out.push(build_table(
name,
table_sql.as_deref().unwrap_or_default(),
columns_by_table.get(name).map_or(&[][..], Vec::as_slice),
indexes_by_table.get(name).map_or(&[][..], Vec::as_slice),
&index_columns,
fks_by_table.get(name).map_or(&[][..], Vec::as_slice),
&single_col_pks,
));
}
Ok(out)
}
fn single_column_pks(
columns_by_table: &BTreeMap<String, Vec<ColumnRow>>,
) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
for (table, cols) in columns_by_table {
let mut pk_cols = cols.iter().filter(|c| c.pk > 0);
if let (Some(first), None) = (pk_cols.next(), pk_cols.next()) {
out.insert(table.clone(), first.column_name.clone());
}
}
out
}
fn list_tables(
conn: &mut SqliteConnection,
) -> Result<Vec<(String, Option<String>)>, IntrospectError> {
let rows: Vec<TableRow> = sql_query(
"SELECT name AS name, sql AS sql FROM sqlite_master \
WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
)
.load(conn)
.map_err(|e| query_err(&e))?;
let virtual_tables: Vec<String> = rows
.iter()
.filter(|r| r.sql.as_deref().is_some_and(is_create_virtual_table))
.map(|r| r.name.clone())
.collect();
Ok(rows
.into_iter()
.filter(|r| {
!super::is_framework_table(&r.name)
&& !is_virtual_or_fts5_shadow_table(&r.name, &virtual_tables)
})
.map(|r| (r.name, r.sql))
.collect())
}
fn is_create_virtual_table(sql: &str) -> bool {
sql.trim_start()
.to_ascii_uppercase()
.starts_with("CREATE VIRTUAL TABLE")
}
const FTS5_SHADOW_SUFFIXES: [&str; 5] = ["_data", "_idx", "_content", "_docsize", "_config"];
fn is_virtual_or_fts5_shadow_table(name: &str, virtual_tables: &[String]) -> bool {
virtual_tables.iter().any(|vtab| {
name == vtab
|| FTS5_SHADOW_SUFFIXES
.iter()
.any(|suffix| name == format!("{vtab}{suffix}"))
})
}
fn fetch_columns(
conn: &mut SqliteConnection,
) -> Result<BTreeMap<String, Vec<ColumnRow>>, IntrospectError> {
let rows: Vec<ColumnRow> = sql_query(
"SELECT m.name AS table_name, ti.name AS column_name, ti.type AS decl_type, \
ti.\"notnull\" AS not_null, ti.dflt_value AS dflt_value, ti.pk AS pk, \
ti.hidden AS hidden \
FROM sqlite_master m JOIN pragma_table_xinfo(m.name) ti \
WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' \
ORDER BY m.name, ti.cid",
)
.load(conn)
.map_err(|e| query_err(&e))?;
Ok(group_by(rows, |r| r.table_name.clone()))
}
fn fetch_indexes(
conn: &mut SqliteConnection,
) -> Result<BTreeMap<String, Vec<IndexRow>>, IntrospectError> {
let rows: Vec<IndexRow> = sql_query(
"SELECT m.name AS table_name, il.name AS index_name, il.\"unique\" AS is_unique, \
il.origin AS origin, il.partial AS partial, idx.sql AS index_sql \
FROM sqlite_master m JOIN pragma_index_list(m.name) il \
LEFT JOIN sqlite_master idx ON idx.type = 'index' AND idx.name = il.name \
WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' \
ORDER BY m.name, il.name",
)
.load(conn)
.map_err(|e| query_err(&e))?;
Ok(group_by(rows, |r| r.table_name.clone()))
}
fn fetch_index_columns(
conn: &mut SqliteConnection,
) -> Result<BTreeMap<IndexKey, Vec<IndexColumnRow>>, IntrospectError> {
let mut rows: Vec<IndexColumnRow> = sql_query(
"SELECT m.name AS table_name, il.name AS index_name, ii.seqno AS seqno, \
ii.name AS column_name \
FROM sqlite_master m JOIN pragma_index_list(m.name) il \
JOIN pragma_index_info(il.name) ii \
WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' \
ORDER BY m.name, il.name, ii.seqno",
)
.load(conn)
.map_err(|e| query_err(&e))?;
rows.sort_by_key(|r| r.seqno);
let mut by_index: BTreeMap<IndexKey, Vec<IndexColumnRow>> = BTreeMap::new();
for row in rows {
by_index
.entry((row.table_name.clone(), row.index_name.clone()))
.or_default()
.push(row);
}
Ok(by_index)
}
fn fetch_foreign_keys(
conn: &mut SqliteConnection,
) -> Result<BTreeMap<String, Vec<ForeignKeyRow>>, IntrospectError> {
let rows: Vec<ForeignKeyRow> = sql_query(
"SELECT m.name AS table_name, fkl.\"table\" AS foreign_table, \
fkl.\"from\" AS column_name, fkl.\"to\" AS foreign_column \
FROM sqlite_master m JOIN pragma_foreign_key_list(m.name) fkl \
WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' AND fkl.seq = 0 \
AND fkl.id NOT IN ( \
SELECT fkl2.id FROM pragma_foreign_key_list(m.name) fkl2 WHERE fkl2.seq > 0)",
)
.load(conn)
.map_err(|e| query_err(&e))?;
Ok(group_by(rows, |r| r.table_name.clone()))
}
fn group_by<T>(rows: Vec<T>, key: impl Fn(&T) -> String) -> BTreeMap<String, Vec<T>> {
let mut out: BTreeMap<String, Vec<T>> = BTreeMap::new();
for row in rows {
out.entry(key(&row)).or_default().push(row);
}
out
}
#[derive(PartialEq, Eq)]
enum Affinity {
Integer,
Text,
Blob,
Real,
Numeric,
}
fn affinity(decl_type: &str) -> Affinity {
let t = decl_type.to_ascii_uppercase();
if t.contains("INT") {
Affinity::Integer
} else if t.contains("CHAR") || t.contains("CLOB") || t.contains("TEXT") {
Affinity::Text
} else if t.is_empty() || t.contains("BLOB") {
Affinity::Blob
} else if t.contains("REAL") || t.contains("FLOA") || t.contains("DOUB") {
Affinity::Real
} else {
Affinity::Numeric
}
}
fn sqlite_default(raw: Option<&str>) -> Option<ColumnDefault> {
let raw = raw?.trim();
if raw.is_empty() || raw.eq_ignore_ascii_case("NULL") {
return None;
}
if raw.eq_ignore_ascii_case("CURRENT_TIMESTAMP") {
return Some(ColumnDefault::Now);
}
Some(ColumnDefault::Sql(raw.to_owned()))
}
fn sqlite_column_type(decl_type: &str, default: Option<&ColumnDefault>) -> ColumnType {
if matches!(default, Some(ColumnDefault::Now)) {
return ColumnType::Timestamp;
}
match affinity(decl_type) {
Affinity::Integer => ColumnType::Int64,
Affinity::Text => ColumnType::Text,
Affinity::Real => ColumnType::Float64,
Affinity::Blob => ColumnType::Bytes,
Affinity::Numeric => ColumnType::Opaque {
pg_type: decl_type.to_owned(),
},
}
}
fn table_sql_has_autoincrement_keyword(table_sql: &str) -> bool {
const KW: &str = "AUTOINCREMENT";
let stripped = neutralize_sqlite_literals_and_quoted_idents(table_sql).to_ascii_uppercase();
let bytes = stripped.as_bytes();
let is_word = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
let mut from = 0;
while let Some(rel) = stripped[from..].find(KW) {
let start = from + rel;
let end = start + KW.len();
let before_ok = start == 0 || !is_word(bytes[start - 1]);
let after_ok = end == bytes.len() || !is_word(bytes[end]);
if before_ok && after_ok {
return true;
}
from = start + 1;
}
false
}
fn neutralize_sqlite_literals_and_quoted_idents(sql: &str) -> String {
let mut out = String::with_capacity(sql.len());
let mut chars = sql.chars().peekable();
while let Some(c) = chars.next() {
if c == '-' && chars.peek() == Some(&'-') {
out.push(' ');
while let Some(&n) = chars.peek() {
if n == '\n' {
break;
}
chars.next();
out.push(' ');
}
continue;
}
if c == '/' && chars.peek() == Some(&'*') {
chars.next();
out.push(' ');
out.push(' ');
let mut prev_star = false;
for n in chars.by_ref() {
out.push(' ');
if prev_star && n == '/' {
break;
}
prev_star = n == '*';
}
continue;
}
let close = match c {
'\'' => Some('\''),
'"' => Some('"'),
'`' => Some('`'),
'[' => Some(']'),
_ => None,
};
let Some(close) = close else {
out.push(c);
continue;
};
out.push(' '); while let Some(n) = chars.next() {
if n == close {
if close != ']' && chars.peek() == Some(&close) {
chars.next();
out.push(' ');
out.push(' ');
continue;
}
break;
}
out.push(' ');
}
out.push(' '); }
out
}
fn build_table(
name: &str,
table_sql: &str,
columns: &[ColumnRow],
indexes: &[IndexRow],
index_columns: &BTreeMap<IndexKey, Vec<IndexColumnRow>>,
foreign_keys: &[ForeignKeyRow],
single_col_pks: &BTreeMap<String, String>,
) -> Table {
let mut table = Table::new(name, Backend::Sqlite);
table.managed = true;
let mut pk_cols: Vec<(i32, String)> = columns
.iter()
.filter(|c| c.pk > 0)
.map(|c| (c.pk, c.column_name.clone()))
.collect();
pk_cols.sort_by_key(|(ord, _)| *ord);
table.primary_key = pk_cols.into_iter().map(|(_, n)| n).collect();
let single_pk = table.primary_key.len() == 1;
let autoincrement = table_sql_has_autoincrement_keyword(table_sql);
let (ir_indexes, unique_columns) = collapse_indexes(name, indexes, index_columns);
let fk_by_column: BTreeMap<&str, &ForeignKeyRow> = foreign_keys
.iter()
.map(|fk| (fk.column_name.as_str(), fk))
.collect();
for row in columns {
if row.hidden == 1 {
continue;
}
let is_pk = row.pk > 0;
let default = sqlite_default(row.dflt_value.as_deref());
let ty = sqlite_column_type(&row.decl_type, default.as_ref());
let mut column = Column::new(row.column_name.clone(), ty);
column.nullable = row.not_null == 0 && !is_pk;
column.primary_key = is_pk;
column.unique = unique_columns.contains(row.column_name.as_str());
column.default = default;
if is_pk && single_pk && matches!(affinity(&row.decl_type), Affinity::Integer) {
column.serial = Some(if autoincrement {
SerialKind::BigSerial
} else {
SerialKind::Plain
});
}
if let Some(fk) = fk_by_column.get(row.column_name.as_str()) {
let foreign_column = fk.foreign_column.clone().or_else(|| {
single_col_pks.get(&fk.foreign_table).cloned()
});
if let Some(foreign_column) = foreign_column {
column.references =
Some(ForeignKey::new(fk.foreign_table.clone(), foreign_column));
}
}
table.columns.push(column);
}
table.indexes = ir_indexes;
table
}
fn collapse_indexes(
table_name: &str,
rows: &[IndexRow],
index_columns: &BTreeMap<IndexKey, Vec<IndexColumnRow>>,
) -> (Vec<Index>, std::collections::BTreeSet<String>) {
let mut indexes = Vec::new();
let mut unique_columns = std::collections::BTreeSet::new();
for row in rows {
if row.origin == "pk" {
continue;
}
if row.origin == "u" {
let cols = index_columns
.get(&(table_name.to_owned(), row.index_name.clone()))
.map_or(&[][..], Vec::as_slice);
let key_columns: Vec<String> =
cols.iter().filter_map(|c| c.column_name.clone()).collect();
if row.is_unique != 0 && key_columns.len() == 1 {
unique_columns.insert(key_columns[0].clone());
}
continue;
}
let cols = index_columns
.get(&(table_name.to_owned(), row.index_name.clone()))
.map_or(&[][..], Vec::as_slice);
let has_expression = cols.iter().any(|c| c.column_name.is_none());
let key_columns: Vec<String> =
cols.iter().filter_map(|c| c.column_name.clone()).collect();
let is_unique = row.is_unique != 0;
let is_partial = row.partial != 0;
let simple = !is_partial && !has_expression;
if simple && is_unique && key_columns.len() == 1 {
unique_columns.insert(key_columns[0].clone());
}
let definition = if simple { None } else { row.index_sql.clone() };
indexes.push(Index {
name: row.index_name.clone(),
columns: key_columns,
unique: is_unique,
definition,
is_partial,
key_columns: Vec::new(),
});
}
(indexes, unique_columns)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn affinity_maps_the_common_declared_types() {
assert!(matches!(affinity("INTEGER"), Affinity::Integer));
assert!(matches!(affinity("BIGINT"), Affinity::Integer));
assert!(matches!(affinity("TEXT"), Affinity::Text));
assert!(matches!(affinity("VARCHAR(255)"), Affinity::Text));
assert!(matches!(affinity("REAL"), Affinity::Real));
assert!(matches!(affinity("DOUBLE"), Affinity::Real));
assert!(matches!(affinity("BLOB"), Affinity::Blob));
assert!(matches!(affinity(""), Affinity::Blob));
assert!(matches!(affinity("NUMERIC"), Affinity::Numeric));
assert!(matches!(affinity("DECIMAL(10,2)"), Affinity::Numeric));
}
#[test]
fn column_type_is_conservative_and_recovers_conventions() {
assert_eq!(sqlite_column_type("INTEGER", None), ColumnType::Int64);
assert_eq!(sqlite_column_type("TEXT", None), ColumnType::Text);
assert_eq!(sqlite_column_type("REAL", None), ColumnType::Float64);
assert_eq!(sqlite_column_type("BLOB", None), ColumnType::Bytes);
assert_eq!(
sqlite_column_type("TEXT", Some(&ColumnDefault::Now)),
ColumnType::Timestamp
);
assert_eq!(
sqlite_column_type("NUMERIC", None),
ColumnType::Opaque {
pg_type: "NUMERIC".to_owned()
}
);
}
#[test]
fn default_recovers_now_and_preserves_others() {
assert_eq!(sqlite_default(None), None);
assert_eq!(sqlite_default(Some(" ")), None);
assert_eq!(
sqlite_default(Some("CURRENT_TIMESTAMP")),
Some(ColumnDefault::Now)
);
assert_eq!(
sqlite_default(Some("'draft'")),
Some(ColumnDefault::Sql("'draft'".to_owned()))
);
}
#[test]
fn default_bareword_null_is_no_default_but_quoted_null_is_preserved() {
assert_eq!(sqlite_default(Some("NULL")), None);
assert_eq!(sqlite_default(Some("null")), None);
assert_eq!(sqlite_default(Some(" Null ")), None);
assert_eq!(
sqlite_default(Some("'NULL'")),
Some(ColumnDefault::Sql("'NULL'".to_owned()))
);
}
fn col_row(
name: &str,
decl: &str,
not_null: i32,
dflt: Option<&str>,
pk: i32,
) -> ColumnRow {
ColumnRow {
table_name: "posts".to_owned(),
column_name: name.to_owned(),
decl_type: decl.to_owned(),
not_null,
dflt_value: dflt.map(str::to_owned),
pk,
hidden: 0,
}
}
fn col_row_hidden(name: &str, decl: &str, hidden: i32) -> ColumnRow {
ColumnRow {
hidden,
..col_row(name, decl, 0, None, 0)
}
}
#[test]
fn build_table_marks_autoincrement_id_and_recovers_created_at() {
let columns = vec![
col_row("id", "INTEGER", 0, None, 1),
col_row("title", "TEXT", 1, None, 0),
col_row("body", "TEXT", 0, None, 0),
col_row("created_at", "TEXT", 1, Some("CURRENT_TIMESTAMP"), 0),
];
let table_sql = "CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, \
title TEXT NOT NULL, body TEXT NULL, \
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)";
let table = build_table(
"posts",
table_sql,
&columns,
&[],
&BTreeMap::new(),
&[],
&BTreeMap::new(),
);
assert_eq!(table.primary_key, vec!["id".to_owned()]);
let id = table.columns.iter().find(|c| c.name == "id").unwrap();
assert_eq!(id.ty, ColumnType::Int64);
assert!(id.primary_key);
assert!(!id.nullable, "an INTEGER PK is forced non-null");
assert_eq!(id.serial, Some(SerialKind::BigSerial));
let body = table.columns.iter().find(|c| c.name == "body").unwrap();
assert!(body.nullable);
let created = table
.columns
.iter()
.find(|c| c.name == "created_at")
.unwrap();
assert_eq!(created.ty, ColumnType::Timestamp);
assert_eq!(created.default, Some(ColumnDefault::Now));
assert!(!created.nullable);
}
#[test]
fn build_table_plain_integer_pk_is_marked_plain() {
let columns = vec![col_row("id", "INTEGER", 0, None, 1)];
let table_sql = "CREATE TABLE posts (id INTEGER PRIMARY KEY)";
let table = build_table(
"posts",
table_sql,
&columns,
&[],
&BTreeMap::new(),
&[],
&BTreeMap::new(),
);
let id = table.columns.iter().find(|c| c.name == "id").unwrap();
assert_eq!(id.serial, Some(SerialKind::Plain));
}
#[test]
fn autoincrement_keyword_is_tokenized_not_substring_matched() {
assert!(table_sql_has_autoincrement_keyword(
"CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT)"
));
assert!(table_sql_has_autoincrement_keyword(
"create table t (id integer primary key autoincrement )"
));
assert!(!table_sql_has_autoincrement_keyword(
"CREATE TABLE autoincrement_events (id INTEGER PRIMARY KEY)"
));
assert!(!table_sql_has_autoincrement_keyword(
"CREATE TABLE t (autoincrement_flag INTEGER, id INTEGER PRIMARY KEY)"
));
assert!(!table_sql_has_autoincrement_keyword(
"CREATE TABLE t (id INTEGER PRIMARY KEY, kind TEXT DEFAULT 'AUTOINCREMENT')"
));
assert!(!table_sql_has_autoincrement_keyword(
"CREATE TABLE t (\"AUTOINCREMENT\" INTEGER, id INTEGER PRIMARY KEY)"
));
assert!(!table_sql_has_autoincrement_keyword(
"CREATE TABLE t (id INTEGER PRIMARY KEY, [AUTOINCREMENT] TEXT)"
));
assert!(!table_sql_has_autoincrement_keyword(
"CREATE TABLE t (id INTEGER PRIMARY KEY /* AUTOINCREMENT */)"
));
assert!(!table_sql_has_autoincrement_keyword(
"CREATE TABLE t (id INTEGER PRIMARY KEY -- AUTOINCREMENT\n)"
));
assert!(table_sql_has_autoincrement_keyword(
"CREATE TABLE t (id INTEGER /* pk */ PRIMARY KEY AUTOINCREMENT)"
));
}
#[test]
fn build_table_autoincrement_substring_name_is_plain_not_serial() {
let columns = vec![col_row("id", "INTEGER", 0, None, 1)];
let table_sql = "CREATE TABLE autoincrement_events (id INTEGER PRIMARY KEY)";
let table = build_table(
"autoincrement_events",
table_sql,
&columns,
&[],
&BTreeMap::new(),
&[],
&BTreeMap::new(),
);
let id = table.columns.iter().find(|c| c.name == "id").unwrap();
assert_eq!(id.serial, Some(SerialKind::Plain));
}
#[test]
fn build_table_preserves_generated_columns_and_skips_internal_hidden() {
let columns = vec![
col_row("id", "INTEGER", 0, None, 1),
col_row("first", "TEXT", 1, None, 0),
col_row_hidden("full_stored", "TEXT", 3),
col_row_hidden("full_virtual", "TEXT", 2),
col_row_hidden("internal", "TEXT", 1),
];
let table_sql = "CREATE TABLE posts (id INTEGER PRIMARY KEY, first TEXT NOT NULL, \
full_stored TEXT GENERATED ALWAYS AS (first) STORED, \
full_virtual TEXT GENERATED ALWAYS AS (first) VIRTUAL)";
let table = build_table(
"posts",
table_sql,
&columns,
&[],
&BTreeMap::new(),
&[],
&BTreeMap::new(),
);
let names: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
assert!(
names.contains(&"full_stored") && names.contains(&"full_virtual"),
"generated columns must be preserved, not dropped: {names:?}"
);
assert!(
!names.contains(&"internal"),
"an internal hidden column (hidden = 1) is skipped: {names:?}"
);
}
#[test]
fn build_table_resolves_implicit_fk_target_from_parent_pk() {
let columns = vec![
col_row("id", "INTEGER", 0, None, 1),
col_row("parent_code", "TEXT", 1, None, 0),
col_row("orphan_ref", "TEXT", 1, None, 0),
];
let fks = vec![
ForeignKeyRow {
table_name: "children".to_owned(),
foreign_table: "parents".to_owned(),
column_name: "parent_code".to_owned(),
foreign_column: None,
},
ForeignKeyRow {
table_name: "children".to_owned(),
foreign_table: "composite_parent".to_owned(),
column_name: "orphan_ref".to_owned(),
foreign_column: None,
},
];
let mut single_col_pks = BTreeMap::new();
single_col_pks.insert("parents".to_owned(), "code".to_owned());
let table = build_table(
"children",
"CREATE TABLE children (id INTEGER PRIMARY KEY, parent_code TEXT NOT NULL \
REFERENCES parents, orphan_ref TEXT NOT NULL REFERENCES composite_parent)",
&columns,
&[],
&BTreeMap::new(),
&fks,
&single_col_pks,
);
let parent_code = table
.columns
.iter()
.find(|c| c.name == "parent_code")
.unwrap();
let fk = parent_code.references.as_ref().expect("FK resolved");
assert_eq!(fk.table, "parents");
assert_eq!(fk.column, "code", "resolved to the real PK, not `id`");
let orphan = table
.columns
.iter()
.find(|c| c.name == "orphan_ref")
.unwrap();
assert!(
orphan.references.is_none(),
"an implicit FK to a composite/unresolvable-PK parent is omitted"
);
}
#[test]
fn single_column_pks_excludes_composite_and_pkless() {
let mut columns_by_table = BTreeMap::new();
columns_by_table.insert(
"parents".to_owned(),
vec![col_row("code", "TEXT", 1, None, 1)],
);
columns_by_table.insert(
"composite".to_owned(),
vec![
col_row("a", "INTEGER", 1, None, 1),
col_row("b", "INTEGER", 1, None, 2),
],
);
columns_by_table.insert("pkless".to_owned(), vec![col_row("x", "TEXT", 0, None, 0)]);
let pks = single_column_pks(&columns_by_table);
assert_eq!(pks.get("parents").map(String::as_str), Some("code"));
assert!(!pks.contains_key("composite"), "composite PK excluded");
assert!(!pks.contains_key("pkless"), "PK-less table excluded");
}
#[test]
fn collapse_indexes_simple_unique_sets_flag() {
let rows = vec![IndexRow {
table_name: "authors".to_owned(),
index_name: "idx_authors_email_unique".to_owned(),
is_unique: 1,
origin: "c".to_owned(),
partial: 0,
index_sql: Some(
"CREATE UNIQUE INDEX idx_authors_email_unique ON authors (email)".to_owned(),
),
}];
let mut index_columns = BTreeMap::new();
index_columns.insert(
("authors".to_owned(), "idx_authors_email_unique".to_owned()),
vec![IndexColumnRow {
table_name: "authors".to_owned(),
index_name: "idx_authors_email_unique".to_owned(),
seqno: 0,
column_name: Some("email".to_owned()),
}],
);
let (indexes, unique_cols) = collapse_indexes("authors", &rows, &index_columns);
assert_eq!(indexes.len(), 1);
assert_eq!(indexes[0].columns, vec!["email".to_owned()]);
assert!(indexes[0].unique);
assert_eq!(
indexes[0].definition, None,
"a simple index carries no definition"
);
assert!(unique_cols.contains("email"));
}
#[test]
fn collapse_indexes_skips_pk_and_retains_partial_verbatim() {
let rows = vec![
IndexRow {
table_name: "t".to_owned(),
index_name: "sqlite_autoindex_t_1".to_owned(),
is_unique: 1,
origin: "pk".to_owned(),
partial: 0,
index_sql: None,
},
IndexRow {
table_name: "t".to_owned(),
index_name: "t_active_idx".to_owned(),
is_unique: 0,
origin: "c".to_owned(),
partial: 1,
index_sql: Some(
"CREATE INDEX t_active_idx ON t (email) WHERE deleted_at IS NULL"
.to_owned(),
),
},
];
let mut index_columns = BTreeMap::new();
index_columns.insert(
("t".to_owned(), "t_active_idx".to_owned()),
vec![IndexColumnRow {
table_name: "t".to_owned(),
index_name: "t_active_idx".to_owned(),
seqno: 0,
column_name: Some("email".to_owned()),
}],
);
let (indexes, _unique) = collapse_indexes("t", &rows, &index_columns);
assert_eq!(indexes.len(), 1, "the PK auto-index is skipped");
assert_eq!(indexes[0].name, "t_active_idx");
assert!(indexes[0].is_partial);
assert!(
indexes[0].definition.as_deref().unwrap().contains("WHERE"),
"a partial index is retained verbatim via its CREATE INDEX SQL"
);
}
#[test]
fn collapse_indexes_unique_constraint_autoindex_folds_or_skips() {
for (cols, expect_flag) in [(vec!["email"], Some("email")), (vec!["a", "b"], None)] {
let rows = vec![IndexRow {
table_name: "t".to_owned(),
index_name: "sqlite_autoindex_t_1".to_owned(),
is_unique: 1,
origin: "u".to_owned(),
partial: 0,
index_sql: None,
}];
let mut index_columns = BTreeMap::new();
index_columns.insert(
("t".to_owned(), "sqlite_autoindex_t_1".to_owned()),
cols.iter()
.enumerate()
.map(|(i, c)| IndexColumnRow {
table_name: "t".to_owned(),
index_name: "sqlite_autoindex_t_1".to_owned(),
seqno: i32::try_from(i).unwrap(),
column_name: Some((*c).to_owned()),
})
.collect(),
);
let (indexes, unique_cols) = collapse_indexes("t", &rows, &index_columns);
assert!(
indexes.is_empty(),
"a UNIQUE-constraint autoindex is never an Index: {indexes:?}"
);
match expect_flag {
Some(c) => assert!(
unique_cols.contains(c),
"single-column inline UNIQUE folds into the column flag"
),
None => assert!(
unique_cols.is_empty(),
"a composite UNIQUE marks no single column unique: {unique_cols:?}"
),
}
}
}
#[test]
fn create_virtual_table_and_fts5_shadow_detection() {
assert!(is_create_virtual_table(
"CREATE VIRTUAL TABLE \"posts__fts\" USING fts5(\"title\")"
));
assert!(is_create_virtual_table(
" create virtual table x using fts5(a)"
));
assert!(!is_create_virtual_table(
"CREATE TABLE posts (id INTEGER PRIMARY KEY)"
));
let vtabs = vec!["posts__fts".to_owned()];
for shadow in [
"posts__fts",
"posts__fts_data",
"posts__fts_idx",
"posts__fts_content",
"posts__fts_docsize",
"posts__fts_config",
] {
assert!(
is_virtual_or_fts5_shadow_table(shadow, &vtabs),
"{shadow} must be excluded"
);
}
assert!(!is_virtual_or_fts5_shadow_table("posts", &vtabs));
assert!(!is_virtual_or_fts5_shadow_table("posts__fts_other", &vtabs));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_host_port_extracts_and_defaults() {
let (host, port) = parse_host_port("postgres://u:pw@db.example.com:6543/app").unwrap();
assert_eq!(host, "db.example.com");
assert_eq!(port, 6543);
let (host, port) = parse_host_port("postgres://localhost/app").unwrap();
assert_eq!(host, "localhost");
assert_eq!(port, 5432);
}
#[test]
fn connection_error_is_credential_safe() {
let err = IntrospectError::Connection {
host: "db.example.com".to_owned(),
port: 5432,
};
let msg = err.to_string();
assert!(msg.contains("db.example.com"));
assert!(!msg.contains("hunter2") && !msg.contains("postgres://"));
}
#[test]
fn framework_tables_are_excluded_user_tables_kept() {
for fw in [
"autumn_jobs",
"_autumn_version_history",
"api_tokens",
"feature_flag_changes",
"__diesel_schema_migrations",
] {
assert!(is_framework_table(fw), "{fw} is a framework table");
}
for user in ["posts", "comments", "accounts"] {
assert!(!is_framework_table(user), "{user} must be kept");
}
}
#[test]
fn normalize_default_now_variants() {
let now = normalize_default(Some("now()"), &ColumnType::Timestamp, false, &[], false);
assert_eq!(now, Some(ColumnDefault::Now));
let ct = normalize_default(
Some("CURRENT_TIMESTAMP"),
&ColumnType::TimestampTz,
false,
&[],
false,
);
assert_eq!(ct, Some(ColumnDefault::Now));
}
#[test]
fn normalize_default_strips_redundant_own_type_cast_on_literals() {
let norm = |raw: &str, ty: &ColumnType| normalize_default(Some(raw), ty, false, &[], false);
assert_eq!(
norm("'{}'::text", &ColumnType::Text),
Some(ColumnDefault::Sql("'{}'".to_owned()))
);
assert_eq!(
norm("'draft'::text", &ColumnType::Text),
Some(ColumnDefault::Sql("'draft'".to_owned()))
);
assert_eq!(
norm("'draft'::character varying", &ColumnType::Text),
Some(ColumnDefault::Sql("'draft'".to_owned()))
);
assert_eq!(
norm("42::integer", &ColumnType::Int32),
Some(ColumnDefault::Sql("42".to_owned()))
);
assert_eq!(
norm("-1.5::double precision", &ColumnType::Float64),
Some(ColumnDefault::Sql("-1.5".to_owned()))
);
assert_eq!(
norm("'{}'::PG_CATALOG.TEXT", &ColumnType::Text),
Some(ColumnDefault::Sql("'{}'".to_owned()))
);
assert_eq!(
norm("'it''s'::text", &ColumnType::Text),
Some(ColumnDefault::Sql("'it''s'".to_owned()))
);
}
#[test]
fn normalize_default_preserves_load_bearing_casts_verbatim() {
let norm = |raw: &str, ty: &ColumnType| normalize_default(Some(raw), ty, false, &[], false);
assert_eq!(
norm("'{}'::integer", &ColumnType::Text),
Some(ColumnDefault::Sql("'{}'::integer".to_owned()))
);
assert_eq!(
norm("'{}'::text", &ColumnType::Int32),
Some(ColumnDefault::Sql("'{}'::text".to_owned()))
);
assert_eq!(
norm("'abcdef'::character(3)", &ColumnType::Text),
Some(ColumnDefault::Sql("'abcdef'::character(3)".to_owned()))
);
assert_eq!(
norm("nextval('posts_id_seq'::regclass)", &ColumnType::Int64),
Some(ColumnDefault::Sql(
"nextval('posts_id_seq'::regclass)".to_owned()
))
);
assert_eq!(
norm("now()", &ColumnType::Timestamp),
Some(ColumnDefault::Now)
);
assert_eq!(
norm("'{}'", &ColumnType::Text),
Some(ColumnDefault::Sql("'{}'".to_owned()))
);
}
#[test]
fn translatable_column_round_trips_without_set_default() {
use crate::schema::diff::{DiffOptions, diff_schema};
use crate::schema::parse::parse_model_source;
let src = r"
#[autumn_web::model]
pub struct Post {
#[id]
pub id: i64,
#[translatable]
pub title: autumn_web::i18n::Translated,
}
";
let desired = parse_model_source(src, Backend::Postgres).expect("parse");
let mut baseline = desired.tables.clone();
for table in &mut baseline {
for column in &mut table.columns {
if column.name == "title" {
column.default =
normalize_default(Some("'{}'::text"), &column.ty, false, &[], false);
}
}
}
let plan = diff_schema(
&baseline,
&desired,
DiffOptions {
allow_destructive: false,
definitions_authoritative: false,
},
);
assert!(
plan.is_empty(),
"translatable round-trip must be drift-free, got: {:?}",
plan.changes
);
}
#[test]
fn normalize_default_null_is_none() {
assert_eq!(
normalize_default(None, &ColumnType::Text, false, &[], false),
None
);
assert_eq!(
normalize_default(Some(" "), &ColumnType::Text, false, &[], false),
None
);
}
#[test]
fn normalize_serial_pk_default_stripped_to_none() {
let pk = vec!["id".to_owned()];
let d = normalize_default(
Some("nextval('posts_id_seq'::regclass)"),
&ColumnType::Int64,
true,
&pk,
true, );
assert_eq!(
d, None,
"int8 serial PK default (owned sequence) must be stripped to None"
);
}
#[test]
fn normalize_int8_serial_pk_shared_sequence_default_is_preserved() {
let pk = vec!["id".to_owned()];
let d = normalize_default(
Some("nextval('global_ids'::regclass)"),
&ColumnType::Int64,
true,
&pk,
false, );
assert_eq!(
d,
Some(ColumnDefault::Sql(
"nextval('global_ids'::regclass)".to_owned()
)),
"int8 PK default over a non-owned (shared) sequence must be preserved verbatim"
);
}
#[test]
fn normalize_int4_serial_pk_default_is_kept() {
let pk = vec!["id".to_owned()];
let d = normalize_default(
Some("nextval('counters_id_seq'::regclass)"),
&ColumnType::Int32,
true,
&pk,
true, );
assert_eq!(
d,
Some(ColumnDefault::Sql(
"nextval('counters_id_seq'::regclass)".to_owned()
)),
"int4 SERIAL PK default must be preserved, not stripped"
);
}
#[test]
fn normalize_int4_pk_without_default_stays_none() {
let pk = vec!["id".to_owned()];
let d = normalize_default(None, &ColumnType::Int32, true, &pk, false);
assert_eq!(d, None, "a plain int4 PK carries no default");
}
#[test]
fn normalize_serial_default_on_non_pk_is_kept_as_sql() {
let d = normalize_default(
Some("nextval('counter_seq'::regclass)"),
&ColumnType::Int64,
false,
&[],
true,
);
assert!(matches!(d, Some(ColumnDefault::Sql(_))));
}
#[test]
fn serial_kind_marker_distinguishes_bigserial_from_plain_bigint_pk() {
let pk = vec!["id".to_owned()];
assert_eq!(
serial_kind_for(
Some("nextval('posts_id_seq'::regclass)"),
&ColumnType::Int64,
true,
&pk,
true,
false
),
Some(SerialKind::BigSerial)
);
assert_eq!(
serial_kind_for(
Some("nextval('counters_id_seq'::regclass)"),
&ColumnType::Int32,
true,
&pk,
true,
false
),
Some(SerialKind::Serial)
);
assert_eq!(
serial_kind_for(None, &ColumnType::Int64, true, &pk, false, false),
Some(SerialKind::Plain)
);
assert_eq!(
serial_kind_for(
Some("nextval('global_ids'::regclass)"),
&ColumnType::Int64,
true,
&pk,
false,
false
),
Some(SerialKind::Plain)
);
assert_eq!(
serial_kind_for(
Some("nextval('c_seq'::regclass)"),
&ColumnType::Int64,
false,
&[],
true,
false
),
None
);
assert_eq!(
serial_kind_for(None, &ColumnType::Uuid, true, &pk, false, false),
None
);
assert_eq!(
serial_kind_for(None, &ColumnType::Int64, true, &pk, false, true),
None
);
}
#[test]
fn build_table_marks_bigserial_id_and_preserves_identity() {
let columns = vec![
ColumnRow {
table_name: "t".to_owned(),
column_name: "id".to_owned(),
udt_name: "int8".to_owned(),
is_nullable: "NO".to_owned(),
column_default: Some("nextval('t_id_seq'::regclass)".to_owned()),
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
domain_name: None,
domain_schema: None,
owns_sequence: true,
is_identity: "NO".to_owned(),
identity_generation: None,
},
ColumnRow {
table_name: "t".to_owned(),
column_name: "seqno".to_owned(),
udt_name: "int8".to_owned(),
is_nullable: "NO".to_owned(),
column_default: None,
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
domain_name: None,
domain_schema: None,
owns_sequence: false,
is_identity: "YES".to_owned(),
identity_generation: Some("ALWAYS".to_owned()),
},
];
let pk = vec!["id".to_owned()];
let table = build_table("t", &columns, &pk, &[], &[], &[]);
let id = table.columns.iter().find(|c| c.name == "id").unwrap();
assert_eq!(id.serial, Some(SerialKind::BigSerial));
assert_eq!(
id.default, None,
"the owned int8 serial default is stripped"
);
assert!(id.identity.is_none());
let seqno = table.columns.iter().find(|c| c.name == "seqno").unwrap();
assert_eq!(
seqno.identity.as_deref(),
Some("ALWAYS"),
"an identity column preserves its generation clause verbatim"
);
assert!(
seqno.serial.is_none(),
"an identity column is not a serial (no owned nextval default)"
);
}
#[test]
fn collapse_indexes_nulls_not_distinct_unique_retained_verbatim() {
let def = "CREATE UNIQUE INDEX u_idx ON public.t USING btree (email) NULLS NOT DISTINCT";
let rows = vec![index_row(
"u_idx", true, "email", "email", false, false, def,
)];
let (indexes, unique_cols) = collapse_indexes(&rows);
assert_eq!(indexes.len(), 1);
assert_eq!(
indexes[0].definition.as_deref(),
Some(def),
"a NULLS NOT DISTINCT unique index must be retained verbatim via its definition"
);
assert!(
unique_cols.is_empty(),
"a NULLS NOT DISTINCT unique index is not a plain simple unique, so it \
sets no single-column unique flag"
);
}
#[test]
fn normalize_uuid_pk_default_is_kept_as_sql() {
let pk = vec!["id".to_owned()];
let d = normalize_default(
Some("gen_random_uuid()"),
&ColumnType::Uuid,
true,
&pk,
false,
);
assert_eq!(d, Some(ColumnDefault::Sql("gen_random_uuid()".to_owned())));
}
fn index_row(
index_name: &str,
is_unique: bool,
columns: &str,
dep_columns: &str,
is_partial: bool,
has_expression: bool,
definition: &str,
) -> IndexRow {
IndexRow {
table_name: "t".to_owned(),
index_name: index_name.to_owned(),
is_unique,
definition: definition.to_owned(),
is_partial,
has_expression,
has_include: false,
is_constraint: false,
constraint_type: String::new(),
constraint_name: String::new(),
constraint_def: String::new(),
columns: columns.to_owned(),
key_columns: if has_expression {
String::new()
} else {
columns.to_owned()
},
dep_columns: dep_columns.to_owned(),
}
}
fn constraint_index_row(
index_name: &str,
is_unique: bool,
columns: &str,
definition: &str,
) -> IndexRow {
IndexRow {
is_constraint: true,
constraint_type: "u".to_owned(),
..index_row(
index_name, is_unique, columns, columns, false, false, definition,
)
}
}
fn exclude_constraint_index_row(
index_name: &str,
constraint_name: &str,
dep_columns: &str,
index_definition: &str,
constraint_def: &str,
) -> IndexRow {
IndexRow {
is_constraint: true,
constraint_type: "x".to_owned(),
constraint_name: constraint_name.to_owned(),
constraint_def: constraint_def.to_owned(),
..index_row(
index_name,
false,
dep_columns,
dep_columns,
false,
false,
index_definition,
)
}
}
#[test]
fn collapse_indexes_single_column_unique_sets_flag_and_index() {
let rows = vec![index_row(
"idx_accounts_email_unique",
true,
"email",
"email",
false,
false,
"CREATE UNIQUE INDEX idx_accounts_email_unique ON public.accounts USING btree (email)",
)];
let (indexes, unique_cols) = collapse_indexes(&rows);
assert_eq!(indexes.len(), 1);
assert_eq!(indexes[0].name, "idx_accounts_email_unique");
assert_eq!(indexes[0].columns, vec!["email".to_owned()]);
assert!(indexes[0].unique);
assert_eq!(indexes[0].definition, None);
assert!(unique_cols.contains("email"));
}
#[test]
fn collapse_indexes_plain_index_no_unique_flag() {
let rows = vec![index_row(
"idx_comments_post_id",
false,
"post_id",
"post_id",
false,
false,
"CREATE INDEX idx_comments_post_id ON public.comments USING btree (post_id)",
)];
let (indexes, unique_cols) = collapse_indexes(&rows);
assert_eq!(indexes.len(), 1);
assert!(!indexes[0].unique);
assert_eq!(indexes[0].definition, None);
assert!(unique_cols.is_empty());
}
#[test]
fn collapse_indexes_multi_column_unique_only_index_not_column_flag() {
let rows = vec![index_row(
"idx_memberships_org_user",
true,
"org_id,user_id",
"org_id,user_id",
false,
false,
"CREATE UNIQUE INDEX idx_memberships_org_user ON public.memberships USING btree (org_id, user_id)",
)];
let (indexes, unique_cols) = collapse_indexes(&rows);
assert_eq!(indexes.len(), 1);
assert_eq!(
indexes[0].columns,
vec!["org_id".to_owned(), "user_id".to_owned()]
);
assert!(indexes[0].unique);
assert_eq!(indexes[0].definition, None);
assert!(
unique_cols.is_empty(),
"composite unique sets no column flag"
);
}
#[test]
fn collapse_indexes_expression_index_preserved_not_dropped() {
let def = "CREATE INDEX users_lower_email_idx ON public.users USING btree (lower(email))";
let rows = vec![index_row(
"users_lower_email_idx",
false,
"", "email", false,
true,
def,
)];
let (indexes, unique_cols) = collapse_indexes(&rows);
assert_eq!(indexes.len(), 1, "expression index preserved, not dropped");
assert_eq!(indexes[0].name, "users_lower_email_idx");
assert_eq!(indexes[0].columns, vec!["email".to_owned()]);
assert_eq!(indexes[0].definition.as_deref(), Some(def));
assert!(unique_cols.is_empty());
}
#[test]
fn collapse_indexes_partial_index_preserves_predicate() {
let def = "CREATE INDEX users_active_idx ON public.users USING btree (email) \
WHERE (deleted_at IS NULL)";
let rows = vec![index_row(
"users_active_idx",
false,
"email", "deleted_at,email", true,
false,
def,
)];
let (indexes, unique_cols) = collapse_indexes(&rows);
assert_eq!(indexes.len(), 1);
assert_eq!(
indexes[0].columns,
vec!["deleted_at".to_owned(), "email".to_owned()]
);
assert_eq!(indexes[0].definition.as_deref(), Some(def));
assert!(
unique_cols.is_empty(),
"partial index sets no column unique flag"
);
}
#[test]
fn collapse_indexes_constraint_owned_index_retained_via_definition() {
let def = "CREATE UNIQUE INDEX users_email_key ON public.users USING btree (email)";
let rows = vec![constraint_index_row("users_email_key", true, "email", def)];
let (indexes, unique_cols) = collapse_indexes(&rows);
assert_eq!(indexes.len(), 1);
assert_eq!(indexes[0].name, "users_email_key");
assert_eq!(indexes[0].columns, vec!["email".to_owned()]);
assert!(indexes[0].unique);
assert_eq!(
indexes[0].definition.as_deref(),
Some(def),
"constraint-owned index must be retained via its definition, not left droppable"
);
assert!(
unique_cols.contains("email"),
"single-column constraint-owned unique still sets the column unique flag"
);
}
#[test]
fn collapse_indexes_exclude_constraint_retained_as_real_constraint() {
let index_def =
"CREATE INDEX reservations_during_excl ON public.reservations USING gist (during)";
let constraint_def = "EXCLUDE USING gist (during WITH &&)";
let rows = vec![exclude_constraint_index_row(
"reservations_during_excl",
"reservations_during_excl",
"during",
index_def,
constraint_def,
)];
let (indexes, unique_cols) = collapse_indexes(&rows);
assert_eq!(indexes.len(), 1);
let idx = &indexes[0];
let def = idx
.definition
.as_deref()
.expect("EXCLUDE constraint must be retained via a definition");
assert!(
def.contains("ADD CONSTRAINT reservations_during_excl")
&& def.contains("EXCLUDE USING gist"),
"EXCLUDE index must be recreated as the real ADD CONSTRAINT … EXCLUDE form, got: {def}"
);
assert!(
!def.starts_with("CREATE INDEX") && !def.contains("CREATE INDEX"),
"the retained definition must NOT be the bare backing CREATE INDEX, got: {def}"
);
assert_eq!(
def,
"ALTER TABLE t ADD CONSTRAINT reservations_during_excl EXCLUDE USING gist (during WITH &&)",
"the ALTER TABLE must use the bare table name form the emitter uses elsewhere"
);
assert_eq!(idx.columns, vec!["during".to_owned()]);
assert!(!idx.unique, "an EXCLUDE index is not unique");
assert!(
idx.key_columns.is_empty(),
"EXCLUDE index leaves key_columns empty (not a #[unique] candidate)"
);
assert!(
unique_cols.is_empty(),
"an EXCLUDE constraint sets no column unique flag"
);
}
#[test]
fn collapse_indexes_model_style_unique_index_stays_ordinary_droppable() {
let def = "CREATE UNIQUE INDEX idx_users_email_unique ON public.users USING btree (email)";
let rows = vec![index_row(
"idx_users_email_unique",
true,
"email",
"email",
false,
false,
def,
)];
let (indexes, unique_cols) = collapse_indexes(&rows);
assert_eq!(indexes.len(), 1);
assert_eq!(indexes[0].name, "idx_users_email_unique");
assert!(indexes[0].unique);
assert_eq!(
indexes[0].definition, None,
"a non-constraint (model-style) unique index stays ordinary/droppable"
);
assert!(unique_cols.contains("email"));
}
#[test]
fn collapse_indexes_covering_include_index_retained_via_definition() {
let def = "CREATE UNIQUE INDEX idx_cover ON public.t USING btree (a) INCLUDE (b)";
let rows = vec![IndexRow {
has_include: true,
..index_row("idx_cover", true, "a,b", "a,b", false, false, def)
}];
let (indexes, unique_cols) = collapse_indexes(&rows);
assert_eq!(indexes.len(), 1);
assert_eq!(indexes[0].name, "idx_cover");
assert!(indexes[0].unique);
assert_eq!(
indexes[0].definition.as_deref(),
Some(def),
"an INCLUDE covering index must be retained via its definition, not left droppable"
);
assert_eq!(
indexes[0].columns,
vec!["a".to_owned(), "b".to_owned()],
"columns carries the exact pg_depend dependent set (key + INCLUDE)"
);
assert!(
unique_cols.is_empty(),
"an INCLUDE unique index must not set a single-column unique flag \
(its uniqueness scope is only the key column)"
);
}
#[test]
fn build_table_assembles_columns_pk_fk_and_indexes() {
let columns = vec![
ColumnRow {
table_name: "comments".to_owned(),
column_name: "id".to_owned(),
udt_name: "int8".to_owned(),
is_nullable: "NO".to_owned(),
column_default: Some("nextval('comments_id_seq'::regclass)".to_owned()),
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
domain_name: None,
domain_schema: None,
owns_sequence: true,
is_identity: "NO".to_owned(),
identity_generation: None,
},
ColumnRow {
table_name: "comments".to_owned(),
column_name: "post_id".to_owned(),
udt_name: "int8".to_owned(),
is_nullable: "NO".to_owned(),
column_default: None,
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
domain_name: None,
domain_schema: None,
owns_sequence: false,
is_identity: "NO".to_owned(),
identity_generation: None,
},
];
let pk = vec!["id".to_owned()];
let indexes = vec![index_row(
"idx_comments_post_id",
false,
"post_id",
"post_id",
false,
false,
"CREATE INDEX idx_comments_post_id ON public.comments USING btree (post_id)",
)];
let fks = vec![ForeignKeyRow {
table_name: "comments".to_owned(),
column_name: "post_id".to_owned(),
foreign_table: "posts".to_owned(),
foreign_schema: "public".to_owned(),
foreign_column: "id".to_owned(),
}];
let table = build_table("comments", &columns, &pk, &indexes, &fks, &[]);
assert_eq!(table.name, "comments");
assert!(table.managed);
assert_eq!(table.primary_key, vec!["id".to_owned()]);
let id = table.columns.iter().find(|c| c.name == "id").unwrap();
assert_eq!(id.ty, ColumnType::Int64);
assert!(id.primary_key);
assert_eq!(id.default, None);
let post_id = table.columns.iter().find(|c| c.name == "post_id").unwrap();
assert_eq!(post_id.references, Some(ForeignKey::new("posts", "id")));
assert_eq!(table.indexes.len(), 1);
assert_eq!(table.indexes[0].name, "idx_comments_post_id");
}
#[test]
fn build_table_preserves_opaque_type() {
let columns = vec![ColumnRow {
table_name: "widgets".to_owned(),
column_name: "addr".to_owned(),
udt_name: "inet".to_owned(),
is_nullable: "YES".to_owned(),
column_default: None,
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
domain_name: None,
domain_schema: None,
owns_sequence: false,
is_identity: "NO".to_owned(),
identity_generation: None,
}];
let table = build_table("widgets", &columns, &[], &[], &[], &[]);
let addr = table.columns.iter().find(|c| c.name == "addr").unwrap();
assert_eq!(
addr.ty,
ColumnType::Opaque {
pg_type: "inet".to_owned()
}
);
assert!(addr.nullable);
}
#[test]
fn build_table_preserves_domain_column_as_opaque() {
let columns = vec![
ColumnRow {
table_name: "contacts".to_owned(),
column_name: "email".to_owned(),
udt_name: "text".to_owned(),
is_nullable: "NO".to_owned(),
column_default: None,
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
domain_name: Some("email_dom".to_owned()),
domain_schema: Some("public".to_owned()),
owns_sequence: false,
is_identity: "NO".to_owned(),
identity_generation: None,
},
ColumnRow {
table_name: "contacts".to_owned(),
column_name: "note".to_owned(),
udt_name: "text".to_owned(),
is_nullable: "YES".to_owned(),
column_default: None,
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
domain_name: None,
domain_schema: None,
owns_sequence: false,
is_identity: "NO".to_owned(),
identity_generation: None,
},
];
let table = build_table("contacts", &columns, &[], &[], &[], &[]);
let email = table.columns.iter().find(|c| c.name == "email").unwrap();
assert_eq!(
email.ty,
ColumnType::Opaque {
pg_type: "email_dom".to_owned()
},
"a public-schema domain column preserves the bare domain name as Opaque"
);
let note = table.columns.iter().find(|c| c.name == "note").unwrap();
assert_eq!(
note.ty,
ColumnType::Text,
"a non-domain text column maps to Text unchanged"
);
}
#[test]
fn build_table_preserves_length_limited_char_columns_as_opaque() {
let columns = vec![
ColumnRow {
table_name: "labels".to_owned(),
column_name: "code".to_owned(),
udt_name: "varchar".to_owned(),
is_nullable: "NO".to_owned(),
column_default: None,
numeric_precision: None,
numeric_scale: None,
character_maximum_length: Some(32),
domain_name: None,
domain_schema: None,
owns_sequence: false,
is_identity: "NO".to_owned(),
identity_generation: None,
},
ColumnRow {
table_name: "labels".to_owned(),
column_name: "kind".to_owned(),
udt_name: "bpchar".to_owned(),
is_nullable: "NO".to_owned(),
column_default: None,
numeric_precision: None,
numeric_scale: None,
character_maximum_length: Some(2),
domain_name: None,
domain_schema: None,
owns_sequence: false,
is_identity: "NO".to_owned(),
identity_generation: None,
},
ColumnRow {
table_name: "labels".to_owned(),
column_name: "note".to_owned(),
udt_name: "text".to_owned(),
is_nullable: "YES".to_owned(),
column_default: None,
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
domain_name: None,
domain_schema: None,
owns_sequence: false,
is_identity: "NO".to_owned(),
identity_generation: None,
},
];
let table = build_table("labels", &columns, &[], &[], &[], &[]);
let code = table.columns.iter().find(|c| c.name == "code").unwrap();
assert_eq!(
code.ty,
ColumnType::Opaque {
pg_type: "varchar(32)".to_owned()
},
"VARCHAR(32) is preserved as Opaque, not flattened to Text"
);
let kind = table.columns.iter().find(|c| c.name == "kind").unwrap();
assert_eq!(
kind.ty,
ColumnType::Opaque {
pg_type: "char(2)".to_owned()
},
"CHAR(2) is preserved as Opaque, not flattened to Text"
);
let note = table.columns.iter().find(|c| c.name == "note").unwrap();
assert_eq!(
note.ty,
ColumnType::Text,
"an unbounded text column stays Text"
);
}
#[test]
fn build_table_domain_precedence_over_length_floor() {
let columns = vec![ColumnRow {
table_name: "labels".to_owned(),
column_name: "code".to_owned(),
udt_name: "varchar".to_owned(),
is_nullable: "NO".to_owned(),
column_default: None,
numeric_precision: None,
numeric_scale: None,
character_maximum_length: Some(32),
domain_name: Some("code_dom".to_owned()),
domain_schema: Some("public".to_owned()),
owns_sequence: false,
is_identity: "NO".to_owned(),
identity_generation: None,
}];
let table = build_table("labels", &columns, &[], &[], &[], &[]);
let code = table.columns.iter().find(|c| c.name == "code").unwrap();
assert_eq!(
code.ty,
ColumnType::Opaque {
pg_type: "code_dom".to_owned()
},
"the domain floor takes precedence over the length floor"
);
}
#[test]
fn build_table_qualifies_non_public_domain_column() {
let columns = vec![ColumnRow {
table_name: "contacts".to_owned(),
column_name: "email".to_owned(),
udt_name: "text".to_owned(),
is_nullable: "NO".to_owned(),
column_default: None,
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
domain_name: Some("email_dom".to_owned()),
domain_schema: Some("otherschema".to_owned()),
owns_sequence: false,
is_identity: "NO".to_owned(),
identity_generation: None,
}];
let table = build_table("contacts", &columns, &[], &[], &[], &[]);
let email = table.columns.iter().find(|c| c.name == "email").unwrap();
assert_eq!(
email.ty,
ColumnType::Opaque {
pg_type: "otherschema.email_dom".to_owned()
}
);
}
#[test]
fn build_table_qualifies_non_public_fk_target() {
let columns = vec![ColumnRow {
table_name: "sessions".to_owned(),
column_name: "account_id".to_owned(),
udt_name: "int8".to_owned(),
is_nullable: "YES".to_owned(),
column_default: None,
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
domain_name: None,
domain_schema: None,
owns_sequence: false,
is_identity: "NO".to_owned(),
identity_generation: None,
}];
let fks = vec![ForeignKeyRow {
table_name: "sessions".to_owned(),
column_name: "account_id".to_owned(),
foreign_table: "accounts".to_owned(),
foreign_schema: "auth".to_owned(),
foreign_column: "id".to_owned(),
}];
let table = build_table("sessions", &columns, &[], &[], &fks, &[]);
let account_id = table
.columns
.iter()
.find(|c| c.name == "account_id")
.unwrap();
assert_eq!(
account_id.references,
Some(ForeignKey::new("auth.accounts", "id")),
"a cross-schema FK target is stored schema-qualified"
);
}
#[test]
fn build_table_keeps_public_fk_target_bare() {
let columns = vec![ColumnRow {
table_name: "comments".to_owned(),
column_name: "post_id".to_owned(),
udt_name: "int8".to_owned(),
is_nullable: "YES".to_owned(),
column_default: None,
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
domain_name: None,
domain_schema: None,
owns_sequence: false,
is_identity: "NO".to_owned(),
identity_generation: None,
}];
let fks = vec![ForeignKeyRow {
table_name: "comments".to_owned(),
column_name: "post_id".to_owned(),
foreign_table: "posts".to_owned(),
foreign_schema: "public".to_owned(),
foreign_column: "id".to_owned(),
}];
let table = build_table("comments", &columns, &[], &[], &fks, &[]);
let post_id = table.columns.iter().find(|c| c.name == "post_id").unwrap();
assert_eq!(
post_id.references,
Some(ForeignKey::new("posts", "id")),
"a public FK target stays bare, unchanged"
);
}
#[test]
fn build_table_populates_check_constraints() {
let columns = vec![ColumnRow {
table_name: "products".to_owned(),
column_name: "price".to_owned(),
udt_name: "int4".to_owned(),
is_nullable: "NO".to_owned(),
column_default: None,
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
domain_name: None,
domain_schema: None,
owns_sequence: false,
is_identity: "NO".to_owned(),
identity_generation: None,
}];
let checks = vec![CheckRow {
table_name: "products".to_owned(),
constraint_name: "products_price_check".to_owned(),
definition: "CHECK ((price >= 0))".to_owned(),
}];
let table = build_table("products", &columns, &[], &[], &[], &checks);
assert_eq!(table.checks.len(), 1);
assert_eq!(
table.checks[0].name.as_deref(),
Some("products_price_check")
);
assert_eq!(table.checks[0].expression, "(price >= 0)");
}
#[test]
fn build_table_without_checks_is_empty() {
let columns = vec![ColumnRow {
table_name: "products".to_owned(),
column_name: "price".to_owned(),
udt_name: "int4".to_owned(),
is_nullable: "NO".to_owned(),
column_default: None,
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
domain_name: None,
domain_schema: None,
owns_sequence: false,
is_identity: "NO".to_owned(),
identity_generation: None,
}];
let table = build_table("products", &columns, &[], &[], &[], &[]);
assert!(
table.checks.is_empty(),
"a table with no CHECK rows has empty checks (unchanged)"
);
}
#[test]
fn strip_check_wrapper_unwraps_pg_constraintdef() {
assert_eq!(strip_check_wrapper("CHECK ((price >= 0))"), "(price >= 0)");
assert_eq!(
strip_check_wrapper("CHECK (status IN ('a','b'))"),
"status IN ('a','b')"
);
assert_eq!(strip_check_wrapper("price >= 0"), "price >= 0");
}
#[test]
fn strip_check_wrapper_drops_trailing_suffixes() {
assert_eq!(
strip_check_wrapper("CHECK ((price >= 0)) NOT VALID"),
"(price >= 0)"
);
assert_eq!(strip_check_wrapper("CHECK (x > 0) NO INHERIT"), "x > 0");
assert_eq!(strip_check_wrapper("CHECK (a = 1)"), "a = 1");
assert_eq!(
strip_check_wrapper("CHECK (label <> ')') NOT VALID"),
"label <> ')'"
);
}
}