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 * 030 * <p>{@link #semanticRole} (US-010) is likewise <b>not</b> part of identity: 031 * it is the fine-grained role from the lineage2 contract §5 taxonomy 032 * (DIRECT/TRANSFORM/FILTER/JOIN/AGGREGATE/DERIVED/CONDITION/LOOKUP/UNKNOWN), 033 * carried for the {@link DivergenceClass#ROLE_MISMATCH} comparison pass. 034 * Null when the producing projector has no role information (the slice-7 035 * projectors). Keeping it out of equals/hashCode means a role disagreement 036 * still identity-matches and is reported as ROLE_MISMATCH instead of a 037 * missing+extra pair. 038 */ 039public final class CanonicalLineageEdge { 040 041 /** Total ordering used everywhere a deterministic edge sequence is needed. */ 042 public static final Comparator<CanonicalLineageEdge> ORDER = 043 Comparator 044 .comparing((CanonicalLineageEdge e) -> e.role.name()) 045 .thenComparing(e -> e.outputName == null ? "" : e.outputName) 046 .thenComparing(e -> e.outputName == null ? 0 : 1) // null sorts before non-null 047 .thenComparing(e -> e.baseTable) 048 .thenComparing(e -> e.baseColumn); 049 050 private final EdgeRole role; 051 private final String outputName; 052 private final String baseTable; 053 private final String baseColumn; 054 private final String semanticRole; 055 056 public CanonicalLineageEdge(EdgeRole role, String outputName, String baseTable, String baseColumn) { 057 this(role, outputName, baseTable, baseColumn, null); 058 } 059 060 public CanonicalLineageEdge(EdgeRole role, String outputName, String baseTable, String baseColumn, 061 String semanticRole) { 062 if (role == null) { 063 throw new IllegalArgumentException("role must not be null"); 064 } 065 if (baseTable == null || baseTable.isEmpty()) { 066 throw new IllegalArgumentException("baseTable must be non-empty"); 067 } 068 if (baseColumn == null || baseColumn.isEmpty()) { 069 throw new IllegalArgumentException("baseColumn must be non-empty"); 070 } 071 if (role == EdgeRole.SELECT) { 072 if (outputName == null || outputName.isEmpty()) { 073 throw new IllegalArgumentException("SELECT edges require a non-empty outputName"); 074 } 075 } else { 076 if (outputName != null) { 077 throw new IllegalArgumentException( 078 "FILTER/JOIN edges must have outputName=null (got '" + outputName + "')"); 079 } 080 } 081 if (semanticRole != null && semanticRole.isEmpty()) { 082 throw new IllegalArgumentException("semanticRole must be null or non-empty"); 083 } 084 this.role = role; 085 this.outputName = outputName; 086 this.baseTable = baseTable; 087 this.baseColumn = baseColumn; 088 this.semanticRole = semanticRole; 089 } 090 091 public EdgeRole getRole() { 092 return role; 093 } 094 095 /** Non-null for SELECT, null for FILTER/JOIN. */ 096 public String getOutputName() { 097 return outputName; 098 } 099 100 public String getBaseTable() { 101 return baseTable; 102 } 103 104 public String getBaseColumn() { 105 return baseColumn; 106 } 107 108 /** 109 * Fine-grained lineage2 contract role (uppercase, §5 taxonomy), or null 110 * when the producing projector carries no role information. NOT part of 111 * edge identity — see the class javadoc. 112 */ 113 public String getSemanticRole() { 114 return semanticRole; 115 } 116 117 @Override 118 public boolean equals(Object o) { 119 if (this == o) return true; 120 if (!(o instanceof CanonicalLineageEdge)) return false; 121 CanonicalLineageEdge other = (CanonicalLineageEdge) o; 122 return role == other.role 123 && Objects.equals(outputName, other.outputName) 124 && baseTable.equals(other.baseTable) 125 && baseColumn.equals(other.baseColumn); 126 } 127 128 @Override 129 public int hashCode() { 130 return Objects.hash(role, outputName, baseTable, baseColumn); 131 } 132 133 @Override 134 public String toString() { 135 return role + " " + (outputName == null ? "<row-set>" : outputName) 136 + " <- " + baseTable + "." + baseColumn 137 + (semanticRole == null ? "" : " [" + semanticRole + "]"); 138 } 139}