Skip to main content

pgevolve_core/parse/
normalize_expr.rs

1//! Build [`NormalizedExpr`] values from `pg_query` AST nodes.
2//!
3//! Phase-2 scope (per `docs/superpowers/plans/phase-2-parser.md`):
4//!
5//! - Strip redundant casts to a column's own type. `42::integer` for an integer
6//!   column collapses to `42`; `'foo'::text` for a text column collapses to `'foo'`.
7//! - Lowercase reserved keywords on the canonical text emitted by `pg_query`'s deparser.
8//! - Compute the BLAKE3 hash of the canonical text.
9//!
10//! Deferred to follow-up phase-2 issues (only affect equivalence sensitivity, not
11//! correctness — equivalent inputs that exercise these may diff for now):
12//!
13//! - Paren folding (collapsing trivial nested `A_Expr` parens).
14//! - Sorting commutative operands of `+`, `*`, `AND`, `OR`.
15
16use pg_query::NodeEnum;
17use pg_query::protobuf::{self, CaseWhen, Node, ResTarget};
18
19use crate::ir::column_type::ColumnType;
20use crate::ir::default_expr::NormalizedExpr;
21use crate::parse::error::{ParseError, SourceLocation};
22
23/// Build a [`NormalizedExpr`] from a `pg_query` expression node.
24///
25/// `target_type`, when supplied, enables redundant-cast stripping: a `TypeCast`
26/// to that type is replaced by its inner expression.
27pub fn from_pg_node(
28    node: &NodeEnum,
29    target_type: Option<&ColumnType>,
30    location: &SourceLocation,
31) -> Result<NormalizedExpr, ParseError> {
32    let normalized = strip_redundant_cast(node.clone(), target_type);
33    // Strip `'literal'::text` (and text-family siblings) from anywhere in the
34    // expression tree.  PG 14–17's `pg_query_deparse` adds these casts when
35    // round-tripping CHECK constraints; PG 18 dropped them.  The cast is always
36    // redundant: a string literal's inherent type IS text.
37    let normalized = strip_redundant_string_casts(normalized);
38    let raw = deparse_expr(&normalized).map_err(|e| ParseError::Structural {
39        location: location.clone(),
40        message: format!("could not deparse expression: {e}"),
41    })?;
42    let canonical_text = lowercase_keywords(&raw);
43    Ok(NormalizedExpr::from_text(canonical_text))
44}
45
46/// If `node` is a `TypeCast` whose target type matches `target_type`, return
47/// the inner argument; otherwise return `node` unchanged.
48fn strip_redundant_cast(node: NodeEnum, target_type: Option<&ColumnType>) -> NodeEnum {
49    let Some(target) = target_type else {
50        return node;
51    };
52    let NodeEnum::TypeCast(cast) = &node else {
53        return node;
54    };
55    let Some(type_name) = cast.type_name.as_ref() else {
56        return node;
57    };
58    let Some(type_str) = render_type_name(type_name) else {
59        return node;
60    };
61    let Ok(parsed) = ColumnType::parse_from_pg_type_string(&type_str) else {
62        return node;
63    };
64    if &parsed != target {
65        return node;
66    }
67    // Strip: replace the cast with its inner argument.
68    cast.arg
69        .as_ref()
70        .and_then(|inner| inner.node.as_ref())
71        .cloned()
72        .unwrap_or(node)
73}
74
75/// Recursively walk an expression AST and strip every `'literal'::text`-family
76/// cast that is always redundant.
77///
78/// A [`NodeEnum::TypeCast`] is stripped (replaced by its inner arg) when ALL
79/// of the following hold:
80/// - the inner arg is an [`NodeEnum::AConst`] with a string value (`Sval`), AND
81/// - the cast target is a bare text-family type (`text`, `bpchar`, `varchar`,
82///   `character varying`, `character`), AND
83/// - the cast carries NO typmod (so `'x'::varchar(5)` is preserved — that is a
84///   meaningful width constraint).
85///
86/// Every other node kind is recursed into so nested casts (e.g. on `<>` operands,
87/// inside `AND`/`OR`, inside function arguments) are all reached.  Node kinds not
88/// explicitly handled are returned unchanged.
89// Long because it exhaustively matches the expression node kinds we recurse into.
90// Each arm is a structurally distinct case; extracting them would not improve clarity.
91#[allow(clippy::too_many_lines)]
92fn strip_redundant_string_casts(node: NodeEnum) -> NodeEnum {
93    match node {
94        NodeEnum::TypeCast(cast) => {
95            // Check whether this cast is the redundant string→text pattern.
96            let is_string_literal =
97                cast.arg
98                    .as_ref()
99                    .and_then(|a| a.node.as_ref())
100                    .is_some_and(|n| {
101                        matches!(
102                            n,
103                            NodeEnum::AConst(c) if matches!(
104                                c.val,
105                                Some(protobuf::a_const::Val::Sval(_))
106                            )
107                        )
108                    });
109
110            let is_text_family_no_typmod = cast
111                .type_name
112                .as_ref()
113                .and_then(|tn| render_type_name(tn).map(|s| (s, tn.typmods.is_empty())))
114                .is_some_and(|(type_str, no_typmod)| {
115                    no_typmod
116                        && matches!(
117                            type_str.as_str(),
118                            "text" | "bpchar" | "varchar" | "character varying" | "character"
119                        )
120                });
121
122            if is_string_literal && is_text_family_no_typmod {
123                // Strip: replace the cast with its inner argument unchanged
124                // (string literals are leaves — no recursion needed).
125                return cast.arg.and_then(|inner| inner.node).unwrap_or_else(|| {
126                    NodeEnum::TypeCast(Box::new(protobuf::TypeCast {
127                        arg: None,
128                        type_name: None,
129                        location: -1,
130                    }))
131                });
132            }
133
134            // Not stripping — but still recurse into the arg.
135            let arg = cast.arg.map(|boxed_node| {
136                let inner = boxed_node.node.map(strip_redundant_string_casts);
137                Box::new(Node { node: inner })
138            });
139            NodeEnum::TypeCast(Box::new(protobuf::TypeCast {
140                arg,
141                type_name: cast.type_name,
142                location: cast.location,
143            }))
144        }
145
146        NodeEnum::AExpr(mut expr) => {
147            expr.lexpr = expr.lexpr.map(|boxed| {
148                let inner = boxed.node.map(strip_redundant_string_casts);
149                Box::new(Node { node: inner })
150            });
151            expr.rexpr = expr.rexpr.map(|boxed| {
152                let inner = boxed.node.map(strip_redundant_string_casts);
153                Box::new(Node { node: inner })
154            });
155            NodeEnum::AExpr(expr)
156        }
157
158        NodeEnum::BoolExpr(mut expr) => {
159            expr.args = expr
160                .args
161                .into_iter()
162                .map(|n| Node {
163                    node: n.node.map(strip_redundant_string_casts),
164                })
165                .collect();
166            NodeEnum::BoolExpr(expr)
167        }
168
169        NodeEnum::FuncCall(mut call) => {
170            call.args = call
171                .args
172                .into_iter()
173                .map(|n| Node {
174                    node: n.node.map(strip_redundant_string_casts),
175                })
176                .collect();
177            NodeEnum::FuncCall(call)
178        }
179
180        NodeEnum::NullTest(mut nt) => {
181            nt.arg = nt.arg.map(|boxed| {
182                let inner = boxed.node.map(strip_redundant_string_casts);
183                Box::new(Node { node: inner })
184            });
185            NodeEnum::NullTest(nt)
186        }
187
188        NodeEnum::BooleanTest(mut bt) => {
189            bt.arg = bt.arg.map(|boxed| {
190                let inner = boxed.node.map(strip_redundant_string_casts);
191                Box::new(Node { node: inner })
192            });
193            NodeEnum::BooleanTest(bt)
194        }
195
196        NodeEnum::CaseExpr(mut ce) => {
197            ce.arg = ce.arg.map(|boxed| {
198                let inner = boxed.node.map(strip_redundant_string_casts);
199                Box::new(Node { node: inner })
200            });
201            ce.args = ce
202                .args
203                .into_iter()
204                .map(|n| {
205                    let node = n.node.map(|inner| {
206                        // Each element is a CaseWhen node.
207                        if let NodeEnum::CaseWhen(mut cw) = inner {
208                            cw.expr = cw.expr.map(|boxed| {
209                                let e = boxed.node.map(strip_redundant_string_casts);
210                                Box::new(Node { node: e })
211                            });
212                            cw.result = cw.result.map(|boxed| {
213                                let r = boxed.node.map(strip_redundant_string_casts);
214                                Box::new(Node { node: r })
215                            });
216                            NodeEnum::CaseWhen(Box::new(CaseWhen {
217                                xpr: cw.xpr,
218                                expr: cw.expr,
219                                result: cw.result,
220                                location: cw.location,
221                            }))
222                        } else {
223                            strip_redundant_string_casts(inner)
224                        }
225                    });
226                    Node { node }
227                })
228                .collect();
229            ce.defresult = ce.defresult.map(|boxed| {
230                let inner = boxed.node.map(strip_redundant_string_casts);
231                Box::new(Node { node: inner })
232            });
233            NodeEnum::CaseExpr(ce)
234        }
235
236        NodeEnum::CoalesceExpr(mut ce) => {
237            ce.args = ce
238                .args
239                .into_iter()
240                .map(|n| Node {
241                    node: n.node.map(strip_redundant_string_casts),
242                })
243                .collect();
244            NodeEnum::CoalesceExpr(ce)
245        }
246
247        NodeEnum::MinMaxExpr(mut mm) => {
248            mm.args = mm
249                .args
250                .into_iter()
251                .map(|n| Node {
252                    node: n.node.map(strip_redundant_string_casts),
253                })
254                .collect();
255            NodeEnum::MinMaxExpr(mm)
256        }
257
258        NodeEnum::List(mut list) => {
259            list.items = list
260                .items
261                .into_iter()
262                .map(|n| Node {
263                    node: n.node.map(strip_redundant_string_casts),
264                })
265                .collect();
266            NodeEnum::List(list)
267        }
268
269        NodeEnum::RowExpr(mut re) => {
270            re.args = re
271                .args
272                .into_iter()
273                .map(|n| Node {
274                    node: n.node.map(strip_redundant_string_casts),
275                })
276                .collect();
277            NodeEnum::RowExpr(re)
278        }
279
280        NodeEnum::AArrayExpr(mut ae) => {
281            ae.elements = ae
282                .elements
283                .into_iter()
284                .map(|n| Node {
285                    node: n.node.map(strip_redundant_string_casts),
286                })
287                .collect();
288            NodeEnum::AArrayExpr(ae)
289        }
290
291        // All other node kinds are returned unchanged — no string casts inside.
292        other => other,
293    }
294}
295
296/// Walk a `TypeName`'s `names` list and join the string fragments with dots.
297/// Returns `None` if any element is not a `String` node.
298fn render_type_name(type_name: &protobuf::TypeName) -> Option<String> {
299    let mut parts = Vec::with_capacity(type_name.names.len());
300    for n in &type_name.names {
301        let Some(NodeEnum::String(s)) = &n.node else {
302            return None;
303        };
304        parts.push(s.sval.clone());
305    }
306    if parts.is_empty() {
307        return None;
308    }
309    // pg_query stores types like `pg_catalog.int4`; we only care about the last
310    // segment for matching against [`ColumnType::parse_from_pg_type_string`],
311    // since that already understands aliases like `int4` → `Integer`.
312    Some(parts.last().cloned().unwrap_or_default())
313}
314
315/// Wrap an expression in `SELECT <expr>` and deparse, then strip the `SELECT `
316/// prefix. This is the workaround for `pg_query`'s deparse expecting a top-level
317/// statement node — there is no public `deparse_expr` entry point in v6.
318///
319/// The `protobuf::ParseResult.version` field must match `libpg_query`'s
320/// embedded `PG_VERSION_NUM`, otherwise the C deparser asserts and aborts the
321/// process. We borrow that version from a freshly-parsed `SELECT 1` rather than
322/// hard-coding it.
323fn deparse_expr(node: &NodeEnum) -> Result<String, pg_query::Error> {
324    let mut scaffold = pg_query::parse("SELECT 1")?.protobuf;
325    let raw = scaffold
326        .stmts
327        .get_mut(0)
328        .ok_or_else(|| pg_query::Error::Parse("scaffold has no stmts".into()))?;
329    let select_node = raw
330        .stmt
331        .as_mut()
332        .ok_or_else(|| pg_query::Error::Parse("scaffold stmt is None".into()))?
333        .node
334        .as_mut()
335        .ok_or_else(|| pg_query::Error::Parse("scaffold node is None".into()))?;
336    let NodeEnum::SelectStmt(select) = select_node else {
337        return Err(pg_query::Error::Parse(
338            "scaffold parse did not yield SelectStmt".into(),
339        ));
340    };
341    select.target_list = vec![Node {
342        node: Some(NodeEnum::ResTarget(Box::new(ResTarget {
343            name: String::new(),
344            indirection: vec![],
345            val: Some(Box::new(Node {
346                node: Some(node.clone()),
347            })),
348            location: -1,
349        }))),
350    }];
351    let s = pg_query::deparse(&scaffold)?;
352    Ok(s.trim_start_matches("SELECT ").to_string())
353}
354
355/// Reserved Postgres keywords that should appear lowercased in canonical text.
356/// We deliberately keep this small — `pg_query`'s deparser already emits most
357/// keywords lowercased; this list is a belt-and-suspenders pass for any node
358/// kinds where the deparser preserves the source's casing.
359const RESERVED_FUNC_KEYWORDS: &[&str] = &[
360    "AND", "OR", "NOT", "NULL", "TRUE", "FALSE", "IS", "IN", "LIKE", "BETWEEN", "CASE", "WHEN",
361    "THEN", "ELSE", "END", "CAST", "AS", "DISTINCT", "FROM", "WHERE", "ORDER", "BY", "GROUP",
362    "HAVING", "LIMIT", "OFFSET", "ASC", "DESC", "NULLS", "FIRST", "LAST", "WITH", "USING",
363    "COLLATE",
364];
365
366/// Lowercase whole-word reserved keywords in `s`. Quoted-string contents are not
367/// modified.
368fn lowercase_keywords(s: &str) -> String {
369    let mut out = String::with_capacity(s.len());
370    let mut chars = s.chars().peekable();
371    while let Some(c) = chars.next() {
372        if c == '\'' {
373            // Pass through quoted string bodies verbatim, including doubled `''`.
374            out.push(c);
375            while let Some(&n) = chars.peek() {
376                chars.next();
377                out.push(n);
378                if n == '\'' {
379                    if chars.peek() == Some(&'\'') {
380                        // SAFETY: peek() returned Some, so next() yields the same char.
381                        if let Some(escaped) = chars.next() {
382                            out.push(escaped);
383                        }
384                        continue;
385                    }
386                    break;
387                }
388            }
389            continue;
390        }
391        if c == '"' {
392            out.push(c);
393            for n in chars.by_ref() {
394                out.push(n);
395                if n == '"' {
396                    break;
397                }
398            }
399            continue;
400        }
401        if c.is_ascii_alphabetic() || c == '_' {
402            let mut word = String::from(c);
403            while let Some(&n) = chars.peek() {
404                if n.is_ascii_alphanumeric() || n == '_' {
405                    word.push(n);
406                    chars.next();
407                } else {
408                    break;
409                }
410            }
411            let upper = word.to_ascii_uppercase();
412            if RESERVED_FUNC_KEYWORDS.contains(&upper.as_str()) {
413                out.push_str(&word.to_ascii_lowercase());
414            } else {
415                out.push_str(&word);
416            }
417            continue;
418        }
419        out.push(c);
420    }
421    out
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use std::path::PathBuf;
428
429    fn loc() -> SourceLocation {
430        SourceLocation::new(PathBuf::from("test.sql"), 1, 1)
431    }
432
433    /// Parse the *value* expression of `SELECT <sql>` and return its `NodeEnum`.
434    fn parse_expr(sql: &str) -> NodeEnum {
435        let full = format!("SELECT {sql}");
436        let parsed = pg_query::parse(&full).expect("expression parses");
437        let stmt = parsed.protobuf.stmts.into_iter().next().expect("one stmt");
438        let select = stmt.stmt.expect("stmt").node.expect("node");
439        let NodeEnum::SelectStmt(s) = select else {
440            panic!("expected SelectStmt, got {select:?}");
441        };
442        let target = s
443            .target_list
444            .into_iter()
445            .next()
446            .expect("target")
447            .node
448            .expect("target node");
449        let NodeEnum::ResTarget(rt) = target else {
450            panic!("expected ResTarget");
451        };
452        rt.val
453            .expect("val")
454            .node
455            .expect("inner node of ResTarget val")
456    }
457
458    #[test]
459    fn cast_to_target_integer_strips() {
460        let node = parse_expr("42 :: integer");
461        let n = from_pg_node(&node, Some(&ColumnType::Integer), &loc()).expect("normalizes");
462        assert_eq!(n.canonical_text, "42");
463    }
464
465    #[test]
466    fn cast_to_target_text_strips() {
467        let node = parse_expr("'foo' :: text");
468        let n = from_pg_node(&node, Some(&ColumnType::Text), &loc()).expect("normalizes");
469        assert_eq!(n.canonical_text, "'foo'");
470    }
471
472    #[test]
473    fn cast_to_other_type_kept() {
474        let node = parse_expr("42 :: integer");
475        let n = from_pg_node(&node, Some(&ColumnType::Text), &loc()).expect("normalizes");
476        // The cast survives because the target type does not match.
477        assert!(
478            n.canonical_text.contains("integer") || n.canonical_text.contains("::"),
479            "got: {}",
480            n.canonical_text
481        );
482    }
483
484    #[test]
485    fn cast_with_no_target_kept() {
486        let node = parse_expr("42 :: integer");
487        let n = from_pg_node(&node, None, &loc()).expect("normalizes");
488        // Without a target type, no cast stripping happens.
489        assert!(
490            n.canonical_text.contains("integer") || n.canonical_text.contains("::"),
491            "got: {}",
492            n.canonical_text
493        );
494    }
495
496    #[test]
497    fn keywords_lowercased() {
498        // pg_query already lowercases most keywords; this asserts the canonical
499        // text has no uppercase reserved-word artifacts.
500        let node = parse_expr("LOWER('FOO')");
501        let n = from_pg_node(&node, None, &loc()).expect("normalizes");
502        assert!(
503            !n.canonical_text.contains("AS") && !n.canonical_text.contains("DISTINCT"),
504            "got: {}",
505            n.canonical_text
506        );
507    }
508
509    #[test]
510    fn lowercase_helper_preserves_quoted_strings() {
511        assert_eq!(lowercase_keywords("'AND OR'"), "'AND OR'");
512        assert_eq!(lowercase_keywords("foo 'AND' OR"), "foo 'AND' or");
513        assert_eq!(lowercase_keywords("AND OR"), "and or");
514    }
515
516    #[test]
517    fn lowercase_helper_handles_doubled_quote() {
518        assert_eq!(lowercase_keywords("'a''b'"), "'a''b'");
519    }
520
521    #[test]
522    fn lowercase_helper_preserves_quoted_identifiers() {
523        assert_eq!(lowercase_keywords("\"AND\""), "\"AND\"");
524    }
525
526    #[test]
527    fn hash_matches_text() {
528        let n = NormalizedExpr::from_text("now()");
529        let expected: [u8; 32] = blake3::hash(b"now()").into();
530        assert_eq!(n.ast_hash, expected);
531    }
532
533    #[test]
534    fn equivalent_casts_hash_equal() {
535        let a = parse_expr("42 :: integer");
536        let na = from_pg_node(&a, Some(&ColumnType::Integer), &loc()).unwrap();
537        let nb = NormalizedExpr::from_text("42");
538        assert_eq!(na.ast_hash, nb.ast_hash);
539    }
540
541    // ── issue-47: redundant string-literal →text casts ─────────────────────
542
543    /// `email <> ''::text` (nested cast on <> operand) must normalise to the
544    /// same text as `email <> ''`.  This is the primary PG 14–17 regression.
545    #[test]
546    fn nested_string_text_cast_stripped() {
547        let with_cast = parse_expr("email <> ''::text");
548        let without_cast = parse_expr("email <> ''");
549        let n_cast = from_pg_node(&with_cast, None, &loc()).expect("normalizes with cast");
550        let n_plain = from_pg_node(&without_cast, None, &loc()).expect("normalizes plain");
551        assert_eq!(
552            n_cast.canonical_text, n_plain.canonical_text,
553            "canonical texts differ: with_cast={:?}  plain={:?}",
554            n_cast.canonical_text, n_plain.canonical_text,
555        );
556    }
557
558    /// AND of two string-literal `::text` casts: both must be stripped.
559    #[test]
560    fn and_of_two_string_casts_stripped() {
561        let with_cast = parse_expr("a <> ''::text and b <> 'x'::text");
562        let without_cast = parse_expr("a <> '' and b <> 'x'");
563        let n_cast = from_pg_node(&with_cast, None, &loc()).expect("normalizes with cast");
564        let n_plain = from_pg_node(&without_cast, None, &loc()).expect("normalizes plain");
565        assert_eq!(
566            n_cast.canonical_text, n_plain.canonical_text,
567            "canonical texts differ: with_cast={:?}  plain={:?}",
568            n_cast.canonical_text, n_plain.canonical_text,
569        );
570    }
571
572    /// String cast inside a function argument must be stripped.
573    #[test]
574    fn func_arg_string_cast_stripped() {
575        let with_cast = parse_expr("lower(name) <> 'x'::text");
576        let without_cast = parse_expr("lower(name) <> 'x'");
577        let n_cast = from_pg_node(&with_cast, None, &loc()).expect("normalizes with cast");
578        let n_plain = from_pg_node(&without_cast, None, &loc()).expect("normalizes plain");
579        assert_eq!(
580            n_cast.canonical_text, n_plain.canonical_text,
581            "canonical texts differ: with_cast={:?}  plain={:?}",
582            n_cast.canonical_text, n_plain.canonical_text,
583        );
584    }
585
586    /// `'5'::int` is a meaningful coercion — must NOT be stripped.  Pinned to
587    /// `pg_query`'s exact deparse so any over-stripping fails loudly.
588    #[test]
589    fn cast_string_to_int_kept() {
590        let node = parse_expr("'5'::int");
591        let n = from_pg_node(&node, None, &loc()).expect("normalizes");
592        assert_eq!(n.canonical_text, "'5'::int");
593    }
594
595    /// `'2024-01-01'::date` is meaningful — must NOT be stripped.
596    #[test]
597    fn cast_string_to_date_kept() {
598        let node = parse_expr("'2024-01-01'::date");
599        let n = from_pg_node(&node, None, &loc()).expect("normalizes");
600        assert_eq!(n.canonical_text, "'2024-01-01'::date");
601    }
602
603    /// `'x'::varchar(5)` has a typmod — must NOT be stripped.
604    #[test]
605    fn cast_string_to_varchar_with_typmod_kept() {
606        let node = parse_expr("'x'::varchar(5)");
607        let n = from_pg_node(&node, None, &loc()).expect("normalizes");
608        assert_eq!(n.canonical_text, "'x'::varchar(5)");
609    }
610
611    /// Bare `'x'::character` — `pg_query` deparses this as `'x'::char(1)`, i.e. it
612    /// materialises an implicit typmod `(1)`.  That typmod trips the typmod gate
613    /// in `strip_redundant_string_casts`, so the cast is KEPT.  Pinning the exact
614    /// canonical text here means a future `pg_query` upgrade that changes this
615    /// implicit-typmod behaviour surfaces as a test failure rather than a silent
616    /// shift in normalisation.
617    #[test]
618    fn cast_string_to_bare_character_kept_or_documented() {
619        let node = parse_expr("'x'::character");
620        let n = from_pg_node(&node, None, &loc()).expect("normalizes");
621        assert_eq!(n.canonical_text, "'x'::char(1)");
622    }
623}