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