use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct NormalizedBody {
canonical_text: String,
canonical_hash: [u8; 32],
}
#[derive(Debug, thiserror::Error)]
pub enum BodyError {
#[error("pg_query rejected body: {0}")]
Parse(String),
}
impl NormalizedBody {
pub const fn empty() -> Self {
Self {
canonical_text: String::new(),
canonical_hash: [0u8; 32],
}
}
pub fn from_raw_canonical(canonical_text: String) -> Self {
let canonical_hash = hash_canonical(&canonical_text);
Self {
canonical_text,
canonical_hash,
}
}
pub fn from_sql(sql: &str) -> Result<Self, BodyError> {
let parsed = pg_query::parse(sql).map_err(|e| BodyError::Parse(e.to_string()))?;
let mut protobuf = parsed.protobuf;
strip_redundant_qualifiers(&mut protobuf);
let deparsed = pg_query::deparse(&protobuf).unwrap_or_default();
let source = if deparsed.is_empty() { sql } else { &deparsed };
let canonical_text = collapse_whitespace(source);
let canonical_hash = hash_canonical(&canonical_text);
Ok(Self {
canonical_text,
canonical_hash,
})
}
pub fn canonical_text(&self) -> &str {
&self.canonical_text
}
#[allow(clippy::missing_const_for_fn)]
pub fn canonical_hash(&self) -> &[u8; 32] {
&self.canonical_hash
}
}
fn strip_redundant_qualifiers(root: &mut pg_query::protobuf::ParseResult) {
use pg_query::NodeEnum;
for stmt in &mut root.stmts {
let Some(node) = stmt.stmt.as_mut().and_then(|n| n.node.as_mut()) else {
continue;
};
if let NodeEnum::SelectStmt(sel) = node {
strip_qualifiers_in_select(sel);
}
}
}
fn strip_qualifiers_in_select(sel: &mut pg_query::protobuf::SelectStmt) {
if let Some(larg) = sel.larg.as_mut() {
strip_qualifiers_in_select(larg);
}
if let Some(rarg) = sel.rarg.as_mut() {
strip_qualifiers_in_select(rarg);
}
if let Some(with) = sel.with_clause.as_mut() {
for cte in &mut with.ctes {
recurse_cte(cte);
}
}
for n in &mut sel.from_clause {
recurse_from_node(n);
}
for n in &mut sel.target_list {
recurse_subselects_in_node(n);
}
if let Some(w) = sel.where_clause.as_mut() {
recurse_subselects_in_node(w);
}
if let Some(h) = sel.having_clause.as_mut() {
recurse_subselects_in_node(h);
}
let from_names = collect_from_qualifiers(&sel.from_clause);
let Some(unique_name) = unique_from_qualifier(&from_names) else {
return;
};
for n in &mut sel.target_list {
strip_qualifier_in_node(n, &unique_name);
}
if let Some(w) = sel.where_clause.as_mut() {
strip_qualifier_in_node(w, &unique_name);
}
for n in &mut sel.group_clause {
strip_qualifier_in_node(n, &unique_name);
}
if let Some(h) = sel.having_clause.as_mut() {
strip_qualifier_in_node(h, &unique_name);
}
for n in &mut sel.sort_clause {
strip_qualifier_in_node(n, &unique_name);
}
}
fn recurse_select_node(node: &mut pg_query::protobuf::Node) {
use pg_query::NodeEnum;
if let Some(NodeEnum::SelectStmt(sel)) = node.node.as_mut() {
strip_qualifiers_in_select(sel);
}
}
fn recurse_cte(cte: &mut pg_query::protobuf::Node) {
use pg_query::NodeEnum;
if let Some(NodeEnum::CommonTableExpr(c)) = cte.node.as_mut()
&& let Some(q) = c.ctequery.as_mut()
{
recurse_select_node(q);
}
}
fn recurse_from_node(n: &mut pg_query::protobuf::Node) {
use pg_query::NodeEnum;
match n.node.as_mut() {
Some(NodeEnum::RangeSubselect(rs)) => {
if let Some(q) = rs.subquery.as_mut() {
recurse_select_node(q);
}
}
Some(NodeEnum::JoinExpr(je)) => {
if let Some(l) = je.larg.as_mut() {
recurse_from_node(l);
}
if let Some(r) = je.rarg.as_mut() {
recurse_from_node(r);
}
}
_ => {}
}
}
fn recurse_subselects_in_node(n: &mut pg_query::protobuf::Node) {
use pg_query::NodeEnum;
let Some(node) = n.node.as_mut() else { return };
match node {
NodeEnum::SubLink(sl) => {
if let Some(t) = sl.testexpr.as_mut() {
recurse_subselects_in_node(t);
}
if let Some(s) = sl.subselect.as_mut() {
recurse_select_node(s);
}
}
NodeEnum::ResTarget(rt) => {
if let Some(v) = rt.val.as_mut() {
recurse_subselects_in_node(v);
}
}
NodeEnum::AExpr(e) => {
if let Some(l) = e.lexpr.as_mut() {
recurse_subselects_in_node(l);
}
if let Some(r) = e.rexpr.as_mut() {
recurse_subselects_in_node(r);
}
}
NodeEnum::BoolExpr(e) => {
for a in &mut e.args {
recurse_subselects_in_node(a);
}
}
NodeEnum::FuncCall(fc) => {
for a in &mut fc.args {
recurse_subselects_in_node(a);
}
if let Some(filt) = fc.agg_filter.as_mut() {
recurse_subselects_in_node(filt);
}
for a in &mut fc.agg_order {
recurse_subselects_in_node(a);
}
}
NodeEnum::CoalesceExpr(c) => {
for a in &mut c.args {
recurse_subselects_in_node(a);
}
}
NodeEnum::CaseExpr(c) => {
if let Some(arg) = c.arg.as_mut() {
recurse_subselects_in_node(arg);
}
for w in &mut c.args {
recurse_subselects_in_node(w);
}
if let Some(d) = c.defresult.as_mut() {
recurse_subselects_in_node(d);
}
}
NodeEnum::CaseWhen(w) => {
if let Some(e) = w.expr.as_mut() {
recurse_subselects_in_node(e);
}
if let Some(r) = w.result.as_mut() {
recurse_subselects_in_node(r);
}
}
NodeEnum::TypeCast(tc) => {
if let Some(arg) = tc.arg.as_mut() {
recurse_subselects_in_node(arg);
}
}
NodeEnum::SortBy(sb) => {
if let Some(arg) = sb.node.as_mut() {
recurse_subselects_in_node(arg);
}
}
NodeEnum::List(l) => {
for item in &mut l.items {
recurse_subselects_in_node(item);
}
}
NodeEnum::NullTest(nt) => {
if let Some(arg) = nt.arg.as_mut() {
recurse_subselects_in_node(arg);
}
}
NodeEnum::BooleanTest(bt) => {
if let Some(arg) = bt.arg.as_mut() {
recurse_subselects_in_node(arg);
}
}
_ => {}
}
}
fn collect_from_qualifiers(from: &[pg_query::protobuf::Node]) -> Vec<String> {
use pg_query::NodeEnum;
let mut names = Vec::new();
for n in from {
let Some(node) = n.node.as_ref() else {
continue;
};
if let NodeEnum::RangeVar(rv) = node {
let qual = rv
.alias
.as_ref()
.map_or_else(|| rv.relname.clone(), |a| a.aliasname.clone());
if !qual.is_empty() {
names.push(qual);
}
}
}
names
}
fn unique_from_qualifier(names: &[String]) -> Option<String> {
if names.len() == 1 {
Some(names[0].clone())
} else {
None
}
}
fn strip_qualifier_in_node(n: &mut pg_query::protobuf::Node, qualifier: &str) {
use pg_query::NodeEnum;
let Some(node) = n.node.as_mut() else { return };
match node {
NodeEnum::ColumnRef(cref) => {
if cref.fields.len() == 2
&& let Some(first) = cref.fields.first()
&& let Some(NodeEnum::String(s)) = first.node.as_ref()
&& s.sval == qualifier
{
cref.fields.remove(0);
}
}
NodeEnum::ResTarget(rt) => {
if let Some(v) = rt.val.as_mut() {
strip_qualifier_in_node(v, qualifier);
}
}
NodeEnum::AExpr(e) => {
if let Some(l) = e.lexpr.as_mut() {
strip_qualifier_in_node(l, qualifier);
}
if let Some(r) = e.rexpr.as_mut() {
strip_qualifier_in_node(r, qualifier);
}
}
NodeEnum::BoolExpr(e) => {
for a in &mut e.args {
strip_qualifier_in_node(a, qualifier);
}
}
NodeEnum::FuncCall(fc) => {
for a in &mut fc.args {
strip_qualifier_in_node(a, qualifier);
}
if let Some(filt) = fc.agg_filter.as_mut() {
strip_qualifier_in_node(filt, qualifier);
}
for a in &mut fc.agg_order {
strip_qualifier_in_node(a, qualifier);
}
}
NodeEnum::CoalesceExpr(c) => {
for a in &mut c.args {
strip_qualifier_in_node(a, qualifier);
}
}
NodeEnum::CaseExpr(c) => {
if let Some(arg) = c.arg.as_mut() {
strip_qualifier_in_node(arg, qualifier);
}
for w in &mut c.args {
strip_qualifier_in_node(w, qualifier);
}
if let Some(d) = c.defresult.as_mut() {
strip_qualifier_in_node(d, qualifier);
}
}
NodeEnum::CaseWhen(w) => {
if let Some(e) = w.expr.as_mut() {
strip_qualifier_in_node(e, qualifier);
}
if let Some(r) = w.result.as_mut() {
strip_qualifier_in_node(r, qualifier);
}
}
NodeEnum::TypeCast(tc) => {
if let Some(arg) = tc.arg.as_mut() {
strip_qualifier_in_node(arg, qualifier);
}
}
NodeEnum::SortBy(sb) => {
if let Some(arg) = sb.node.as_mut() {
strip_qualifier_in_node(arg, qualifier);
}
}
NodeEnum::List(l) => {
for item in &mut l.items {
strip_qualifier_in_node(item, qualifier);
}
}
NodeEnum::SubLink(sl) => {
if let Some(t) = sl.testexpr.as_mut() {
strip_qualifier_in_node(t, qualifier);
}
}
NodeEnum::NullTest(nt) => {
if let Some(arg) = nt.arg.as_mut() {
strip_qualifier_in_node(arg, qualifier);
}
}
NodeEnum::BooleanTest(bt) => {
if let Some(arg) = bt.arg.as_mut() {
strip_qualifier_in_node(arg, qualifier);
}
}
_ => {}
}
}
fn collapse_whitespace(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn hash_canonical(text: &str) -> [u8; 32] {
let mut h = blake3::Hasher::new();
h.update(b"pgevolve-normalized-body-v1\n");
h.update(text.as_bytes());
*h.finalize().as_bytes()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_qualifiers_equates_pg14_and_source_form() {
let pg14 = "SELECT products.category, count(*) AS cnt, avg(products.price) AS avg_price FROM app.products GROUP BY products.category";
let src = "SELECT category, count(*) AS cnt, avg(price) AS avg_price FROM app.products GROUP BY category";
let a = NormalizedBody::from_sql(pg14).unwrap();
let b = NormalizedBody::from_sql(src).unwrap();
assert_eq!(
a.canonical_text(),
b.canonical_text(),
"left: {} right: {}",
a.canonical_text(),
b.canonical_text()
);
}
#[test]
fn subquery_inner_scope_strip() {
let qualified =
NormalizedBody::from_sql("SELECT s.id FROM (SELECT t.id FROM app.t) s").unwrap();
let unqualified =
NormalizedBody::from_sql("SELECT s.id FROM (SELECT id FROM app.t) s").unwrap();
assert_eq!(
qualified.canonical_text(),
unqualified.canonical_text(),
"inner t.id should strip to id: {} vs {}",
qualified.canonical_text(),
unqualified.canonical_text()
);
}
#[test]
fn cte_inner_scope_strip() {
let qualified =
NormalizedBody::from_sql("WITH c AS (SELECT u.x FROM app.u) SELECT * FROM c").unwrap();
let unqualified =
NormalizedBody::from_sql("WITH c AS (SELECT x FROM app.u) SELECT * FROM c").unwrap();
assert_eq!(
qualified.canonical_text(),
unqualified.canonical_text(),
"inner u.x should strip to x: {} vs {}",
qualified.canonical_text(),
unqualified.canonical_text()
);
}
#[test]
fn correlated_subquery_outer_qualifier_preserved() {
let body = NormalizedBody::from_sql(
"SELECT a FROM app.t WHERE EXISTS (SELECT 1 FROM app.u WHERE u.fk = t.pk)",
)
.unwrap();
assert!(
body.canonical_text().contains("t.pk"),
"correlated outer ref t.pk must be preserved, got: {}",
body.canonical_text()
);
assert!(
!body.canonical_text().contains("u.fk"),
"inner u.fk should have stripped to fk, got: {}",
body.canonical_text()
);
let variant = NormalizedBody::from_sql(
"SELECT a FROM app.t WHERE EXISTS (SELECT 1 FROM app.u WHERE u.fk = t.other)",
)
.unwrap();
assert_ne!(
body.canonical_hash(),
variant.canonical_hash(),
"distinct correlation predicates must not collapse: {} vs {}",
body.canonical_text(),
variant.canonical_text()
);
}
#[test]
fn distinct_bodies_differ() {
let a = NormalizedBody::from_sql("SELECT a FROM app.t").unwrap();
let b = NormalizedBody::from_sql("SELECT b FROM app.t").unwrap();
assert_ne!(a.canonical_hash(), b.canonical_hash());
}
#[test]
fn multi_table_top_level_from_unchanged() {
let body =
NormalizedBody::from_sql("SELECT t.a, u.b FROM app.t JOIN app.u ON t.k = u.k").unwrap();
assert!(
body.canonical_text().contains("t.a"),
"join scope must keep t.a, got: {}",
body.canonical_text()
);
assert!(
body.canonical_text().contains("u.b"),
"join scope must keep u.b, got: {}",
body.canonical_text()
);
}
}