001package gudusoft.gsqlparser.ir.semantic.diff; 002 003import java.util.Comparator; 004import java.util.Objects; 005 006/** 007 * One canonical-form lineage edge used for cross-engine comparison. 008 * 009 * <p>Identity is over four fields: 010 * 011 * <ul> 012 * <li>{@link #role} — {@link EdgeRole}.</li> 013 * <li>{@link #outputName} — the outer-query output column the edge belongs 014 * to. <b>Always non-null for {@link EdgeRole#SELECT}; always null for 015 * {@link EdgeRole#FILTER} and {@link EdgeRole#JOIN}.</b> The null-anchor 016 * is what makes the SELECT and FILTER/JOIN sets disjoint by 017 * construction without a string sentinel that could collide with a 018 * legitimate alias (see slice-7 plan, "Row-influence: typed 019 * null-output anchor").</li> 020 * <li>{@link #baseTable} — resolved base-table qualified name (lower-cased).</li> 021 * <li>{@link #baseColumn} — column on that base table (lower-cased).</li> 022 * </ul> 023 * 024 * <p>Aggregate-ness is <b>not</b> part of edge identity — it lives in 025 * {@link CanonicalLineageModel#getAggregateByOutput()}. Encoding it on the 026 * edge would mean {@code COUNT(*)} outputs (which produce zero edges) could 027 * vanish entirely from the model, and would break Java equality semantics 028 * for sets of edges. 029 */ 030public final class CanonicalLineageEdge { 031 032 /** Total ordering used everywhere a deterministic edge sequence is needed. */ 033 public static final Comparator<CanonicalLineageEdge> ORDER = 034 Comparator 035 .comparing((CanonicalLineageEdge e) -> e.role.name()) 036 .thenComparing(e -> e.outputName == null ? "" : e.outputName) 037 .thenComparing(e -> e.outputName == null ? 0 : 1) // null sorts before non-null 038 .thenComparing(e -> e.baseTable) 039 .thenComparing(e -> e.baseColumn); 040 041 private final EdgeRole role; 042 private final String outputName; 043 private final String baseTable; 044 private final String baseColumn; 045 046 public CanonicalLineageEdge(EdgeRole role, String outputName, String baseTable, String baseColumn) { 047 if (role == null) { 048 throw new IllegalArgumentException("role must not be null"); 049 } 050 if (baseTable == null || baseTable.isEmpty()) { 051 throw new IllegalArgumentException("baseTable must be non-empty"); 052 } 053 if (baseColumn == null || baseColumn.isEmpty()) { 054 throw new IllegalArgumentException("baseColumn must be non-empty"); 055 } 056 if (role == EdgeRole.SELECT) { 057 if (outputName == null || outputName.isEmpty()) { 058 throw new IllegalArgumentException("SELECT edges require a non-empty outputName"); 059 } 060 } else { 061 if (outputName != null) { 062 throw new IllegalArgumentException( 063 "FILTER/JOIN edges must have outputName=null (got '" + outputName + "')"); 064 } 065 } 066 this.role = role; 067 this.outputName = outputName; 068 this.baseTable = baseTable; 069 this.baseColumn = baseColumn; 070 } 071 072 public EdgeRole getRole() { 073 return role; 074 } 075 076 /** Non-null for SELECT, null for FILTER/JOIN. */ 077 public String getOutputName() { 078 return outputName; 079 } 080 081 public String getBaseTable() { 082 return baseTable; 083 } 084 085 public String getBaseColumn() { 086 return baseColumn; 087 } 088 089 @Override 090 public boolean equals(Object o) { 091 if (this == o) return true; 092 if (!(o instanceof CanonicalLineageEdge)) return false; 093 CanonicalLineageEdge other = (CanonicalLineageEdge) o; 094 return role == other.role 095 && Objects.equals(outputName, other.outputName) 096 && baseTable.equals(other.baseTable) 097 && baseColumn.equals(other.baseColumn); 098 } 099 100 @Override 101 public int hashCode() { 102 return Objects.hash(role, outputName, baseTable, baseColumn); 103 } 104 105 @Override 106 public String toString() { 107 return role + " " + (outputName == null ? "<row-set>" : outputName) 108 + " <- " + baseTable + "." + baseColumn; 109 } 110}