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