001package gudusoft.gsqlparser.ir.semantic.diff;
002
003import gudusoft.gsqlparser.util.json.JSON;
004
005import java.util.LinkedHashMap;
006import java.util.LinkedHashSet;
007import java.util.List;
008import java.util.Locale;
009import java.util.Map;
010import java.util.Set;
011
012/**
013 * Project a lineage2 contract JSON document (lineage2-contract.md §2, v0.9)
014 * into the {@link CanonicalLineageModel} comparison form shared with
015 * {@link SemanticIRProjector} and {@link DlineageXmlProjector} (US-010).
016 *
017 * <p>Mapping rules:
018 *
019 * <ul>
020 *   <li>VALUE-axis edges become {@link EdgeRole#SELECT} edges keyed by the
021 *       lower-cased target column as {@code outputName}. The contract role
022 *       (DIRECT/TRANSFORM/AGGREGATE/…) is preserved on
023 *       {@link CanonicalLineageEdge#getSemanticRole()} for the
024 *       {@link DivergenceClass#ROLE_MISMATCH} pass.</li>
025 *   <li>ROW-axis edges become the typed null-output anchor edges:
026 *       contract role JOIN maps to {@link EdgeRole#JOIN}, every other
027 *       ROW-axis role (FILTER, and the R3 UNKNOWN fallback) maps to
028 *       {@link EdgeRole#FILTER} — the same bucket legacy {@code fdr}
029 *       projects into. The original role survives as the semanticRole.</li>
030 *   <li>An output is flagged aggregate when any VALUE-axis edge feeding it
031 *       carries contract role AGGREGATE.</li>
032 *   <li>{@code role: DERIVED} edges have a null source by contract §4; they
033 *       contribute the target output name (so output-presence comparison
034 *       sees it) but no canonical edge.</li>
035 *   <li>Base tables are the dot-joined non-empty
036 *       {@code database.schema.object} endpoint parts, lower-cased —
037 *       contract §4.1 promises physical endpoints arrive fully expanded.</li>
038 * </ul>
039 *
040 * <p>Identity, transformation, location, confidence, and diagnostic fields
041 * are intentionally ignored: the canonical model compares lineage facts
042 * only. A document with no parseable {@code edges} array is
043 * {@link ProjectorResult.UnsupportedReason#MALFORMED_CONTRACT_JSON}; a valid
044 * document with zero edges is
045 * {@link ProjectorResult.UnsupportedReason#NO_RELATIONSHIPS}, mirroring the
046 * dlineage projector's convention.
047 */
048public final class Lineage2Projector {
049
050    private Lineage2Projector() {}
051
052    /** Contract roles legal on a VALUE-axis edge per §5 pass 2. */
053    private static final Set<String> VALUE_ROLES = new LinkedHashSet<>();
054    /** Contract roles legal on a ROW-axis edge per §5 pass 2. */
055    private static final Set<String> ROW_ROLES = new LinkedHashSet<>();
056    static {
057        VALUE_ROLES.add("AGGREGATE");
058        VALUE_ROLES.add("CONDITION");
059        VALUE_ROLES.add("TRANSFORM");
060        VALUE_ROLES.add("LOOKUP");
061        VALUE_ROLES.add("DIRECT");
062        VALUE_ROLES.add("DERIVED");
063        VALUE_ROLES.add("UNKNOWN");
064        ROW_ROLES.add("FILTER");
065        ROW_ROLES.add("JOIN");
066        ROW_ROLES.add("UNKNOWN");
067    }
068
069    /**
070     * @param contractJson one lineage2 contract document (the whole unit
071     *                     output, §2), as produced by the engine's exporter
072     */
073    public static ProjectorResult project(String contractJson) {
074        if (contractJson == null || contractJson.trim().isEmpty()) {
075            return malformed("empty contract document");
076        }
077        Object root;
078        try {
079            root = JSON.parseObject(contractJson);
080        } catch (RuntimeException e) {
081            return malformed("unparseable JSON: " + e.getMessage());
082        }
083        if (!(root instanceof Map)) {
084            return malformed("top-level value is not an object");
085        }
086        Map<?, ?> doc = (Map<?, ?>) root;
087        if (!(doc.get("contractVersion") instanceof String)) {
088            return malformed("missing contractVersion");
089        }
090        Object edgesVal = doc.get("edges");
091        if (!(edgesVal instanceof List)) {
092            return malformed("missing or non-array edges");
093        }
094        List<?> edgeList = (List<?>) edgesVal;
095        if (edgeList.isEmpty()) {
096            return ProjectorResult.unsupported(
097                    ProjectorResult.UnsupportedReason.NO_RELATIONSHIPS,
098                    "contract document has zero edges");
099        }
100
101        Set<CanonicalLineageEdge> edges = new LinkedHashSet<>();
102        Set<String> outputNames = new LinkedHashSet<>();
103        Map<String, Boolean> aggregateByOutput = new LinkedHashMap<>();
104        int index = 0;
105        for (Object item : edgeList) {
106            String where = "edges[" + index + "]";
107            index++;
108            if (!(item instanceof Map)) {
109                return malformed(where + " is not an object");
110            }
111            Map<?, ?> edge = (Map<?, ?>) item;
112            String role = stringField(edge, "role");
113            String axis = stringField(edge, "axis");
114            if (role == null || axis == null) {
115                return malformed(where + " lacks role/axis");
116            }
117            Map<?, ?> target = mapField(edge, "target");
118            if (target == null) {
119                return malformed(where + " lacks a target endpoint");
120            }
121            String targetColumn = lower(stringField(target, "column"));
122            Map<?, ?> source = mapField(edge, "source");
123            if (source == null && !"DERIVED".equals(role)) {
124                // Contract §4: a null source is legal exclusively for DERIVED.
125                return malformed(where + " has null source for role " + role);
126            }
127
128            if ("VALUE".equals(axis)) {
129                if (!VALUE_ROLES.contains(role)) {
130                    return malformed(where + " has non-VALUE role " + role + " on VALUE axis");
131                }
132                if (targetColumn == null) {
133                    return malformed(where + " VALUE edge lacks target.column");
134                }
135                outputNames.add(targetColumn);
136                boolean aggregate = "AGGREGATE".equals(role);
137                Boolean prior = aggregateByOutput.get(targetColumn);
138                aggregateByOutput.put(targetColumn,
139                        aggregate || (prior != null && prior.booleanValue()));
140                if (source != null) {
141                    String baseTable = qualifiedName(source);
142                    String baseColumn = lower(stringField(source, "column"));
143                    if (baseTable == null || baseColumn == null) {
144                        return malformed(where + " source endpoint lacks object/column");
145                    }
146                    edges.add(new CanonicalLineageEdge(EdgeRole.SELECT, targetColumn,
147                            baseTable, baseColumn, role));
148                }
149            } else if ("ROW".equals(axis)) {
150                if (!ROW_ROLES.contains(role)) {
151                    return malformed(where + " has non-ROW role " + role + " on ROW axis");
152                }
153                if (source == null) {
154                    return malformed(where + " ROW edge lacks a source endpoint");
155                }
156                String baseTable = qualifiedName(source);
157                String baseColumn = lower(stringField(source, "column"));
158                if (baseTable == null || baseColumn == null) {
159                    return malformed(where + " source endpoint lacks object/column");
160                }
161                EdgeRole canonical = "JOIN".equals(role) ? EdgeRole.JOIN : EdgeRole.FILTER;
162                edges.add(new CanonicalLineageEdge(canonical, null, baseTable, baseColumn, role));
163            } else {
164                return malformed(where + " has unknown axis " + axis);
165            }
166        }
167
168        return ProjectorResult.ok(new CanonicalLineageModel(edges, outputNames, aggregateByOutput));
169    }
170
171    private static ProjectorResult malformed(String detail) {
172        return ProjectorResult.unsupported(
173                ProjectorResult.UnsupportedReason.MALFORMED_CONTRACT_JSON, detail);
174    }
175
176    private static String stringField(Map<?, ?> map, String key) {
177        Object v = map.get(key);
178        return v instanceof String && !((String) v).isEmpty() ? (String) v : null;
179    }
180
181    private static Map<?, ?> mapField(Map<?, ?> map, String key) {
182        Object v = map.get(key);
183        return v instanceof Map ? (Map<?, ?>) v : null;
184    }
185
186    private static String lower(String s) {
187        return s == null ? null : s.toLowerCase(Locale.ROOT);
188    }
189
190    /** Dot-joined non-empty database/schema/object parts, lower-cased. */
191    private static String qualifiedName(Map<?, ?> endpoint) {
192        StringBuilder sb = new StringBuilder();
193        for (String key : new String[] {"database", "schema", "object"}) {
194            String part = stringField(endpoint, key);
195            if (part != null) {
196                if (sb.length() > 0) sb.append('.');
197                sb.append(lower(part));
198            }
199        }
200        return sb.length() == 0 ? null : sb.toString();
201    }
202}