use ahash::AHashMap;
use sha2::{Digest, Sha256};
use crate::dialect::SqlDialect;
use super::{Catalog, CatalogObjectName, GeneratedColumnKind, RelationKind};
const FINGERPRINT_ALGORITHM_TAG: &str = "sch2";
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),
field.nullable
)
})
.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
));
}
for (key, functions) in canonical_entries(&self.functions) {
let mut overloads = functions.iter().map(canonical_function).collect::<Vec<_>>();
overloads.sort();
for overload in overloads {
lines.push(format!("function\t{}\t{overload}", escape_component(&key)));
}
}
self.append_inspection_metadata(&mut lines);
lines.join("\n")
}
fn append_inspection_metadata(&self, lines: &mut Vec<String>) {
append_relation_metadata(self, lines);
append_preserved_names(lines, "relation", &self.relation_names);
append_preserved_names(lines, "enum", &self.enum_names);
append_preserved_names(lines, "composite", &self.composite_names);
append_preserved_names(lines, "domain", &self.domain_names);
append_raw_domain_types(self, lines);
}
}
fn canonical_function(function: &super::Function) -> String {
use super::{FunctionArgumentMode, FunctionReturn};
let arguments = function
.arguments
.iter()
.map(|argument| {
let mode = match argument.mode {
FunctionArgumentMode::In => "in",
FunctionArgumentMode::Out => "out",
FunctionArgumentMode::InOut => "inout",
FunctionArgumentMode::Variadic => "variadic",
};
format!(
"{}:{}:{}:{}",
escape_component(argument.name.as_deref().unwrap_or("")),
escape_component(&argument.sql_type),
mode,
argument.has_default
)
})
.collect::<Vec<_>>()
.join("|");
let return_type = match &function.return_type {
FunctionReturn::Scalar { sql_type } => format!("scalar:{}", escape_component(sql_type)),
FunctionReturn::SetOf { sql_type } => format!("setof:{}", escape_component(sql_type)),
FunctionReturn::Table { columns } => format!(
"table:{}",
columns
.iter()
.map(|column| format!(
"{}:{}:{}",
escape_component(&column.name),
escape_component(&column.sql_type),
column.nullable
))
.collect::<Vec<_>>()
.join("|")
),
};
format!("{}:{arguments}:{return_type}", function.arguments.len())
}
fn append_relation_metadata(catalog: &Catalog, lines: &mut Vec<String>) {
for (key, kind) in sorted_entries(&catalog.relation_kinds) {
if *kind == RelationKind::View {
lines.push(format!("relation-kind\t{}\tview", escape_component(key)));
}
}
for (relation_key, raw_types) in sorted_entries(&catalog.raw_column_types) {
for (column_key, raw_type) in sorted_entries(raw_types) {
let normalized_type = catalog
.tables
.get(relation_key)
.and_then(|table| {
table
.columns
.iter()
.find(|column| column.name.trim().to_lowercase() == *column_key)
})
.map(|column| column.sql_type.as_str());
if normalized_type.is_none_or(|resolved| !equivalent_sql_type(raw_type, resolved)) {
lines.push(format!(
"raw-column-type\t{}\t{}\t{}",
escape_component(relation_key),
escape_component(column_key),
escape_component(raw_type)
));
}
}
}
for (relation_key, generated_kinds) in sorted_entries(&catalog.generated_column_kinds) {
for (column_key, kind) in sorted_entries(generated_kinds) {
lines.push(format!(
"generated-column\t{}\t{}\t{}",
escape_component(relation_key),
escape_component(column_key),
generated_column_kind_tag(*kind)
));
}
}
}
fn append_preserved_names(lines: &mut Vec<String>, object_kind: &str, names: &AHashMap<String, CatalogObjectName>) {
for (key, name) in sorted_entries(names) {
if name.schema().is_none() && name.name() == key {
continue;
}
lines.push(format!(
"object-name\t{}\t{}\t{}\t{}",
object_kind,
escape_component(key),
escape_component(name.schema().unwrap_or("")),
escape_component(name.name())
));
}
}
fn append_raw_domain_types(catalog: &Catalog, lines: &mut Vec<String>) {
for (key, raw_type) in sorted_entries(&catalog.raw_domain_types) {
let normalized_type = catalog.domains.get(key).map(|domain| domain.base_type.as_str());
if normalized_type.is_none_or(|resolved| !equivalent_sql_type(raw_type, resolved)) {
lines.push(format!(
"raw-domain-type\t{}\t{}",
escape_component(key),
escape_component(raw_type)
));
}
}
}
fn sorted_entries<T>(map: &AHashMap<String, T>) -> Vec<(&str, &T)> {
let mut entries: Vec<(&str, &T)> = map.iter().map(|(key, value)| (key.as_str(), value)).collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
entries
}
fn equivalent_sql_type(raw: &str, resolved: &str) -> bool {
raw.trim().to_lowercase() == resolved
}
fn generated_column_kind_tag(kind: GeneratedColumnKind) -> &'static str {
match kind {
GeneratedColumnKind::Virtual => "virtual",
GeneratedColumnKind::Stored => "stored",
}
}
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::catalog::{
CatalogBuilder, CatalogObjectName, ColumnDefinition, CompositeDefinition, CompositeFieldDefinition,
DomainDefinition, EnumDefinition, GeneratedColumnKind, RelationDefinition,
};
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_inspected_column_default_change_produces_same_hash() {
let build = |default: &str| {
CatalogBuilder::new(SqlDialect::SQLite)
.relation(RelationDefinition::table(
CatalogObjectName::new("items"),
vec![ColumnDefinition::new("quantity", "INTEGER", false).default(default)],
))
.build()
.expect("valid inspected catalog")
};
assert_eq!(
build("0").fingerprint(),
build("1").fingerprint(),
"inspected Column.default must remain excluded from the fingerprint"
);
}
#[test]
fn test_inspected_table_and_view_produce_different_hashes() {
let table = CatalogBuilder::new(SqlDialect::SQLite)
.relation(RelationDefinition::table(
CatalogObjectName::new("items"),
vec![ColumnDefinition::new("id", "INTEGER", false)],
))
.build()
.expect("valid inspected table");
let view = CatalogBuilder::new(SqlDialect::SQLite)
.relation(RelationDefinition::view(
CatalogObjectName::new("items"),
vec![ColumnDefinition::new("id", "INTEGER", false)],
))
.build()
.expect("valid inspected view");
assert_ne!(table.fingerprint(), view.fingerprint());
}
#[test]
fn test_inspected_virtual_and_stored_columns_produce_different_hashes() {
let build = |kind| {
CatalogBuilder::new(SqlDialect::SQLite)
.relation(RelationDefinition::table(
CatalogObjectName::new("items"),
vec![ColumnDefinition::new("computed", "INTEGER", false).generated(kind)],
))
.build()
.expect("valid inspected catalog")
};
assert_ne!(
build(GeneratedColumnKind::Virtual).fingerprint(),
build(GeneratedColumnKind::Stored).fingerprint()
);
}
#[test]
fn test_materially_distinct_raw_column_type_changes_hash() {
let build = |raw_type: &str| {
CatalogBuilder::new(SqlDialect::PostgreSQL)
.relation(RelationDefinition::table(
CatalogObjectName::new("items"),
vec![ColumnDefinition::new("id", raw_type, false).resolved_sql_type("integer")],
))
.build()
.expect("valid inspected catalog")
};
assert_ne!(build("int4").fingerprint(), build("serial4").fingerprint());
}
#[test]
fn test_materially_distinct_raw_domain_type_changes_hash() {
let build = || {
CatalogBuilder::new(SqlDialect::PostgreSQL)
.domain(DomainDefinition::new(
CatalogObjectName::new("identifier"),
"bigint",
false,
))
.build()
.expect("valid inspected catalog")
};
let mut int8 = build();
let mut serial8 = build();
int8.raw_domain_types
.insert("identifier".to_string(), "int8".to_string());
serial8
.raw_domain_types
.insert("identifier".to_string(), "serial8".to_string());
assert_ne!(int8.fingerprint(), serial8.fingerprint());
}
#[test]
fn test_preserved_qualified_and_caseful_names_change_hash() {
let bare = CatalogBuilder::new(SqlDialect::PostgreSQL)
.relation(RelationDefinition::table(
CatalogObjectName::new("items"),
vec![ColumnDefinition::new("id", "integer", false)],
))
.enum_type(EnumDefinition::new(
CatalogObjectName::new("mood"),
vec!["happy".to_string()],
))
.composite(CompositeDefinition::new(CatalogObjectName::new("address"), vec![]))
.domain(DomainDefinition::new(
CatalogObjectName::new("identifier"),
"bigint",
false,
))
.build()
.expect("valid normalized catalog");
let preserved = CatalogBuilder::new(SqlDialect::PostgreSQL)
.relation(RelationDefinition::table(
CatalogObjectName::qualified("Public", "Items"),
vec![ColumnDefinition::new("id", "integer", false)],
))
.enum_type(EnumDefinition::new(
CatalogObjectName::new("Mood"),
vec!["happy".to_string()],
))
.composite(CompositeDefinition::new(CatalogObjectName::new("Address"), vec![]))
.domain(DomainDefinition::new(
CatalogObjectName::new("Identifier"),
"bigint",
false,
))
.build()
.expect("valid preserved-name catalog");
assert_ne!(bare.fingerprint(), preserved.fingerprint());
}
#[test]
fn test_default_inspection_metadata_remains_parser_compatible() {
let parsed =
Catalog::from_ddl_with_dialect(&["CREATE TABLE items (id INTEGER NOT NULL);"], &SqlDialect::SQLite)
.expect("valid DDL catalog");
let inspected = CatalogBuilder::new(SqlDialect::SQLite)
.relation(RelationDefinition::table(
CatalogObjectName::new("items"),
vec![ColumnDefinition::new("id", "INTEGER", false)],
))
.build()
.expect("valid inspected catalog");
assert_eq!(parsed.fingerprint(), inspected.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_composite_field_nullability_participates() {
let build = |nullable| {
CatalogBuilder::new(SqlDialect::PostgreSQL)
.composite(CompositeDefinition::new(
CatalogObjectName::new("address"),
vec![CompositeFieldDefinition::new("zip", "text", nullable)],
))
.build()
.expect("valid catalog")
};
assert_ne!(build(false).fingerprint(), build(true).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,
"sch2:4bf6bb703d5818da",
),
(
"CREATE TYPE status AS ENUM ('active', 'inactive', 'banned');",
SqlDialect::PostgreSQL,
"sch2:b23bd2728dc1df1c",
),
(
"CREATE TYPE address AS (street TEXT, city TEXT, zip INTEGER);",
SqlDialect::PostgreSQL,
"sch2:d6d144db11d41ec2",
),
(
"CREATE TABLE public.users (id INTEGER PRIMARY KEY);",
SqlDialect::PostgreSQL,
"sch2:83901ff72944cf53",
),
(
"CREATE TABLE t (id INT NOT NULL, note VARCHAR(255));",
SqlDialect::MySQL,
"sch2:d1b6623bd34edc4b",
),
(
"CREATE TABLE t (id INTEGER NOT NULL, note TEXT);",
SqlDialect::SQLite,
"sch2: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 = "SCH2_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()
}
}