001package gudusoft.gsqlparser.ir.semantic.export; 002 003import gudusoft.gsqlparser.common.structured.StructuredColumnPath; 004import gudusoft.gsqlparser.common.structured.StructuredPathSegment; 005import gudusoft.gsqlparser.ir.semantic.ColumnRef; 006import gudusoft.gsqlparser.ir.semantic.FrameBound; 007import gudusoft.gsqlparser.ir.semantic.GroupingElement; 008import gudusoft.gsqlparser.ir.semantic.LineageEdge; 009import gudusoft.gsqlparser.ir.semantic.LineageRef; 010import gudusoft.gsqlparser.ir.semantic.OutputColumn; 011import gudusoft.gsqlparser.ir.semantic.RelationSource; 012import gudusoft.gsqlparser.ir.semantic.RowLimit; 013import gudusoft.gsqlparser.ir.semantic.SemanticProgram; 014import gudusoft.gsqlparser.ir.semantic.SetOperator; 015import gudusoft.gsqlparser.ir.semantic.Diagnostic; 016import gudusoft.gsqlparser.ir.semantic.RelatedLocation; 017import gudusoft.gsqlparser.ir.semantic.SourceSpan; 018import gudusoft.gsqlparser.ir.semantic.StatementGraph; 019import gudusoft.gsqlparser.ir.semantic.TargetRelation; 020import gudusoft.gsqlparser.ir.semantic.WindowFrame; 021import gudusoft.gsqlparser.ir.semantic.WindowSpec; 022import gudusoft.gsqlparser.ir.semantic.VendorError; 023import gudusoft.gsqlparser.ir.semantic.binding.RelationBinding; 024import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinAnalysisFacts; 025import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinEndpoint; 026import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinEndpointKind; 027import gudusoft.gsqlparser.ir.semantic.joinanalysis.JoinEntity; 028import gudusoft.gsqlparser.ir.semantic.joinanalysis.Predicate; 029import gudusoft.gsqlparser.ir.semantic.joinanalysis.PredicateOperand; 030import gudusoft.gsqlparser.ir.semantic.joinanalysis.QueryBlockScope; 031 032import java.util.List; 033 034/** 035 * Deterministic JSON exporter for {@link SemanticProgram}. Hand-rolled — 036 * no reflection, no map ordering surprises, no third-party dependency — 037 * so golden files stay byte-stable across JVMs and refactors. 038 * 039 * <p>Format is pretty-printed with two-space indent and a trailing newline. 040 * Field order within an object is fixed by the writer methods, not by any 041 * map iteration order. 042 * 043 * <p><b>API status: advanced/preview.</b> Supported consumers obtain JSON and 044 * its schema discriminator from 045 * {@link gudusoft.gsqlparser.ir.semantic.AnalysisResult}. They should branch 046 * on the schema version rather than depend on this exporter implementation. 047 */ 048public final class SemanticIRJsonExporter { 049 050 private static final String INDENT = " "; 051 052 /** 053 * Baseline JSON schema version emitted at the top of every exported 054 * program when the program contains no structured-data paths. 055 * Slice 75 froze this at {@code "1"}; structured-dataflow support 056 * adds {@code "2"} which carries optional {@code path} fields on 057 * {@link ColumnRef} and {@link LineageRef}. Consumers can branch on 058 * the value to handle either shape. 059 * 060 * <p>Initialised in a static block (rather than as a literal 061 * compile-time constant) so binary consumers compiled against 062 * one version of this library don't silently see the old value 063 * after a drop-in JAR upgrade. The trade-off is a slightly 064 * verbose declaration; the upside is a durable version contract 065 * (codex diff-review round-1 Q3). 066 */ 067 public static final String SCHEMA_VERSION; 068 069 /** 070 * Schema version emitted when any reference in the program carries 071 * a {@link StructuredColumnPath}. Strictly additive over v1: every 072 * v1 field is still present, v2 only adds optional {@code path} 073 * objects on column / lineage references. 074 */ 075 public static final String SCHEMA_VERSION_STRUCTURED; 076 077 /** 078 * Schema version emitted when any statement carries join-analysis 079 * facts (a structured join graph or WHERE filter predicates — slice 080 * 172, GAPs 1/2/4). Strictly additive over v1/v2: it only adds an 081 * optional {@code joinAnalysis} object per statement; every other 082 * field is unchanged, so v1/v2 payloads are byte-identical when no 083 * statement has joins or filter predicates. 084 */ 085 public static final String SCHEMA_VERSION_JOIN_ANALYSIS; 086 087 /** 088 * Schema version emitted when the program contains at least one 089 * {@link StatementGraph#KIND_UNANALYZED} block — a new {@code kind} value 090 * plus the {@code unanalyzedReason} object, neither of which is part of the 091 * frozen v1 / v2 / v3 shapes. Version 5 extends the original v4 diagnostic 092 * object with category, related locations, and exact vendor enrichment. 093 * Selected per-payload, so a program with nothing skipped still exports 094 * under its historical version, byte for byte. 095 */ 096 public static final String SCHEMA_VERSION_DEGRADED; 097 098 static { 099 SCHEMA_VERSION = "1"; 100 SCHEMA_VERSION_STRUCTURED = "2"; 101 SCHEMA_VERSION_JOIN_ANALYSIS = "3"; 102 SCHEMA_VERSION_DEGRADED = "5"; 103 } 104 105 private SemanticIRJsonExporter() {} 106 107 /** 108 * Return the schema version selected for {@code program}. 109 * 110 * <p>The exporter uses payload-sensitive versions: ordinary programs use 111 * v1, structured-column paths use v2, join-analysis facts use v3, and a 112 * recovered program containing an unanalyzed block uses v5. This method is 113 * the single selector shared by the JSON writer and 114 * {@code SqlSemanticAnalyzer}, so an {@code AnalysisResult} cannot report a 115 * version different from its JSON payload. 116 * 117 * @param program non-null Semantic IR program 118 * @return schema version that {@link #toJson(SemanticProgram)} will emit 119 */ 120 public static String schemaVersionFor(SemanticProgram program) { 121 if (program == null) { 122 throw new IllegalArgumentException("program must not be null"); 123 } 124 return selectSchemaVersion(program, 125 programNeedsJoinAnalysisVersion(program)); 126 } 127 128 private static String selectSchemaVersion(SemanticProgram program, 129 boolean joinAnalysisVersion) { 130 if (programContainsUnanalyzedBlock(program)) { 131 return SCHEMA_VERSION_DEGRADED; 132 } 133 if (joinAnalysisVersion) { 134 return SCHEMA_VERSION_JOIN_ANALYSIS; 135 } 136 return programNeedsStructuredVersion(program) 137 ? SCHEMA_VERSION_STRUCTURED : SCHEMA_VERSION; 138 } 139 140 public static String toJson(SemanticProgram program) { 141 if (program == null) { 142 throw new IllegalArgumentException("program must not be null"); 143 } 144 StringBuilder sb = new StringBuilder(); 145 sb.append("{\n"); 146 // Slice 75: schemaVersion is the first key in every exported 147 // program so consumers can short-circuit on it without parsing 148 // the full payload. Bumped to "2" when any reference carries a 149 // structured path (additive over v1). 150 boolean joinAnalysisVersion = programNeedsJoinAnalysisVersion(program); 151 writeKey(sb, 1, "schemaVersion"); 152 // A degraded payload carries a `kind` value and an `unanalyzedReason` 153 // object that none of the frozen v1/v2/v3 shapes describe, so it gets 154 // its own version (v5 after complete diagnostic metadata was added to 155 // the original v4 shape). Checked first: it is the widest shape. 156 // Programs with nothing skipped are unaffected and keep exporting under 157 // their historical version, byte for byte. 158 writeString(sb, selectSchemaVersion(program, joinAnalysisVersion)); 159 sb.append(",\n"); 160 writeKey(sb, 1, "statements"); 161 sb.append("["); 162 List<StatementGraph> stmts = program.getStatements(); 163 if (!stmts.isEmpty()) { 164 sb.append("\n"); 165 for (int i = 0; i < stmts.size(); i++) { 166 writeStatement(sb, 2, stmts.get(i), joinAnalysisVersion); 167 if (i < stmts.size() - 1) sb.append(","); 168 sb.append("\n"); 169 } 170 indent(sb, 1); 171 } 172 sb.append("],\n"); 173 writeKey(sb, 1, "lineage"); 174 writeLineage(sb, 1, program.getLineage()); 175 sb.append("\n}\n"); 176 return sb.toString(); 177 } 178 179 /** 180 * @return true when any statement is a degrade placeholder, i.e. the 181 * payload will carry {@code kind: "UNANALYZED"} and an 182 * {@code unanalyzedReason} object. 183 */ 184 private static boolean programContainsUnanalyzedBlock(SemanticProgram program) { 185 for (StatementGraph s : program.getStatements()) { 186 if (s.isUnanalyzed()) return true; 187 } 188 return false; 189 } 190 191 private static void writeStatement(StringBuilder sb, int depth, StatementGraph s, 192 boolean joinAnalysisVersion) { 193 indent(sb, depth); 194 sb.append("{\n"); 195 if (s.getName() != null) { 196 writeKey(sb, depth + 1, "name"); 197 writeString(sb, s.getName()); 198 sb.append(",\n"); 199 } 200 writeKey(sb, depth + 1, "kind"); 201 writeString(sb, s.getKind()); 202 sb.append(",\n"); 203 // Degrade placeholder (GitHub #708): emitted ONLY for a block the 204 // builder skipped under 205 // SemanticIRBuildOptions.withDegradeUnsupportedNestedBlocks(true). 206 // Absent from every other statement, so existing output is byte-for- 207 // byte unchanged. Without it a consumer would see kind=UNANALYZED 208 // with no way to learn what was skipped or where. 209 if (s.getUnanalyzedReason() != null) { 210 writeKey(sb, depth + 1, "unanalyzedReason"); 211 writeDiagnosticInline(sb, s.getUnanalyzedReason()); 212 sb.append(",\n"); 213 } 214 writeKey(sb, depth + 1, "distinct"); 215 sb.append(s.isDistinct() ? "true" : "false"); 216 sb.append(",\n"); 217 writeKey(sb, depth + 1, "setOperator"); 218 writeNullableString(sb, s.getSetOperator() == null 219 ? null : s.getSetOperator().name()); 220 sb.append(",\n"); 221 writeKey(sb, depth + 1, "rowLimit"); 222 writeRowLimit(sb, s.getRowLimit()); 223 sb.append(",\n"); 224 writeKey(sb, depth + 1, "target"); 225 writeTarget(sb, s.getTarget()); 226 sb.append(",\n"); 227 writeKey(sb, depth + 1, "relations"); 228 writeRelations(sb, depth + 1, s.getRelations()); 229 sb.append(",\n"); 230 writeKey(sb, depth + 1, "outputColumns"); 231 writeOutputColumns(sb, depth + 1, s.getOutputColumns()); 232 sb.append(",\n"); 233 writeKey(sb, depth + 1, "returningColumns"); 234 writeOutputColumns(sb, depth + 1, s.getReturningColumns()); 235 sb.append(",\n"); 236 writeKey(sb, depth + 1, "filterColumnRefs"); 237 writeColumnRefArray(sb, depth + 1, s.getFilterColumnRefs()); 238 sb.append(",\n"); 239 writeKey(sb, depth + 1, "joinColumnRefs"); 240 writeColumnRefArray(sb, depth + 1, s.getJoinColumnRefs()); 241 sb.append(",\n"); 242 writeKey(sb, depth + 1, "groupByColumnRefs"); 243 writeColumnRefArray(sb, depth + 1, s.getGroupByColumnRefs()); 244 sb.append(",\n"); 245 writeKey(sb, depth + 1, "havingColumnRefs"); 246 writeColumnRefArray(sb, depth + 1, s.getHavingColumnRefs()); 247 sb.append(",\n"); 248 writeKey(sb, depth + 1, "orderByColumnRefs"); 249 writeColumnRefArray(sb, depth + 1, s.getOrderByColumnRefs()); 250 sb.append(",\n"); 251 writeKey(sb, depth + 1, "distinctOnColumnRefs"); 252 writeColumnRefArray(sb, depth + 1, s.getDistinctOnColumnRefs()); 253 sb.append(",\n"); 254 writeKey(sb, depth + 1, "qualifyColumnRefs"); 255 writeColumnRefArray(sb, depth + 1, s.getQualifyColumnRefs()); 256 // Slice 128: the structured GROUP BY view. Additive key emitted 257 // only when present (i.e. the statement has a GROUP BY), so 258 // GROUP-BY-free goldens stay byte-identical. The flat 259 // groupByColumnRefs above is unchanged. 260 if (!s.getGroupingElements().isEmpty()) { 261 sb.append(",\n"); 262 writeKey(sb, depth + 1, "groupingElements"); 263 writeGroupingElements(sb, depth + 1, s.getGroupingElements()); 264 } 265 // Slice 129: the PIVOT consumed-column refs (FOR + aggregation-arg 266 // columns). Additive key emitted only when present (i.e. the FROM 267 // source is a PIVOT), so non-PIVOT goldens stay byte-identical. 268 if (!s.getPivotColumnRefs().isEmpty()) { 269 sb.append(",\n"); 270 writeKey(sb, depth + 1, "pivotColumnRefs"); 271 writeColumnRefArray(sb, depth + 1, s.getPivotColumnRefs()); 272 } 273 // Slice 172 (S11): the join-analysis facts (GAPs 1/2/4). The whole 274 // block is gated on the program being schema v3 (i.e. some statement 275 // has a join graph or filter predicates), so v1/v2 payloads stay 276 // byte-identical. Within a v3 program, every statement with any facts 277 // — including a scope-only CTE / subquery body — emits its block so 278 // the exported scope tree is complete (S15 review fix). 279 if (joinAnalysisVersion && !s.getJoinAnalysisFacts().isEmpty()) { 280 sb.append(",\n"); 281 writeKey(sb, depth + 1, "joinAnalysis"); 282 writeJoinAnalysis(sb, depth + 1, s); 283 } 284 sb.append("\n"); 285 indent(sb, depth); 286 sb.append("}"); 287 } 288 289 private static boolean statementNeedsJoinAnalysis(StatementGraph s) { 290 return !s.getJoinGraph().isEmpty() 291 || !s.getJoinAnalysisFacts().getFilterPredicates().isEmpty(); 292 } 293 294 private static boolean programNeedsJoinAnalysisVersion(SemanticProgram program) { 295 if (program.getStatements() == null) return false; 296 for (StatementGraph s : program.getStatements()) { 297 if (statementNeedsJoinAnalysis(s)) return true; 298 } 299 return false; 300 } 301 302 private static void writeJoinAnalysis(StringBuilder sb, int depth, StatementGraph s) { 303 JoinAnalysisFacts facts = s.getJoinAnalysisFacts(); 304 sb.append("{\n"); 305 // joinGraph 306 writeKey(sb, depth + 1, "joinGraph"); 307 writeJoinEntities(sb, depth + 1, facts.getJoinGraph().getJoins()); 308 sb.append(",\n"); 309 // filterPredicates 310 writeKey(sb, depth + 1, "filterPredicates"); 311 writePredicateArray(sb, depth + 1, facts.getFilterPredicates()); 312 // queryBlockScope (optional) 313 if (facts.getQueryBlockScope() != null) { 314 sb.append(",\n"); 315 writeKey(sb, depth + 1, "queryBlockScope"); 316 writeQueryBlockScope(sb, facts.getQueryBlockScope()); 317 } 318 sb.append("\n"); 319 indent(sb, depth); 320 sb.append("}"); 321 } 322 323 private static void writeJoinEntities(StringBuilder sb, int depth, List<JoinEntity> joins) { 324 if (joins.isEmpty()) { 325 sb.append("[]"); 326 return; 327 } 328 sb.append("[\n"); 329 for (int i = 0; i < joins.size(); i++) { 330 writeJoinEntity(sb, depth + 1, joins.get(i)); 331 if (i < joins.size() - 1) sb.append(","); 332 sb.append("\n"); 333 } 334 indent(sb, depth); 335 sb.append("]"); 336 } 337 338 private static void writeJoinEntity(StringBuilder sb, int depth, JoinEntity e) { 339 indent(sb, depth); 340 sb.append("{\n"); 341 writeKey(sb, depth + 1, "order"); 342 sb.append(e.getOrder()); 343 sb.append(",\n"); 344 writeKey(sb, depth + 1, "joinType"); 345 writeString(sb, e.getJoinType().name()); 346 sb.append(",\n"); 347 writeKey(sb, depth + 1, "sourceSyntax"); 348 writeString(sb, e.getSourceSyntax().name()); 349 sb.append(",\n"); 350 writeKey(sb, depth + 1, "natural"); 351 sb.append(e.isNatural() ? "true" : "false"); 352 // Lateral marker (CROSS/OUTER APPLY). Emitted only when true so 353 // existing non-lateral join JSON stays byte-identical (mirrors the 354 // conditional conditionText/span emission below). Consumers treat 355 // an absent "lateral" key as false. 356 if (e.isLateral()) { 357 sb.append(",\n"); 358 writeKey(sb, depth + 1, "lateral"); 359 sb.append("true"); 360 } 361 sb.append(",\n"); 362 writeKey(sb, depth + 1, "leftEndpoint"); 363 writeJoinEndpoint(sb, e.getLeftEndpoint()); 364 sb.append(",\n"); 365 writeKey(sb, depth + 1, "rightEndpoint"); 366 writeJoinEndpoint(sb, e.getRightEndpoint()); 367 sb.append(",\n"); 368 writeKey(sb, depth + 1, "usingColumns"); 369 writeStringArrayInline(sb, e.getUsingColumns()); 370 if (e.getSourceSpan() != null) { 371 sb.append(",\n"); 372 writeKey(sb, depth + 1, "span"); 373 writeSourceSpanInline(sb, e.getSourceSpan()); 374 } 375 // Optional source-provenance text, emitted only when the Java value is 376 // non-null. A missing key means that no join-local expression was 377 // written or anchored; it is never encoded as JSON null/empty text and 378 // does not imply that the semantic `conditions` array is empty. For 379 // EXPLICIT joins this is the ON expression; for SEMI joins it can be 380 // the complete EXISTS/IN wrapper (GitHub #711 contract). 381 if (e.getConditionText() != null) { 382 sb.append(",\n"); 383 writeKey(sb, depth + 1, "conditionText"); 384 writeString(sb, e.getConditionText()); 385 } 386 sb.append(",\n"); 387 writeKey(sb, depth + 1, "conditions"); 388 writePredicateArray(sb, depth + 1, e.getConditions()); 389 sb.append("\n"); 390 indent(sb, depth); 391 sb.append("}"); 392 } 393 394 private static void writeJoinEndpoint(StringBuilder sb, JoinEndpoint ep) { 395 sb.append("{"); 396 writeKeyInline(sb, "kind"); 397 writeString(sb, ep.getKind().name()); 398 if (ep.getKind() == JoinEndpointKind.RELATION) { 399 sb.append(", "); 400 writeKeyInline(sb, "alias"); 401 writeString(sb, ep.getAlias()); 402 sb.append(", "); 403 writeKeyInline(sb, "qualifiedName"); 404 writeNullableString(sb, ep.getQualifiedName()); 405 } else if (ep.getKind() == JoinEndpointKind.SUBQUERY) { 406 // R8: predicate-derived semi-join right side. statementIndex 407 // points at the lifted subquery's StatementGraph block; label 408 // is the synthetic name / alias (may be null). 409 sb.append(", "); 410 writeKeyInline(sb, "statementIndex"); 411 sb.append(ep.getStatementIndex()); 412 sb.append(", "); 413 writeKeyInline(sb, "label"); 414 writeNullableString(sb, ep.getAlias()); 415 } else { 416 sb.append(", "); 417 writeKeyInline(sb, "producingJoinOrder"); 418 sb.append(ep.getProducingJoinOrder()); 419 sb.append(", "); 420 writeKeyInline(sb, "contributingAliases"); 421 writeStringArrayInline(sb, ep.getContributingAliases()); 422 } 423 sb.append("}"); 424 } 425 426 private static void writePredicateArray(StringBuilder sb, int depth, List<Predicate> preds) { 427 if (preds.isEmpty()) { 428 sb.append("[]"); 429 return; 430 } 431 sb.append("[\n"); 432 for (int i = 0; i < preds.size(); i++) { 433 indent(sb, depth + 1); 434 writePredicateInline(sb, preds.get(i)); 435 if (i < preds.size() - 1) sb.append(","); 436 sb.append("\n"); 437 } 438 indent(sb, depth); 439 sb.append("]"); 440 } 441 442 private static void writePredicateInline(StringBuilder sb, Predicate p) { 443 sb.append("{"); 444 writeKeyInline(sb, "kind"); 445 writeString(sb, p.getKind().name()); 446 sb.append(", "); 447 writeKeyInline(sb, "operator"); 448 writeNullableString(sb, p.getOperator()); 449 if (p.getSourceSpan() != null) { 450 sb.append(", "); 451 writeKeyInline(sb, "span"); 452 writeSourceSpanInline(sb, p.getSourceSpan()); 453 } 454 sb.append(", "); 455 writeKeyInline(sb, "left"); 456 writeOperandInline(sb, p.getLeftOperand()); 457 if (p.getRightOperand() != null) { 458 sb.append(", "); 459 writeKeyInline(sb, "right"); 460 writeOperandInline(sb, p.getRightOperand()); 461 } 462 sb.append("}"); 463 } 464 465 private static void writeOperandInline(StringBuilder sb, PredicateOperand op) { 466 sb.append("{"); 467 writeKeyInline(sb, "kind"); 468 writeString(sb, op.getKind().name()); 469 if (op.getColumn() != null) { 470 sb.append(", "); 471 writeKeyInline(sb, "column"); 472 writeOperandColumnInline(sb, op.getColumn()); 473 } 474 if (op.getSourceSpan() != null) { 475 sb.append(", "); 476 writeKeyInline(sb, "span"); 477 writeSourceSpanInline(sb, op.getSourceSpan()); 478 } 479 sb.append("}"); 480 } 481 482 private static void writeOperandColumnInline(StringBuilder sb, ColumnRef r) { 483 sb.append("{"); 484 writeKeyInline(sb, "relationAlias"); 485 writeString(sb, r.getRelationAlias()); 486 sb.append(", "); 487 writeKeyInline(sb, "columnName"); 488 writeString(sb, r.getColumnName()); 489 if (r.getResolution() != null) { 490 sb.append(", "); 491 writeKeyInline(sb, "resolution"); 492 sb.append("{"); 493 writeKeyInline(sb, "status"); 494 writeString(sb, r.getResolution().getStatus().name()); 495 if (r.getResolution().getResolvedTableQualifiedName() != null) { 496 sb.append(", "); 497 writeKeyInline(sb, "resolvedTable"); 498 writeString(sb, r.getResolution().getResolvedTableQualifiedName()); 499 } 500 sb.append("}"); 501 } 502 sb.append("}"); 503 } 504 505 private static void writeQueryBlockScope(StringBuilder sb, QueryBlockScope scope) { 506 sb.append("{"); 507 writeKeyInline(sb, "statementIndex"); 508 sb.append(scope.getStatementIndex()); 509 sb.append(", "); 510 writeKeyInline(sb, "parentStatementIndex"); 511 if (scope.getParentStatementIndex() == null) { 512 sb.append("null"); 513 } else { 514 sb.append(scope.getParentStatementIndex().intValue()); 515 } 516 sb.append(", "); 517 writeKeyInline(sb, "scopeKind"); 518 writeString(sb, scope.getScopeKind().name()); 519 sb.append(", "); 520 writeKeyInline(sb, "name"); 521 writeNullableString(sb, scope.getName()); 522 sb.append("}"); 523 } 524 525 /** 526 * Write the complete structured diagnostic attached to an unanalyzed 527 * block. Field order is stable for deterministic golden files. 528 */ 529 private static void writeDiagnosticInline(StringBuilder sb, Diagnostic d) { 530 sb.append("{"); 531 writeKeyInline(sb, "code"); 532 writeString(sb, d.getCode().name()); 533 sb.append(", "); 534 writeKeyInline(sb, "category"); 535 writeString(sb, d.getCategory().name()); 536 sb.append(", "); 537 writeKeyInline(sb, "severity"); 538 writeString(sb, d.getSeverity().name()); 539 sb.append(", "); 540 writeKeyInline(sb, "message"); 541 writeString(sb, d.getMessage()); 542 sb.append(", "); 543 writeKeyInline(sb, "span"); 544 if (d.getSpan() == null) { 545 sb.append("null"); 546 } else { 547 writeSourceSpanInline(sb, d.getSpan()); 548 } 549 sb.append(", "); 550 writeKeyInline(sb, "relatedLocations"); 551 writeRelatedLocationsInline(sb, d.getRelatedLocations()); 552 sb.append(", "); 553 writeKeyInline(sb, "vendorError"); 554 writeVendorErrorInline(sb, d.getVendorError()); 555 sb.append("}"); 556 } 557 558 private static void writeRelatedLocationsInline( 559 StringBuilder sb, List<RelatedLocation> locations) { 560 sb.append("["); 561 for (int i = 0; i < locations.size(); i++) { 562 if (i > 0) sb.append(", "); 563 RelatedLocation location = locations.get(i); 564 sb.append("{"); 565 writeKeyInline(sb, "role"); 566 writeString(sb, location.getRole()); 567 sb.append(", "); 568 writeKeyInline(sb, "message"); 569 writeString(sb, location.getMessage()); 570 sb.append(", "); 571 writeKeyInline(sb, "span"); 572 writeSourceSpanInline(sb, location.getSpan()); 573 sb.append("}"); 574 } 575 sb.append("]"); 576 } 577 578 private static void writeVendorErrorInline(StringBuilder sb, 579 VendorError vendorError) { 580 if (vendorError == null) { 581 sb.append("null"); 582 return; 583 } 584 sb.append("{"); 585 writeKeyInline(sb, "vendor"); 586 writeString(sb, vendorError.getVendor()); 587 sb.append(", "); 588 writeKeyInline(sb, "code"); 589 writeString(sb, vendorError.getCode()); 590 sb.append(", "); 591 writeKeyInline(sb, "title"); 592 writeString(sb, vendorError.getTitle()); 593 sb.append(", "); 594 writeKeyInline(sb, "helpUri"); 595 writeString(sb, vendorError.getHelpUri()); 596 sb.append(", "); 597 writeKeyInline(sb, "matchedProfile"); 598 writeNullableString(sb, vendorError.getMatchedProfile()); 599 sb.append("}"); 600 } 601 602 private static void writeSourceSpanInline(StringBuilder sb, SourceSpan span) { 603 sb.append("{"); 604 writeKeyInline(sb, "startLine"); 605 sb.append(span.getStartLine()); 606 sb.append(", "); 607 writeKeyInline(sb, "startColumn"); 608 sb.append(span.getStartColumn()); 609 sb.append(", "); 610 writeKeyInline(sb, "endLine"); 611 sb.append(span.getEndLine()); 612 sb.append(", "); 613 writeKeyInline(sb, "endColumn"); 614 sb.append(span.getEndColumn()); 615 sb.append("}"); 616 } 617 618 private static void writeStringArrayInline(StringBuilder sb, List<String> values) { 619 sb.append("["); 620 for (int i = 0; i < values.size(); i++) { 621 if (i > 0) sb.append(", "); 622 writeString(sb, values.get(i)); 623 } 624 sb.append("]"); 625 } 626 627 private static void writeGroupingElements(StringBuilder sb, int depth, 628 List<GroupingElement> elems) { 629 if (elems.isEmpty()) { 630 sb.append("[]"); 631 return; 632 } 633 sb.append("[\n"); 634 for (int i = 0; i < elems.size(); i++) { 635 GroupingElement e = elems.get(i); 636 indent(sb, depth + 1); 637 sb.append("{"); 638 writeKeyInline(sb, "kind"); 639 writeString(sb, e.getKind().name()); 640 sb.append(", "); 641 writeKeyInline(sb, "members"); 642 writeColumnRefArrayInline(sb, e.getMembers()); 643 sb.append("}"); 644 if (i < elems.size() - 1) sb.append(","); 645 sb.append("\n"); 646 } 647 indent(sb, depth); 648 sb.append("]"); 649 } 650 651 private static void writeRelations(StringBuilder sb, int depth, List<RelationSource> rels) { 652 if (rels.isEmpty()) { 653 sb.append("[]"); 654 return; 655 } 656 sb.append("[\n"); 657 for (int i = 0; i < rels.size(); i++) { 658 RelationSource r = rels.get(i); 659 indent(sb, depth + 1); 660 sb.append("{"); 661 writeKeyInline(sb, "alias"); 662 writeString(sb, r.getAlias()); 663 sb.append(", "); 664 writeKeyInline(sb, "binding"); 665 writeBinding(sb, r.getBinding()); 666 sb.append("}"); 667 if (i < rels.size() - 1) sb.append(","); 668 sb.append("\n"); 669 } 670 indent(sb, depth); 671 sb.append("]"); 672 } 673 674 private static void writeBinding(StringBuilder sb, RelationBinding b) { 675 sb.append("{"); 676 writeKeyInline(sb, "kind"); 677 writeString(sb, b.getKind().name()); 678 sb.append(", "); 679 writeKeyInline(sb, "qualifiedName"); 680 writeString(sb, b.getQualifiedName()); 681 if (b.getOuterKind() != null) { 682 sb.append(", "); 683 writeKeyInline(sb, "outerKind"); 684 writeString(sb, b.getOuterKind().name()); 685 } 686 sb.append("}"); 687 } 688 689 private static void writeOutputColumns(StringBuilder sb, int depth, List<OutputColumn> cols) { 690 if (cols.isEmpty()) { 691 sb.append("[]"); 692 return; 693 } 694 sb.append("[\n"); 695 for (int i = 0; i < cols.size(); i++) { 696 OutputColumn c = cols.get(i); 697 indent(sb, depth + 1); 698 sb.append("{"); 699 writeKeyInline(sb, "name"); 700 writeString(sb, c.getName()); 701 sb.append(", "); 702 writeKeyInline(sb, "derived"); 703 sb.append(c.isDerived() ? "true" : "false"); 704 sb.append(", "); 705 writeKeyInline(sb, "aggregate"); 706 sb.append(c.isAggregate() ? "true" : "false"); 707 sb.append(", "); 708 writeKeyInline(sb, "window"); 709 writeWindowSpec(sb, c.getWindowSpec()); 710 sb.append(", "); 711 writeKeyInline(sb, "sources"); 712 writeColumnRefArrayInline(sb, c.getSources()); 713 sb.append("}"); 714 if (i < cols.size() - 1) sb.append(","); 715 sb.append("\n"); 716 } 717 indent(sb, depth); 718 sb.append("]"); 719 } 720 721 /** 722 * Slice 13: write the per-output {@code window} field. Emits the JSON 723 * literal {@code null} when {@code spec} is null (slice-8 always-emit 724 * shape-stability rule), or {@code {"partitionRefs": [...], "orderRefs": 725 * [...], "frame": null|{...}}} otherwise. Both inner arrays are always 726 * emitted; one may be empty when only PARTITION BY or only OVER 727 * ORDER BY is present. The {@code frame} key (slice 22) is also 728 * always emitted — null when no frame, an object otherwise. 729 */ 730 private static void writeWindowSpec(StringBuilder sb, WindowSpec spec) { 731 if (spec == null) { 732 sb.append("null"); 733 return; 734 } 735 sb.append("{"); 736 writeKeyInline(sb, "partitionRefs"); 737 writeColumnRefArrayInline(sb, spec.getPartitionRefs()); 738 sb.append(", "); 739 writeKeyInline(sb, "orderRefs"); 740 writeColumnRefArrayInline(sb, spec.getOrderRefs()); 741 sb.append(", "); 742 writeKeyInline(sb, "frame"); 743 writeWindowFrame(sb, spec.getFrame()); 744 sb.append("}"); 745 } 746 747 /** 748 * Slice 22: write the {@code window.frame} field. Emits {@code null} 749 * when no frame, otherwise {@code {"unit": "ROWS|RANGE|GROUPS", 750 * "start": {...}, "end": null|{...}}}. {@code end} is the literal 751 * null when the surface SQL used the unary form 752 * ({@code ROWS UNBOUNDED PRECEDING}). 753 */ 754 private static void writeWindowFrame(StringBuilder sb, WindowFrame frame) { 755 if (frame == null) { 756 sb.append("null"); 757 return; 758 } 759 sb.append("{"); 760 writeKeyInline(sb, "unit"); 761 writeString(sb, frame.getUnit().name()); 762 sb.append(", "); 763 writeKeyInline(sb, "start"); 764 writeFrameBound(sb, frame.getStart()); 765 sb.append(", "); 766 writeKeyInline(sb, "end"); 767 writeFrameBound(sb, frame.getEnd()); 768 sb.append("}"); 769 } 770 771 /** 772 * Slice 22: write a {@link FrameBound}. Emits {@code null} when the 773 * bound itself is null (the {@code end} of a unary frame), otherwise 774 * {@code {"kind": "...", "offsetLiteral": null|"..."}}. 775 * {@code offsetLiteral} is presentation text — it captures the 776 * SQL author's literal spelling and is NOT canonical across 777 * vendors. 778 */ 779 private static void writeFrameBound(StringBuilder sb, FrameBound bound) { 780 if (bound == null) { 781 sb.append("null"); 782 return; 783 } 784 sb.append("{"); 785 writeKeyInline(sb, "kind"); 786 writeString(sb, bound.getKind().name()); 787 sb.append(", "); 788 writeKeyInline(sb, "offsetLiteral"); 789 if (bound.getOffsetLiteral() == null) { 790 sb.append("null"); 791 } else { 792 writeString(sb, bound.getOffsetLiteral()); 793 } 794 sb.append("}"); 795 } 796 797 /** 798 * Slices 70 and 71: write the per-statement {@code rowLimit} field. 799 * Emits the JSON literal {@code null} when {@code rowLimit} is null 800 * (mirrors the slice-12 {@code setOperator} always-emit shape- 801 * stability rule), or 802 * {@code {"kind": "<kind>", "count": "<verbatim>|null", 803 * "offset": "<verbatim>|null"}} otherwise. 804 * 805 * <p>{@code kind} is one of {@code LIMIT}, {@code FETCH_FIRST}, 806 * {@code TOP}, or {@code OFFSET_FETCH}. {@code count} is normally 807 * a string but may be the JSON literal {@code null} when 808 * {@code kind == "OFFSET_FETCH"} and the SQL author wrote 809 * offset-only (e.g. PG {@code OFFSET 5} or Oracle 810 * {@code OFFSET 5 ROWS} without {@code FETCH NEXT}). {@code offset} 811 * is the JSON literal {@code null} when no offset is present. 812 */ 813 private static void writeRowLimit(StringBuilder sb, RowLimit rl) { 814 if (rl == null) { 815 sb.append("null"); 816 return; 817 } 818 sb.append("{"); 819 writeKeyInline(sb, "kind"); 820 writeString(sb, rl.getKind().name()); 821 sb.append(", "); 822 writeKeyInline(sb, "count"); 823 if (rl.getCount() == null) { 824 sb.append("null"); 825 } else { 826 writeString(sb, rl.getCount()); 827 } 828 sb.append(", "); 829 writeKeyInline(sb, "offset"); 830 if (rl.getOffset() == null) { 831 sb.append("null"); 832 } else { 833 writeString(sb, rl.getOffset()); 834 } 835 sb.append("}"); 836 } 837 838 /** 839 * Slice 78: write the per-statement {@code target} field for 840 * {@code INSERT INTO target SELECT ...} statements. Emits the JSON 841 * literal {@code null} when {@code target} is null (mirrors the 842 * always-emit shape-stability rule used by {@code setOperator} / 843 * {@code rowLimit}), or 844 * {@code {"table": "<qualified>", "columns": ["c1", "c2", ...]}} 845 * otherwise. The {@code columns} list is empty when the SQL author 846 * omitted the INSERT column list — consumers should fall back to the 847 * source SELECT's positional output names for the per-column lineage 848 * mapping. 849 */ 850 private static void writeTarget(StringBuilder sb, TargetRelation t) { 851 if (t == null) { 852 sb.append("null"); 853 return; 854 } 855 sb.append("{"); 856 writeKeyInline(sb, "table"); 857 writeString(sb, t.getBinding().getQualifiedName()); 858 sb.append(", "); 859 writeKeyInline(sb, "columns"); 860 sb.append("["); 861 List<String> cols = t.getColumns(); 862 for (int i = 0; i < cols.size(); i++) { 863 if (i > 0) sb.append(", "); 864 writeString(sb, cols.get(i)); 865 } 866 sb.append("]"); 867 sb.append("}"); 868 } 869 870 private static void writeColumnRefArray(StringBuilder sb, int depth, List<ColumnRef> refs) { 871 if (refs.isEmpty()) { 872 sb.append("[]"); 873 return; 874 } 875 sb.append("[\n"); 876 for (int i = 0; i < refs.size(); i++) { 877 indent(sb, depth + 1); 878 writeColumnRefInline(sb, refs.get(i)); 879 if (i < refs.size() - 1) sb.append(","); 880 sb.append("\n"); 881 } 882 indent(sb, depth); 883 sb.append("]"); 884 } 885 886 private static void writeColumnRefArrayInline(StringBuilder sb, List<ColumnRef> refs) { 887 sb.append("["); 888 for (int i = 0; i < refs.size(); i++) { 889 if (i > 0) sb.append(", "); 890 writeColumnRefInline(sb, refs.get(i)); 891 } 892 sb.append("]"); 893 } 894 895 private static void writeColumnRefInline(StringBuilder sb, ColumnRef r) { 896 sb.append("{"); 897 writeKeyInline(sb, "relationAlias"); 898 writeString(sb, r.getRelationAlias()); 899 sb.append(", "); 900 writeKeyInline(sb, "columnName"); 901 writeString(sb, r.getColumnName()); 902 if (r.getStructuredPath() != null) { 903 sb.append(", "); 904 writeKeyInline(sb, "path"); 905 writeStructuredPathInline(sb, r.getStructuredPath()); 906 } 907 sb.append("}"); 908 } 909 910 private static void writeStructuredPathInline(StringBuilder sb, StructuredColumnPath p) { 911 sb.append("{"); 912 writeKeyInline(sb, "rootColumn"); 913 writeString(sb, p.getRootColumn()); 914 sb.append(", "); 915 writeKeyInline(sb, "segments"); 916 sb.append("["); 917 List<StructuredPathSegment> segs = p.getSegments(); 918 for (int i = 0; i < segs.size(); i++) { 919 if (i > 0) sb.append(", "); 920 writeStructuredSegmentInline(sb, segs.get(i)); 921 } 922 sb.append("], "); 923 writeKeyInline(sb, "display"); 924 writeString(sb, p.toDisplayString()); 925 sb.append("}"); 926 } 927 928 private static void writeStructuredSegmentInline(StringBuilder sb, StructuredPathSegment seg) { 929 sb.append("{"); 930 writeKeyInline(sb, "kind"); 931 writeString(sb, seg.getKind().name()); 932 if (seg.getName() != null) { 933 sb.append(", "); 934 writeKeyInline(sb, "name"); 935 writeString(sb, seg.getName()); 936 } 937 sb.append("}"); 938 } 939 940 private static void writeLineage(StringBuilder sb, int depth, List<LineageEdge> edges) { 941 if (edges.isEmpty()) { 942 sb.append("[]"); 943 return; 944 } 945 sb.append("[\n"); 946 for (int i = 0; i < edges.size(); i++) { 947 indent(sb, depth + 1); 948 writeLineageEdgeInline(sb, edges.get(i)); 949 if (i < edges.size() - 1) sb.append(","); 950 sb.append("\n"); 951 } 952 indent(sb, depth); 953 sb.append("]"); 954 } 955 956 private static void writeLineageEdgeInline(StringBuilder sb, LineageEdge e) { 957 sb.append("{"); 958 writeKeyInline(sb, "from"); 959 writeLineageRefInline(sb, e.getFrom()); 960 sb.append(", "); 961 writeKeyInline(sb, "to"); 962 writeLineageRefInline(sb, e.getTo()); 963 sb.append("}"); 964 } 965 966 private static void writeLineageRefInline(StringBuilder sb, LineageRef ref) { 967 sb.append("{"); 968 writeKeyInline(sb, "kind"); 969 writeString(sb, ref.getKind().name()); 970 switch (ref.getKind()) { 971 case STATEMENT_OUTPUT: 972 sb.append(", "); 973 writeKeyInline(sb, "statementIndex"); 974 sb.append(ref.getStatementIndex()); 975 sb.append(", "); 976 writeKeyInline(sb, "outputName"); 977 writeString(sb, ref.getOutputName()); 978 break; 979 case TABLE_COLUMN: 980 sb.append(", "); 981 writeKeyInline(sb, "qualifiedName"); 982 writeString(sb, ref.getQualifiedName()); 983 sb.append(", "); 984 writeKeyInline(sb, "columnName"); 985 writeString(sb, ref.getColumnName()); 986 break; 987 } 988 if (ref.getStructuredPath() != null) { 989 sb.append(", "); 990 writeKeyInline(sb, "path"); 991 writeStructuredPathInline(sb, ref.getStructuredPath()); 992 } 993 sb.append("}"); 994 } 995 996 /** 997 * Returns true when any reference reachable from the program carries 998 * a non-null {@link StructuredColumnPath}, so the schema version must 999 * be bumped to {@link #SCHEMA_VERSION_STRUCTURED}. 1000 */ 1001 private static boolean programNeedsStructuredVersion(SemanticProgram program) { 1002 if (program.getLineage() != null) { 1003 for (LineageEdge e : program.getLineage()) { 1004 if (e.getFrom() != null && e.getFrom().getStructuredPath() != null) return true; 1005 if (e.getTo() != null && e.getTo().getStructuredPath() != null) return true; 1006 } 1007 } 1008 if (program.getStatements() != null) { 1009 for (StatementGraph s : program.getStatements()) { 1010 if (s.getOutputColumns() != null) { 1011 for (OutputColumn oc : s.getOutputColumns()) { 1012 if (oc.getSources() == null) continue; 1013 for (ColumnRef r : oc.getSources()) { 1014 if (r.getStructuredPath() != null) return true; 1015 } 1016 } 1017 } 1018 } 1019 } 1020 return false; 1021 } 1022 1023 private static void writeKey(StringBuilder sb, int depth, String key) { 1024 indent(sb, depth); 1025 sb.append('"').append(escape(key)).append("\": "); 1026 } 1027 1028 private static void writeKeyInline(StringBuilder sb, String key) { 1029 sb.append('"').append(escape(key)).append("\": "); 1030 } 1031 1032 private static void writeString(StringBuilder sb, String value) { 1033 sb.append('"').append(escape(value)).append('"'); 1034 } 1035 1036 /** 1037 * Write a quoted string when {@code value} is non-null, or the JSON 1038 * literal {@code null} when it is. Used for nullable scalar fields 1039 * like {@code setOperator} (slice 12) where the absence of a value 1040 * is itself meaningful. 1041 */ 1042 private static void writeNullableString(StringBuilder sb, String value) { 1043 if (value == null) { 1044 sb.append("null"); 1045 } else { 1046 sb.append('"').append(escape(value)).append('"'); 1047 } 1048 } 1049 1050 private static void indent(StringBuilder sb, int depth) { 1051 for (int i = 0; i < depth; i++) sb.append(INDENT); 1052 } 1053 1054 private static String escape(String s) { 1055 StringBuilder out = new StringBuilder(s.length() + 2); 1056 for (int i = 0; i < s.length(); i++) { 1057 char c = s.charAt(i); 1058 switch (c) { 1059 case '"': out.append("\\\""); break; 1060 case '\\': out.append("\\\\"); break; 1061 case '\n': out.append("\\n"); break; 1062 case '\r': out.append("\\r"); break; 1063 case '\t': out.append("\\t"); break; 1064 case '\b': out.append("\\b"); break; 1065 case '\f': out.append("\\f"); break; 1066 default: 1067 if (c < 0x20) { 1068 out.append(String.format("\\u%04x", (int) c)); 1069 } else { 1070 out.append(c); 1071 } 1072 } 1073 } 1074 return out.toString(); 1075 } 1076}