001package gudusoft.gsqlparser.lineage2.contract; 002 003import java.util.ArrayList; 004import java.util.Collections; 005import java.util.Comparator; 006import java.util.List; 007import java.util.Locale; 008 009/** 010 * Deterministic JSON serializer for {@link LineageDocument} 011 * (lineage2-contract.md §2): same input + same engine version => 012 * byte-identical output. 013 * 014 * <p><b>Documented sort keys</b> (applied by this exporter, regardless of 015 * model construction order): 016 * <ul> 017 * <li><b>statements</b> — {@code statementIndex} ascending;</li> 018 * <li><b>edges</b> — target endpoint (database, schema, object, column; 019 * lower-cased, null → empty string), then source endpoint (same key; 020 * a null source sorts first as all-empty), then {@code role}, then 021 * {@code axis}, then {@code statementIndex}. Ties preserve 022 * construction order (stable sort);</li> 023 * <li><b>transformGraphs</b> — {@code statementIndex} ascending;</li> 024 * <li><b>diagnostics</b> (document-level and per-statement) — emitted in 025 * producer order, which the engine keeps deterministic (source 026 * order). No re-sort, so producers control clustering.</li> 027 * </ul> 028 * 029 * <p>Output format matches {@code json.dumps(doc, indent=2, 030 * ensure_ascii=False)} of the gold-fixture generator: two-space indent, 031 * {@code ": "} key separator, non-ASCII characters emitted raw, 032 * {@code contractVersion} as the first key (consumers may short-circuit 033 * parse). No trailing newline (callers append one when writing files). 034 * 035 * <p>Key-emission conventions pinned by the gold fixtures: 036 * <ul> 037 * <li>statement {@code diagnostics} omitted when empty;</li> 038 * <li>endpoint location: non-null location emitted alone; null location 039 * with a reason emitted as {@code "sourceLocation": null} + 040 * {@code "locationNullReason"}; both absent → keys omitted;</li> 041 * <li>transformation always carries both {@code sourceLocation} and 042 * {@code locationNullReason} keys (one of the two null);</li> 043 * <li>edge {@code caseBranch} always present (null when not a CASE 044 * edge); {@code transformation} omitted when null.</li> 045 * </ul> 046 * 047 * <p>Internal API: not part of the public gsqlparser surface. 048 */ 049public final class ContractJsonExporter { 050 051 private ContractJsonExporter() { 052 // utility — no instances 053 } 054 055 /** Serialize {@code document} to deterministic contract JSON. */ 056 public static String toJson(LineageDocument document) { 057 if (document == null) { 058 throw new IllegalArgumentException("document must not be null"); 059 } 060 Writer w = new Writer(); 061 w.openObject(); 062 w.field("contractVersion", document.getContractVersion()); 063 w.field("engineVersion", document.getEngineVersion()); 064 w.field("vendor", document.getVendor()); 065 w.field("artifactLocator", document.getArtifactLocator()); 066 067 w.key("statements"); 068 List<ContractStatement> statements = 069 new ArrayList<>(document.getStatements()); 070 Collections.sort(statements, new Comparator<ContractStatement>() { 071 @Override 072 public int compare(ContractStatement a, ContractStatement b) { 073 return Integer.compare(a.getStatementIndex(), 074 b.getStatementIndex()); 075 } 076 }); 077 w.openArray(statements.isEmpty()); 078 for (ContractStatement s : statements) { 079 w.arrayItem(); 080 writeStatement(w, s); 081 } 082 w.closeArray(statements.isEmpty()); 083 084 w.key("edges"); 085 List<ContractEdge> edges = new ArrayList<>(document.getEdges()); 086 Collections.sort(edges, EDGE_ORDER); 087 w.openArray(edges.isEmpty()); 088 for (ContractEdge e : edges) { 089 w.arrayItem(); 090 writeEdge(w, e); 091 } 092 w.closeArray(edges.isEmpty()); 093 094 w.key("transformGraphs"); 095 List<TransformGraph> graphs = 096 new ArrayList<>(document.getTransformGraphs()); 097 Collections.sort(graphs, new Comparator<TransformGraph>() { 098 @Override 099 public int compare(TransformGraph a, TransformGraph b) { 100 return Integer.compare(a.getStatementIndex(), 101 b.getStatementIndex()); 102 } 103 }); 104 w.openArray(graphs.isEmpty()); 105 for (TransformGraph g : graphs) { 106 w.arrayItem(); 107 writeTransformGraph(w, g); 108 } 109 w.closeArray(graphs.isEmpty()); 110 111 w.key("diagnostics"); 112 List<ContractDiagnostic> diagnostics = document.getDiagnostics(); 113 w.openArray(diagnostics.isEmpty()); 114 for (ContractDiagnostic d : diagnostics) { 115 w.arrayItem(); 116 writeDiagnostic(w, d); 117 } 118 w.closeArray(diagnostics.isEmpty()); 119 120 w.closeObject(); 121 return w.toString(); 122 } 123 124 /** Documented edge sort key — see the class javadoc. */ 125 private static final Comparator<ContractEdge> EDGE_ORDER = 126 new Comparator<ContractEdge>() { 127 @Override 128 public int compare(ContractEdge a, ContractEdge b) { 129 int c = compareEndpoints(a.getTarget(), b.getTarget()); 130 if (c != 0) return c; 131 c = compareEndpoints(a.getSource(), b.getSource()); 132 if (c != 0) return c; 133 c = a.getRole().name().compareTo(b.getRole().name()); 134 if (c != 0) return c; 135 c = a.getAxis().name().compareTo(b.getAxis().name()); 136 if (c != 0) return c; 137 return Integer.compare(a.getStatementIndex(), 138 b.getStatementIndex()); 139 } 140 }; 141 142 private static int compareEndpoints(Endpoint a, Endpoint b) { 143 int c = sortPart(a == null ? null : a.getDatabase()) 144 .compareTo(sortPart(b == null ? null : b.getDatabase())); 145 if (c != 0) return c; 146 c = sortPart(a == null ? null : a.getSchema()) 147 .compareTo(sortPart(b == null ? null : b.getSchema())); 148 if (c != 0) return c; 149 c = sortPart(a == null ? null : a.getObject()) 150 .compareTo(sortPart(b == null ? null : b.getObject())); 151 if (c != 0) return c; 152 return sortPart(a == null ? null : a.getColumn()) 153 .compareTo(sortPart(b == null ? null : b.getColumn())); 154 } 155 156 private static String sortPart(String s) { 157 return (s == null) ? "" : s.toLowerCase(Locale.ROOT); 158 } 159 160 private static void writeStatement(Writer w, ContractStatement s) { 161 w.openObject(); 162 w.field("statementIndex", s.getStatementIndex()); 163 w.field("kind", s.getKind()); 164 w.field("queryFingerprint", s.getQueryFingerprint()); 165 if (s.getSourceLocation() != null) { 166 w.key("sourceLocation"); 167 writeLocation(w, s.getSourceLocation()); 168 } 169 if (!s.getDiagnostics().isEmpty()) { 170 w.key("diagnostics"); 171 w.openArray(false); 172 for (ContractDiagnostic d : s.getDiagnostics()) { 173 w.arrayItem(); 174 writeDiagnostic(w, d); 175 } 176 w.closeArray(false); 177 } 178 w.closeObject(); 179 } 180 181 private static void writeEdge(Writer w, ContractEdge e) { 182 w.openObject(); 183 if (e.getSource() == null) { 184 w.nullField("source"); 185 } else { 186 w.key("source"); 187 writeEndpoint(w, e.getSource()); 188 } 189 w.key("target"); 190 writeEndpoint(w, e.getTarget()); 191 w.field("role", e.getRole().name()); 192 w.field("axis", e.getAxis().name()); 193 w.field("evaluationModel", e.getEvaluationModel().name()); 194 w.field("rowLevel", e.isRowLevel()); 195 if (e.getCaseBranch() == null) { 196 w.nullField("caseBranch"); 197 } else { 198 w.field("caseBranch", e.getCaseBranch().jsonValue()); 199 } 200 w.field("dynamic", e.isDynamic()); 201 w.field("confidence", e.getConfidence()); 202 w.field("statementIndex", e.getStatementIndex()); 203 if (e.getTransformation() != null) { 204 w.key("transformation"); 205 writeTransformation(w, e.getTransformation()); 206 } 207 w.key("identity"); 208 w.openObject(); 209 w.field("factGuid", e.getIdentity().getFactGuid()); 210 w.field("variantGuid", e.getIdentity().getVariantGuid()); 211 w.field("occurrenceGuid", e.getIdentity().getOccurrenceGuid()); 212 w.closeObject(); 213 w.closeObject(); 214 } 215 216 private static void writeEndpoint(Writer w, Endpoint ep) { 217 w.openObject(); 218 w.fieldNullable("database", ep.getDatabase()); 219 w.fieldNullable("schema", ep.getSchema()); 220 w.field("object", ep.getObject()); 221 w.fieldNullable("column", ep.getColumn()); 222 w.field("objectKind", ep.getObjectKind().name()); 223 w.field("resolutionStatus", ep.getResolutionStatus().name()); 224 if (ep.getScopeKind() != null) { 225 w.field("scopeKind", ep.getScopeKind().name()); 226 } 227 if (ep.getDefinedAtStatementIndex() != null) { 228 w.field("definedAtStatementIndex", ep.getDefinedAtStatementIndex()); 229 } 230 if (ep.getSourceLocation() != null) { 231 w.key("sourceLocation"); 232 writeLocation(w, ep.getSourceLocation()); 233 } else if (ep.getLocationNullReason() != null) { 234 w.nullField("sourceLocation"); 235 w.field("locationNullReason", ep.getLocationNullReason().name()); 236 } 237 w.closeObject(); 238 } 239 240 private static void writeTransformation(Writer w, Transformation t) { 241 w.openObject(); 242 w.field("originalExpression", t.getOriginalExpression()); 243 w.field("normalizedExpression", t.getNormalizedExpression()); 244 w.field("expressionHash", t.getExpressionHash()); 245 if (t.getSourceLocation() != null) { 246 w.key("sourceLocation"); 247 writeLocation(w, t.getSourceLocation()); 248 w.nullField("locationNullReason"); 249 } else { 250 w.nullField("sourceLocation"); 251 if (t.getLocationNullReason() != null) { 252 w.field("locationNullReason", t.getLocationNullReason().name()); 253 } else { 254 w.nullField("locationNullReason"); 255 } 256 } 257 w.closeObject(); 258 } 259 260 private static void writeTransformGraph(Writer w, TransformGraph g) { 261 w.openObject(); 262 w.field("statementIndex", g.getStatementIndex()); 263 w.fieldNullable("occurrenceGuidRef", g.getOccurrenceGuidRef()); 264 w.key("nodes"); 265 w.openArray(g.getNodes().isEmpty()); 266 for (TransformGraph.Node n : g.getNodes()) { 267 w.arrayItem(); 268 w.openObject(); 269 w.field("localNodeId", n.getLocalNodeId()); 270 w.field("nodeKind", n.getNodeKind().name()); 271 if (n.getLabel() != null) { 272 w.field("label", n.getLabel()); 273 } 274 if (n.getSourceLocation() != null) { 275 w.key("sourceLocation"); 276 writeLocation(w, n.getSourceLocation()); 277 } else if (n.getLocationNullReason() != null) { 278 w.nullField("sourceLocation"); 279 w.field("locationNullReason", n.getLocationNullReason().name()); 280 } 281 w.closeObject(); 282 } 283 w.closeArray(g.getNodes().isEmpty()); 284 w.key("steps"); 285 w.openArray(g.getSteps().isEmpty()); 286 for (TransformGraph.Step s : g.getSteps()) { 287 w.arrayItem(); 288 w.openObject(); 289 w.field("stepIndex", s.getStepIndex()); 290 w.field("operationType", s.getOperationType().name()); 291 w.key("inputNodeIds"); 292 w.openArray(s.getInputNodeIds().isEmpty()); 293 for (String id : s.getInputNodeIds()) { 294 w.arrayItem(); 295 w.value(id); 296 } 297 w.closeArray(s.getInputNodeIds().isEmpty()); 298 w.field("outputNodeId", s.getOutputNodeId()); 299 if (s.getExpressionHash() != null) { 300 w.field("expressionHash", s.getExpressionHash()); 301 } 302 if (s.getSourceLocation() != null) { 303 w.key("sourceLocation"); 304 writeLocation(w, s.getSourceLocation()); 305 } else if (s.getLocationNullReason() != null) { 306 w.nullField("sourceLocation"); 307 w.field("locationNullReason", s.getLocationNullReason().name()); 308 } 309 w.closeObject(); 310 } 311 w.closeArray(g.getSteps().isEmpty()); 312 w.closeObject(); 313 } 314 315 private static void writeDiagnostic(Writer w, ContractDiagnostic d) { 316 w.openObject(); 317 w.field("code", d.getCode()); 318 w.field("severity", d.getSeverity().name()); 319 w.field("message", d.getMessage()); 320 if (d.getStatementIndex() != null) { 321 w.field("statementIndex", d.getStatementIndex()); 322 } 323 if (d.getSourceLocation() != null) { 324 w.key("sourceLocation"); 325 writeLocation(w, d.getSourceLocation()); 326 } 327 w.closeObject(); 328 } 329 330 private static void writeLocation(Writer w, SourceLocation loc) { 331 w.openObject(); 332 w.field("artifactLocator", loc.getArtifactLocator()); 333 w.field("statementIndex", loc.getStatementIndex()); 334 w.field("startLine", loc.getStartLine()); 335 w.field("startCol", loc.getStartCol()); 336 w.field("endLine", loc.getEndLine()); 337 w.field("endCol", loc.getEndCol()); 338 w.closeObject(); 339 } 340 341 /** 342 * Minimal indenting JSON writer producing the 343 * {@code json.dumps(indent=2, ensure_ascii=False)} surface form. 344 */ 345 private static final class Writer { 346 private final StringBuilder sb = new StringBuilder(1024); 347 private int indent; 348 private boolean firstInScope = true; 349 350 void openObject() { 351 sb.append('{'); 352 indent++; 353 firstInScope = true; 354 } 355 356 void closeObject() { 357 indent--; 358 if (!firstInScope) { 359 newlineIndent(); 360 } 361 sb.append('}'); 362 firstInScope = false; 363 } 364 365 /** {@code empty} must say whether the array will receive items. */ 366 void openArray(boolean empty) { 367 sb.append('['); 368 indent++; 369 firstInScope = true; 370 if (empty) { 371 // json.dumps prints empty arrays inline: [] 372 indent--; 373 sb.append(']'); 374 firstInScope = false; 375 } 376 } 377 378 void closeArray(boolean empty) { 379 if (empty) { 380 return; // already closed inline by openArray 381 } 382 indent--; 383 newlineIndent(); 384 sb.append(']'); 385 firstInScope = false; 386 } 387 388 void arrayItem() { 389 separator(); 390 } 391 392 void key(String name) { 393 separator(); 394 quote(name); 395 sb.append(": "); 396 // The value follows immediately; mark scope as populated so the 397 // value's own open/close logic does not re-separate. 398 firstInScope = false; 399 } 400 401 void field(String name, String value) { 402 key(name); 403 quote(value); 404 } 405 406 void field(String name, int value) { 407 key(name); 408 sb.append(value); 409 } 410 411 void field(String name, boolean value) { 412 key(name); 413 sb.append(value); 414 } 415 416 void field(String name, double value) { 417 key(name); 418 // Double.toString matches Python repr for the contract's 419 // confidence tiers (1.0 / 0.5 / 0.2). 420 sb.append(Double.toString(value)); 421 } 422 423 void fieldNullable(String name, String value) { 424 key(name); 425 if (value == null) { 426 sb.append("null"); 427 } else { 428 quote(value); 429 } 430 } 431 432 void nullField(String name) { 433 key(name); 434 sb.append("null"); 435 } 436 437 void value(String s) { 438 quote(s); 439 } 440 441 private void separator() { 442 if (!firstInScope) { 443 sb.append(','); 444 } 445 newlineIndent(); 446 firstInScope = false; 447 } 448 449 private void newlineIndent() { 450 sb.append('\n'); 451 for (int i = 0; i < indent; i++) { 452 sb.append(" "); 453 } 454 } 455 456 private void quote(String s) { 457 sb.append('"'); 458 for (int i = 0; i < s.length(); i++) { 459 char c = s.charAt(i); 460 switch (c) { 461 case '"': 462 sb.append("\\\""); 463 break; 464 case '\\': 465 sb.append("\\\\"); 466 break; 467 case '\n': 468 sb.append("\\n"); 469 break; 470 case '\r': 471 sb.append("\\r"); 472 break; 473 case '\t': 474 sb.append("\\t"); 475 break; 476 case '\b': 477 sb.append("\\b"); 478 break; 479 case '\f': 480 sb.append("\\f"); 481 break; 482 default: 483 if (c < 0x20) { 484 sb.append(String.format("\\u%04x", (int) c)); 485 } else { 486 // ensure_ascii=False — non-ASCII emitted raw 487 sb.append(c); 488 } 489 } 490 } 491 sb.append('"'); 492 } 493 494 @Override 495 public String toString() { 496 return sb.toString(); 497 } 498 } 499}