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}