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}