Class AstEquivalence
Why record equality is not the answer
Every RelNode, Predicate and Operand is a Java
record carrying a SourceLocation as a component,
and nothing in this module overrides equals. The compiler-generated
component-wise equality therefore includes the source position: σ x > 5
written on line 3 and σ x > 5 written on line 7 are unequal, with
different hash codes. Every rule that wants to know whether two fragments say the
same thing — duplicate-conjunct removal (p ∧ p → p), bound comparison for
contradiction detection, the set-op idempotence laws, any common-subexpression
elimination — needs this class instead.
⚠ Testing this
Every test-convenience constructor in this module uses
SourceLocation.UNKNOWN. An implementation keyed on record equality
passes its unit tests and then finds nothing on real parsed queries, because only
parsed fragments carry distinct locations. Fixtures for anything that depends on
this class must be built through the parser; hand-built AstBuilders
fragments are not evidence.
What is normalised
- Source locations — ignored everywhere, recursively, including inside the predicates and operands a node carries.
- Numeric literals —
NumberOperandstores aString, so5,5.0and05are three distinct values under record equality. They are compared asBigDecimals and are equivalent here. A literal that does not parse as a decimal falls back to string comparison. - Identifier case — attribute, function and relation names are compared
case-insensitively, matching
Predicates.isColumn(com.darkcollective.relix.ast.Operand, java.lang.String)and the symbol layer.
This is not exact structural equality
The normalisation above is the point of this class, and it is also what makes it
the wrong comparison for a test asserting that a fragment parsed or was built
exactly as written: under the rules above 5.0 matches 5 and
users matches Users, so a test asserting either distinction would
silently stop asserting anything. Comparing printed forms is the weaker claim in the
other direction too — two structurally different trees that print alike compare equal
here. For the exact, position-blind comparison, strip the locations and use record
equality: AstLocations.stripLocations, published from this module's test
fixtures, with RelNodeAssert.isStructurallyEqualTo as its assertion form.
What is deliberately not normalised
- An attribute's qualifier.
Users.idandidare not equivalent.AttributeNames.stripQualifier(java.lang.String)exists and is tempting, but "equal ignoring qualifier" is wrong exactly where this class gets used: inA ⨝ B,A.xandB.xname different columns, and merging them would make a rule that reasons about them vacuous — the same conclusionEQ-001reached independently. A caller that genuinely wants the unqualified reading should strip before comparing, which makes the choice visible at the call site. - Commutativity and associativity.
a ∧ bis not equivalent tob ∧ a, andx + 1is not equivalent to1 + x. This is structural equality, not semantic equality; a caller that wants the commutative reading should canonicalise its operands first (the predicate simplifier'sPRED-003already normalises a comparison's operand order for precisely this reason). - Arithmetic identities.
x + 0is not equivalent tox; that is the expression simplifier's job, and running it first is what makes this comparison see the simplified form.
Reference identity is a different question
RelNode.mapChildren(java.util.function.UnaryOperator<com.darkcollective.relix.ast.RelNode>) contracts to return this when nothing
changed, so != is the optimizer's established "did this rewrite fire"
signal. That is identity. This class answers semantic sameness,
and the two must not be conflated: two fragments can be equivalent here and still
be different objects, which is the entire point.
This class is stateless; every method is static.
-
Method Summary
Modifier and TypeMethodDescriptionstatic StringReturns a location-free canonical string fornode, equal for exactly the treesequivalent(RelNode, RelNode)accepts — so it can be used as a map or set key where pairwise comparison would be quadratic.static booleanequivalent(Operand a, Operand b) Returns whether two operand expressions are structurally equivalent, ignoring source locations and normalising numeric literals and identifier case.static booleanequivalent(Predicate a, Predicate b) Returns whether two predicates are structurally equivalent, ignoring source locations and normalising the operands they compare.static booleanequivalent(RelNode a, RelNode b) Returns whether two expression trees are structurally equivalent, ignoring source locations.
-
Method Details
-
equivalent
Returns whether two operand expressions are structurally equivalent, ignoring source locations and normalising numeric literals and identifier case.- Parameters:
a- the first operand; must not be nullb- the second operand; must not be null- Returns:
trueif the two denote the same expression
-
equivalent
Returns whether two predicates are structurally equivalent, ignoring source locations and normalising the operands they compare.This is the comparison duplicate-conjunct removal (
p ∧ p → p) asks:Predicates.conjuncts(com.darkcollective.relix.ast.Predicate)splits, this decides which of the results say the same thing, andPredicates.conjoin(java.util.List<com.darkcollective.relix.ast.Predicate>)rebuilds.- Parameters:
a- the first predicate; must not be nullb- the second predicate; must not be null- Returns:
trueif the two denote the same condition
-
equivalent
Returns whether two expression trees are structurally equivalent, ignoring source locations.How, and what that costs
This is decided by comparing
RelNode.prettyPrint()forms rather than by a 48-arm structural walk, for two reasons that are properties of the printer rather than conveniences:- It is location-free — no arm prints a
SourceLocation— so the position a fragment was written at cannot leak into the answer. - It round-trips through the parser. Two trees that print identically therefore parse back to the same tree, which is what rules out a false positive — the only dangerous direction for a rewrite that deletes one of two "identical" sub-trees.
It is also exhaustive by construction:
RelNodeVisitorhas no default arm, so a new node type cannot be added without teaching the printer about it, and this comparison follows for free.Known limitation. The printer spells literals verbatim, so
σ x > 5 (R)andσ x > 5.0 (R)are not equivalent here, even thoughequivalent(Operand, Operand)says their bounds are. That is a false negative — a rule declines to fire — which is the safe direction, and it is where the line is drawn deliberately: normalising literals inside a node needs an operand-rewriting arm for all 48RelNodetypes, which is the cost this class exists to avoid, and no shipped rule consumes node-level equivalence yet. Fix it when the first one does.- Parameters:
a- the first tree; must not be nullb- the second tree; must not be null- Returns:
trueif the two trees denote the same expression
- It is location-free — no arm prints a
-
digest
Returns a location-free canonical string fornode, equal for exactly the treesequivalent(RelNode, RelNode)accepts — so it can be used as a map or set key where pairwise comparison would be quadratic.- Parameters:
node- the tree to digest; must not be null- Returns:
- the canonical form; never null
-