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}