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}