use ahash::AHashMap;
use sha2::{Digest, Sha256};
use crate::dialect::SqlDialect;
use super::Catalog;
const FINGERPRINT_ALGORITHM_TAG: &str = "sch1";
const TRUNCATED_BYTES: usize = 8;
impl Catalog {
pub fn fingerprint(&self) -> String {
let canonical = self.canonical_form();
let digest = Sha256::digest(canonical.as_bytes());
let hex: String = digest[..TRUNCATED_BYTES].iter().map(|b| format!("{b:02x}")).collect();
format!("{FINGERPRINT_ALGORITHM_TAG}:{hex}")
}
fn canonical_form(&self) -> String {
let mut lines: Vec<String> = Vec::new();
lines.push(format!("dialect\t{}", dialect_tag(self.dialect)));
for (key, table) in canonical_entries(&self.tables) {
let key = escape_component(&key);
lines.push(format!("table\t{key}\t{}", table.columns.len()));
for (idx, column) in table.columns.iter().enumerate() {
lines.push(format!(
"column\t{key}\t{idx}\t{}\t{}\t{}\t{}",
escape_component(&column.name),
escape_component(&column.sql_type),
column.nullable,
column.primary_key
));
}
}
for (key, enum_type) in canonical_entries(&self.enums) {
let values = enum_type
.values
.iter()
.map(|value| escape_component(value))
.collect::<Vec<_>>()
.join("|");
lines.push(format!("enum\t{}\t{values}", escape_component(&key)));
}
for (key, composite) in canonical_entries(&self.composites) {
let fields = composite
.fields
.iter()
.map(|field| {
format!(
"{}:{}",
escape_component(&field.name),
escape_component(&field.sql_type)
)
})
.collect::<Vec<_>>()
.join("|");
lines.push(format!("composite\t{}\t{fields}", escape_component(&key)));
}
for (key, domain) in canonical_entries(&self.domains) {
lines.push(format!(
"domain\t{}\t{}\t{}",
escape_component(&key),
escape_component(&domain.base_type),
domain.not_null
));
}
lines.join("\n")
}
}
fn dialect_tag(dialect: SqlDialect) -> &'static str {
match dialect {
SqlDialect::PostgreSQL => "postgresql",
SqlDialect::MySQL => "mysql",
SqlDialect::SQLite => "sqlite",
SqlDialect::MsSql => "mssql",
SqlDialect::Oracle => "oracle",
SqlDialect::Snowflake => "snowflake",
}
}
fn canonical_entries<T>(map: &AHashMap<String, T>) -> Vec<(String, &T)> {
let stripped: Vec<(String, &T)> = map
.iter()
.map(|(key, value)| (strip_leading_qualifier(key), value))
.collect();
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::with_capacity(stripped.len());
let collides = stripped.iter().any(|(key, _)| !seen.insert(key.as_str()));
let mut entries: Vec<(String, &T)> = if collides {
map.iter().map(|(key, value)| (key.clone(), value)).collect()
} else {
stripped
};
entries.sort_by(|a, b| a.0.cmp(&b.0));
entries
}
fn strip_leading_qualifier(key: &str) -> String {
key.split_once('.')
.map_or_else(|| key.to_string(), |(_, rest)| rest.to_string())
}
fn escape_component(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'\\' => escaped.push_str("\\\\"),
'|' => escaped.push_str("\\|"),
':' => escaped.push_str("\\:"),
'\t' => escaped.push_str("\\t"),
'\n' => escaped.push_str("\\n"),
other => escaped.push(other),
}
}
escaped
}
#[cfg(test)]
mod tests {
use crate::dialect::SqlDialect;
use super::Catalog;
#[test]
fn test_reformatted_ddl_produces_same_hash() {
let a = Catalog::from_ddl(&["CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL);"])
.unwrap();
let b = Catalog::from_ddl(
&["-- orders first this time, extra whitespace and comments throughout\n\
CREATE TABLE orders (\n id INTEGER PRIMARY KEY, -- pk\n user_id INTEGER NOT NULL\n);\n\n\
/* users table */\n\
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);"],
)
.unwrap();
assert_eq!(
a.fingerprint(),
b.fingerprint(),
"whitespace, comments, and top-level statement order must not affect the fingerprint"
);
}
#[test]
fn test_nullability_change_produces_different_hash() {
let a = Catalog::from_ddl(&["CREATE TABLE t (id INTEGER, name TEXT);"]).unwrap();
let b = Catalog::from_ddl(&["CREATE TABLE t (id INTEGER, name TEXT NOT NULL);"]).unwrap();
assert_ne!(a.fingerprint(), b.fingerprint());
}
#[test]
fn test_column_reorder_produces_different_hash() {
let a = Catalog::from_ddl(&["CREATE TABLE t (a INTEGER, b TEXT);"]).unwrap();
let b = Catalog::from_ddl(&["CREATE TABLE t (b TEXT, a INTEGER);"]).unwrap();
assert_ne!(
a.fingerprint(),
b.fingerprint(),
"column order is positional and must be part of the hash"
);
}
#[test]
fn test_column_default_change_produces_same_hash() {
let a = Catalog::from_ddl(&["CREATE TABLE t (id INTEGER, age INTEGER DEFAULT 0);"]).unwrap();
let b = Catalog::from_ddl(&["CREATE TABLE t (id INTEGER, age INTEGER DEFAULT 1);"]).unwrap();
assert_eq!(
a.fingerprint(),
b.fingerprint(),
"Column.default must be excluded from the fingerprint"
);
}
#[test]
fn test_table_added_produces_different_hash() {
let a = Catalog::from_ddl(&["CREATE TABLE t (id INTEGER);"]).unwrap();
let b = Catalog::from_ddl(&["CREATE TABLE t (id INTEGER); CREATE TABLE u (id INTEGER);"]).unwrap();
assert_ne!(a.fingerprint(), b.fingerprint());
}
#[test]
fn test_enum_and_composite_participate() {
let a = Catalog::from_ddl(&["CREATE TYPE mood AS ENUM ('sad', 'happy');"]).unwrap();
let b = Catalog::from_ddl(&["CREATE TYPE mood AS ENUM ('sad', 'happy', 'ok');"]).unwrap();
assert_ne!(a.fingerprint(), b.fingerprint());
let c = Catalog::from_ddl(&["CREATE TYPE address AS (street TEXT, city TEXT);"]).unwrap();
let d = Catalog::from_ddl(&["CREATE TYPE address AS (street TEXT, city TEXT, zip INTEGER);"]).unwrap();
assert_ne!(c.fingerprint(), d.fingerprint());
}
#[test]
fn test_dialect_participates() {
let pg = Catalog::from_ddl_with_dialect(&["CREATE TABLE t (id INTEGER);"], &SqlDialect::PostgreSQL).unwrap();
let mysql = Catalog::from_ddl_with_dialect(&["CREATE TABLE t (id INT);"], &SqlDialect::MySQL).unwrap();
assert_ne!(pg.fingerprint(), mysql.fingerprint());
}
#[test]
fn test_mysql_and_mariadb_alias_share_one_dialect_variant() {
assert_eq!(SqlDialect::from_str("mysql"), SqlDialect::from_str("mariadb"));
}
#[test]
fn test_public_prefix_stripped_when_no_collision() {
let a = Catalog::from_ddl_with_dialect(&["CREATE TABLE public.users (id INTEGER);"], &SqlDialect::PostgreSQL)
.unwrap();
let b = Catalog::from_ddl_with_dialect(&["CREATE TABLE users (id INTEGER);"], &SqlDialect::PostgreSQL).unwrap();
assert_eq!(a.fingerprint(), b.fingerprint());
}
#[test]
fn test_public_schema_collision_falls_back_to_raw_keys() {
let base = Catalog::from_ddl_with_dialect(
&["CREATE TABLE public.users (id INTEGER); CREATE TABLE users (name TEXT);"],
&SqlDialect::PostgreSQL,
)
.unwrap();
let fp_base = base.fingerprint();
let changed_bare = Catalog::from_ddl_with_dialect(
&["CREATE TABLE public.users (id INTEGER); CREATE TABLE users (name TEXT, extra BOOLEAN);"],
&SqlDialect::PostgreSQL,
)
.unwrap();
assert_ne!(
fp_base,
changed_bare.fingerprint(),
"changing the bare `users` table must change the hash even though `public.users` also exists"
);
let changed_qualified = Catalog::from_ddl_with_dialect(
&["CREATE TABLE public.users (id INTEGER, extra BOOLEAN); CREATE TABLE users (name TEXT);"],
&SqlDialect::PostgreSQL,
)
.unwrap();
assert_ne!(
fp_base,
changed_qualified.fingerprint(),
"changing `public.users` must change the hash even though bare `users` also exists"
);
}
#[test]
fn test_schema_qualifier_stripped_on_non_postgresql_dialect() {
let qualified =
Catalog::from_ddl_with_dialect(&["CREATE TABLE dbo.users (id INTEGER);"], &SqlDialect::MsSql).unwrap();
let bare = Catalog::from_ddl_with_dialect(&["CREATE TABLE users (id INTEGER);"], &SqlDialect::MsSql).unwrap();
assert_eq!(
qualified.fingerprint(),
bare.fingerprint(),
"a schema-qualified table must fingerprint the same as its bare name on every dialect, not just PostgreSQL"
);
}
#[test]
fn test_non_public_schema_qualifier_stripped_under_postgresql() {
let qualified =
Catalog::from_ddl_with_dialect(&["CREATE TABLE myschema.users (id INTEGER);"], &SqlDialect::PostgreSQL)
.unwrap();
let bare =
Catalog::from_ddl_with_dialect(&["CREATE TABLE users (id INTEGER);"], &SqlDialect::PostgreSQL).unwrap();
assert_eq!(
qualified.fingerprint(),
bare.fingerprint(),
"any leading schema qualifier must be stripped under PostgreSQL, not only the literal `public.`"
);
}
#[test]
fn test_non_public_schema_collision_falls_back_to_raw_keys_on_other_dialects() {
let base = Catalog::from_ddl_with_dialect(
&["CREATE TABLE dbo.users (id INTEGER); CREATE TABLE users (name TEXT);"],
&SqlDialect::MsSql,
)
.unwrap();
let fp_base = base.fingerprint();
let changed_bare = Catalog::from_ddl_with_dialect(
&["CREATE TABLE dbo.users (id INTEGER); CREATE TABLE users (name TEXT, extra BOOLEAN);"],
&SqlDialect::MsSql,
)
.unwrap();
assert_ne!(
fp_base,
changed_bare.fingerprint(),
"changing the bare `users` table must change the hash even though `dbo.users` also exists"
);
let changed_qualified = Catalog::from_ddl_with_dialect(
&["CREATE TABLE dbo.users (id INTEGER, extra BOOLEAN); CREATE TABLE users (name TEXT);"],
&SqlDialect::MsSql,
)
.unwrap();
assert_ne!(
fp_base,
changed_qualified.fingerprint(),
"changing `dbo.users` must change the hash even though bare `users` also exists"
);
}
#[test]
fn test_enum_pipe_value_does_not_collide_with_split_values() {
let one_value_with_pipe = Catalog::from_ddl(&["CREATE TYPE t AS ENUM ('a|b');"]).unwrap();
let two_values = Catalog::from_ddl(&["CREATE TYPE t AS ENUM ('a', 'b');"]).unwrap();
assert_ne!(
one_value_with_pipe.fingerprint(),
two_values.fingerprint(),
"an enum with one value containing a literal `|` must not fingerprint the same as \
a different enum with two values split at that `|`"
);
}
#[test]
fn test_composite_field_colon_does_not_collide_across_name_type_boundary() {
let colon_in_type = Catalog::from_ddl(&["CREATE TYPE addr AS (a \"b:c\");"]).unwrap();
let colon_in_name = Catalog::from_ddl(&["CREATE TYPE addr AS (\"a:b\" c);"]).unwrap();
assert_ne!(
colon_in_type.fingerprint(),
colon_in_name.fingerprint(),
"a composite field name or type containing `:` must not fingerprint the same as a \
differently-shaped field whose unescaped `name:type` rendering is byte-identical"
);
}
#[test]
fn test_enum_value_with_tab_and_newline_cannot_forge_a_composite_line() {
let smuggled_value = "z\ncomposite\taddr\tstreet:text";
let forged_ddl = format!("CREATE TYPE e AS ENUM ('{smuggled_value}');");
let forged = Catalog::from_ddl(&[forged_ddl.as_str()]).unwrap();
let genuine =
Catalog::from_ddl(&["CREATE TYPE e AS ENUM ('z');", "CREATE TYPE addr AS (street TEXT);"]).unwrap();
assert_ne!(
forged.fingerprint(),
genuine.fingerprint(),
"a tab/newline-bearing enum value must not be able to forge what looks like an \
unrelated composite's canonical-form line"
);
}
#[test]
fn test_domain_base_type_change_produces_different_hash() {
let a = Catalog::from_ddl(&["CREATE DOMAIN email AS TEXT;"]).unwrap();
let b = Catalog::from_ddl(&["CREATE DOMAIN email AS VARCHAR(255);"]).unwrap();
assert_ne!(
a.fingerprint(),
b.fingerprint(),
"a domain's base type must participate in the fingerprint even when no table column resolves through it"
);
}
#[test]
fn test_fingerprints_are_pinned_to_their_released_values() {
let cases: &[(&str, SqlDialect, &str)] = &[
(
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, bio TEXT);",
SqlDialect::PostgreSQL,
"sch1:4bf6bb703d5818da",
),
(
"CREATE TYPE status AS ENUM ('active', 'inactive', 'banned');",
SqlDialect::PostgreSQL,
"sch1:b23bd2728dc1df1c",
),
(
"CREATE TYPE address AS (street TEXT, city TEXT, zip INTEGER);",
SqlDialect::PostgreSQL,
"sch1:08277bec474dde8b",
),
(
"CREATE TABLE public.users (id INTEGER PRIMARY KEY);",
SqlDialect::PostgreSQL,
"sch1:83901ff72944cf53",
),
(
"CREATE TABLE t (id INT NOT NULL, note VARCHAR(255));",
SqlDialect::MySQL,
"sch1:d1b6623bd34edc4b",
),
(
"CREATE TABLE t (id INTEGER NOT NULL, note TEXT);",
SqlDialect::SQLite,
"sch1:4eb52891d7937ab9",
),
];
for (ddl, dialect, expected) in cases {
let catalog = Catalog::from_ddl_with_dialect(&[ddl], dialect).unwrap_or_else(|e| panic!("{ddl}: {e}"));
assert_eq!(
catalog.fingerprint(),
*expected,
"pinned fingerprint moved for {ddl:?} -- see this test's doc comment before updating it"
);
}
}
const CHILD_FINGERPRINT_PREFIX: &str = "SCH1_CHILD_FINGERPRINT=";
const CHILD_TEST_NAME: &str = "catalog::fingerprint::tests::cross_process_child_prints_fingerprint";
#[test]
fn cross_process_child_prints_fingerprint() {
println!(
"{CHILD_FINGERPRINT_PREFIX}{}",
cross_process_sample_catalog().fingerprint()
);
}
#[test]
fn test_cross_process_ordering_independence() {
let parent_fingerprint = cross_process_sample_catalog().fingerprint();
let exe = std::env::current_exe().expect("current test binary path");
let output = std::process::Command::new(exe)
.arg("--exact")
.arg(CHILD_TEST_NAME)
.arg("--nocapture")
.output()
.expect("failed to spawn child test process");
assert!(
output.status.success(),
"child process failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let child_fingerprint = stdout
.lines()
.find_map(|line| line.strip_prefix(CHILD_FINGERPRINT_PREFIX))
.unwrap_or_else(|| {
panic!(
"child process did not print a fingerprint -- if `{CHILD_TEST_NAME}` was renamed, \
update CHILD_TEST_NAME. stdout was:\n{stdout}"
)
});
assert_eq!(
parent_fingerprint, child_fingerprint,
"fingerprint must be identical across independently seeded processes"
);
}
fn cross_process_sample_catalog() -> Catalog {
Catalog::from_ddl(&["CREATE TABLE alpha (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE bravo (id INTEGER PRIMARY KEY, alpha_id INTEGER NOT NULL);
CREATE TABLE charlie (id INTEGER PRIMARY KEY, note TEXT);
CREATE TABLE delta (id INTEGER PRIMARY KEY, amount NUMERIC(10,2));
CREATE TABLE echo (id INTEGER PRIMARY KEY, active BOOLEAN NOT NULL);
CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy');
CREATE TYPE status AS ENUM ('pending', 'done');
CREATE TYPE address AS (street TEXT, city TEXT, zip INTEGER);
CREATE TYPE point AS (x INTEGER, y INTEGER);"])
.unwrap()
}
}