use sha2::{Digest, Sha256};
use super::types::{AnalyzedColumn, AnalyzedParam, AnalyzedQuery, NestedFieldInfo, NestedStructInfo};
const QUERY_FINGERPRINT_ALGORITHM_TAG: &str = "q1";
const TRUNCATED_BYTES: usize = 8;
impl AnalyzedQuery {
#[must_use]
pub fn fingerprint_set<'a, I>(queries: I) -> String
where
I: IntoIterator<Item = &'a AnalyzedQuery>,
{
let canonical = canonical_form(queries);
let digest = Sha256::digest(canonical.as_bytes());
let hex: String = digest[..TRUNCATED_BYTES].iter().map(|b| format!("{b:02x}")).collect();
format!("{QUERY_FINGERPRINT_ALGORITHM_TAG}:{hex}")
}
}
fn canonical_form<'a, I>(queries: I) -> String
where
I: IntoIterator<Item = &'a AnalyzedQuery>,
{
let mut sorted: Vec<&AnalyzedQuery> = queries.into_iter().collect();
sorted.sort_by(|a, b| a.name.cmp(&b.name));
let mut lines: Vec<String> = Vec::new();
for query in sorted {
let name = escape_field(&query.name);
lines.push(format!(
"query\t{name}\t{}\t{}\t{}",
query.command,
query.params.len(),
query.columns.len()
));
for (idx, param) in query.params.iter().enumerate() {
lines.push(param_line(&name, idx, param));
}
for (idx, column) in query.columns.iter().enumerate() {
lines.push(column_line(&name, idx, column));
}
for (idx, nested) in query.nested_structs.iter().enumerate() {
lines.push(nested_struct_line(&name, idx, nested));
for (field_idx, field) in nested.fields.iter().enumerate() {
lines.push(nested_field_line(&name, idx, field_idx, field));
}
}
}
lines.join("\n")
}
fn param_line(escaped_query_name: &str, idx: usize, param: &AnalyzedParam) -> String {
format!(
"param\t{escaped_query_name}\t{idx}\t{}\t{}",
escape_field(¶m.name),
escape_field(¶m.neutral_type)
)
}
fn column_line(escaped_query_name: &str, idx: usize, column: &AnalyzedColumn) -> String {
format!(
"column\t{escaped_query_name}\t{idx}\t{}\t{}\t{}",
escape_field(&column.name),
escape_field(&column.neutral_type),
column.nullable
)
}
fn nested_struct_line(escaped_query_name: &str, idx: usize, nested: &NestedStructInfo) -> String {
format!(
"nested\t{escaped_query_name}\t{idx}\t{}\t{}",
escape_field(&nested.name),
nested.fields.len()
)
}
fn nested_field_line(escaped_query_name: &str, struct_idx: usize, field_idx: usize, field: &NestedFieldInfo) -> String {
format!(
"nfield\t{escaped_query_name}\t{struct_idx}\t{field_idx}\t{}\t{}\t{}",
escape_field(&field.name),
escape_field(&field.neutral_type),
field.nullable
)
}
fn escape_field(value: &str) -> String {
if !value.contains(['\\', '\t', '\n', '\r']) {
return value.to_string();
}
let mut escaped = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'\\' => escaped.push_str("\\\\"),
'\t' => escaped.push_str("\\t"),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
other => escaped.push(other),
}
}
escaped
}
#[cfg(test)]
mod tests {
use crate::analyzer::analyze;
use crate::catalog::Catalog;
use crate::parser::parse_query;
use super::AnalyzedQuery;
fn make_catalog() -> Catalog {
Catalog::from_ddl(&["CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email VARCHAR(255) NOT NULL,
age INTEGER
);"])
.unwrap()
}
fn analyzed(sql: &str) -> AnalyzedQuery {
let catalog = make_catalog();
let query = parse_query(sql).unwrap();
analyze(&catalog, &query).unwrap()
}
#[test]
fn test_reformatted_and_recommented_query_produces_same_fingerprint() {
let a = analyzed("-- @name GetUser\n-- @returns :one\nSELECT id, name, email FROM users WHERE id = $1;");
let b = analyzed(
"-- @name GetUser\n-- @returns :one\n-- fetches a single user by id, added a comment here\nSELECT id,\n name,\n \
email\nFROM users\nWHERE id = $1; -- trailing comment\n",
);
assert_eq!(
AnalyzedQuery::fingerprint_set([&a]),
AnalyzedQuery::fingerprint_set([&b]),
"whitespace and comments must not affect the query fingerprint"
);
}
#[test]
fn test_query_name_change_produces_different_fingerprint() {
let a = analyzed("-- @name GetUser\n-- @returns :one\nSELECT id FROM users WHERE id = $1;");
let b = analyzed("-- @name FetchUser\n-- @returns :one\nSELECT id FROM users WHERE id = $1;");
assert_ne!(
AnalyzedQuery::fingerprint_set([&a]),
AnalyzedQuery::fingerprint_set([&b])
);
}
#[test]
fn test_return_kind_change_produces_different_fingerprint() {
let a = analyzed("-- @name ListUsers\n-- @returns :many\nSELECT id FROM users;");
let b = analyzed("-- @name ListUsers\n-- @returns :one\nSELECT id FROM users;");
assert_ne!(
AnalyzedQuery::fingerprint_set([&a]),
AnalyzedQuery::fingerprint_set([&b]),
"return kind (:one vs :many) must participate"
);
}
#[test]
fn test_param_type_change_produces_different_fingerprint() {
let a = analyzed("-- @name GetUser\n-- @returns :one\nSELECT id FROM users WHERE id = $1;");
let b = analyzed("-- @name GetUser\n-- @returns :one\nSELECT id FROM users WHERE email = $1;");
assert_ne!(
AnalyzedQuery::fingerprint_set([&a]),
AnalyzedQuery::fingerprint_set([&b]),
"a parameter's resolved type must participate"
);
}
#[test]
fn test_param_name_change_at_same_type_produces_different_fingerprint() {
let a = analyzed("-- @name FindUser\n-- @returns :one\nSELECT id FROM users WHERE name = $1;");
let b = analyzed("-- @name FindUser\n-- @returns :one\nSELECT id FROM users WHERE email = $1;");
assert_eq!(
a.params[0].neutral_type, b.params[0].neutral_type,
"guard: this test is only meaningful while both params share a type"
);
assert_ne!(
a.params[0].name, b.params[0].name,
"guard: the names must actually differ"
);
assert_ne!(
AnalyzedQuery::fingerprint_set([&a]),
AnalyzedQuery::fingerprint_set([&b]),
"a parameter's name must participate -- it becomes the generated argument name"
);
}
#[test]
fn test_column_nullability_change_produces_different_fingerprint() {
let make = |nullable: bool| {
AnalyzedQuery::build(|q| {
q.name = "GetUser".to_string();
q.command = crate::parser::QueryCommand::One;
q.columns = vec![crate::analyzer::AnalyzedColumn {
name: "age".to_string(),
neutral_type: "int".to_string(),
nullable,
..Default::default()
}];
})
};
let not_null = make(false);
let nullable = make(true);
assert_ne!(
AnalyzedQuery::fingerprint_set([¬_null]),
AnalyzedQuery::fingerprint_set([&nullable]),
"a result column's nullability must participate"
);
}
#[test]
fn test_column_reorder_produces_different_fingerprint() {
let a = analyzed("-- @name GetUser\n-- @returns :one\nSELECT id, name FROM users WHERE id = $1;");
let b = analyzed("-- @name GetUser\n-- @returns :one\nSELECT name, id FROM users WHERE id = $1;");
assert_ne!(
AnalyzedQuery::fingerprint_set([&a]),
AnalyzedQuery::fingerprint_set([&b]),
"column order is positional and must be part of the fingerprint"
);
}
#[test]
fn test_query_added_produces_different_fingerprint() {
let a = analyzed("-- @name GetUser\n-- @returns :one\nSELECT id FROM users WHERE id = $1;");
let b = analyzed("-- @name ListUsers\n-- @returns :many\nSELECT id FROM users;");
let one = AnalyzedQuery::fingerprint_set([&a]);
let two = AnalyzedQuery::fingerprint_set([&a, &b]);
assert_ne!(one, two);
}
#[test]
fn test_fingerprint_is_independent_of_input_order() {
let a = analyzed("-- @name GetUser\n-- @returns :one\nSELECT id FROM users WHERE id = $1;");
let b = analyzed("-- @name ListUsers\n-- @returns :many\nSELECT id FROM users;");
assert_eq!(
AnalyzedQuery::fingerprint_set([&a, &b]),
AnalyzedQuery::fingerprint_set([&b, &a]),
"queries are sorted by name before hashing, so parse/collection order must not matter"
);
}
#[test]
fn test_escape_field_prevents_delimiter_forgery() {
let forged = AnalyzedQuery::build(|q| {
q.name = "Evil\nquery\tGetUser\tone\t1\t1\nparam\tGetUser\t0\tstring".to_string();
q.command = crate::parser::QueryCommand::One;
});
let real_one = AnalyzedQuery::build(|q| {
q.name = "Evil".to_string();
q.command = crate::parser::QueryCommand::One;
});
assert_ne!(
AnalyzedQuery::fingerprint_set([&forged]),
AnalyzedQuery::fingerprint_set([&real_one]),
"a name containing raw delimiters must not be able to forge extra fingerprint lines"
);
}
#[test]
fn test_nested_aggregate_over_a_different_table_produces_different_fingerprint() {
let catalog = Catalog::from_ddl(&[
"CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT);",
"CREATE TABLE posts (id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL, body TEXT);",
])
.unwrap();
let analyze_with = |sql: &str| {
let query = parse_query(sql).unwrap();
analyze(&catalog, &query).unwrap()
};
let over_posts = analyze_with("-- @name Q\n-- @returns :many\nSELECT json_agg(p.*) AS x FROM posts p;");
let over_users = analyze_with("-- @name Q\n-- @returns :many\nSELECT json_agg(u.*) AS x FROM users u;");
assert_eq!(
over_posts.columns[0].neutral_type, over_users.columns[0].neutral_type,
"guard: this test is only meaningful while the owning column's neutral type is identical -- \
that identity is what made the two fingerprints collide"
);
assert_ne!(
over_posts.nested_structs[0].fields, over_users.nested_structs[0].fields,
"guard: the two nested row shapes must actually differ"
);
assert_ne!(
AnalyzedQuery::fingerprint_set([&over_posts]),
AnalyzedQuery::fingerprint_set([&over_users]),
"a nested-aggregate row shape must participate: the generated row type changed completely"
);
}
#[test]
fn test_each_nested_field_attribute_participates() {
let with_fields = |fields: Vec<crate::analyzer::NestedFieldInfo>| {
AnalyzedQuery::build(|q| {
q.name = "Q".to_string();
q.command = crate::parser::QueryCommand::Many;
q.columns = vec![crate::analyzer::AnalyzedColumn {
name: "x".to_string(),
neutral_type: "json_nested<array<QRowX>>".to_string(),
nullable: true,
..Default::default()
}];
q.nested_structs = vec![crate::analyzer::NestedStructInfo {
name: "q_row_x".to_string(),
fields,
}];
})
};
let field = |name: &str, neutral_type: &str, nullable: bool| crate::analyzer::NestedFieldInfo {
name: name.to_string(),
neutral_type: neutral_type.to_string(),
nullable,
};
let baseline = with_fields(vec![field("id", "int32", false)]);
let renamed = with_fields(vec![field("ident", "int32", false)]);
let retyped = with_fields(vec![field("id", "int64", false)]);
let arrayed = with_fields(vec![field("id", "array<int32>", false)]);
let nulled = with_fields(vec![field("id", "int32", true)]);
let extra = with_fields(vec![field("id", "int32", false), field("title", "string", false)]);
let baseline_fingerprint = AnalyzedQuery::fingerprint_set([&baseline]);
for (label, other) in [
("a renamed field", &renamed),
("a retyped field", &retyped),
("a field wrapped in an array", &arrayed),
("a field whose nullability flipped", &nulled),
("an added field", &extra),
] {
assert_ne!(
baseline_fingerprint,
AnalyzedQuery::fingerprint_set([other]),
"{label} must change the fingerprint"
);
}
}
#[test]
fn test_query_without_nested_structs_keeps_its_pre_existing_fingerprint() {
let query = analyzed("-- @name GetUser\n-- @returns :one\nSELECT id, name FROM users WHERE id = $1;");
assert!(query.nested_structs.is_empty(), "guard: no nested structs here");
assert_eq!(AnalyzedQuery::fingerprint_set([&query]), "q1:b3ea4a697b34db86");
}
#[test]
fn test_empty_query_set_is_deterministic() {
assert_eq!(
AnalyzedQuery::fingerprint_set(std::iter::empty()),
AnalyzedQuery::fingerprint_set(std::iter::empty())
);
}
}