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}