001package gudusoft.gsqlparser.lineage.v2.producer;
002
003import java.util.ArrayList;
004import java.util.Collections;
005import java.util.List;
006
007import gudusoft.gsqlparser.dlineage.DataFlowAnalyzer;
008import gudusoft.gsqlparser.dlineage.dataflow.metadata.MetadataReader;
009import gudusoft.gsqlparser.dlineage.dataflow.model.ModelBindingManager;
010import gudusoft.gsqlparser.dlineage.dataflow.model.Option;
011import gudusoft.gsqlparser.dlineage.dataflow.model.SqlInfo;
012import gudusoft.gsqlparser.dlineage.dataflow.model.SqllrObservations;
013
014/**
015 * Produces a SQL Lineage Representation (SQL-LR) L1 document from SQL inputs with GSP's lineage engine
016 * (plan {@code docs/designs/data-lineage/06-sqllr-m2-producer-plan-zh.md}).
017 *
018 * <p>The producer runs its own analysis with fixed options ({@link ProducerOptions}) and an observation recorder,
019 * builds an ExplainGraph from what the analysis observed, folds it with the shared fold kernel of
020 * {@code lineage.v2.fold}, and writes the Facts, Occurrences and writes of the L1 core. It never changes what a
021 * legacy {@code DataFlowAnalyzer} run of the caller outputs.</p>
022 *
023 * <p>The analysis, its projection ({@code lineage.v2.shape}) and {@code dispose()} run on the calling thread; the
024 * document is built from the projection after {@code dispose()}, and could be built on any thread (plan 09, slice
025 * 1b). The result holds plain values.</p>
026 *
027 * @since 4.2.17
028 */
029public final class SqllrProducer {
030
031    private SqllrProducer() {
032    }
033
034    /**
035     * Analyses the inputs together (DDL and views in one input are visible to the others, in input order) and
036     * returns the document.
037     *
038     * @throws SqllrProducerException when the inputs or the configuration are refused
039     */
040    public static ProducerResult produce(List<SqllrInput> inputs, ProducerConfig config) {
041        return produce(inputs, config, null);
042    }
043
044    /** Tests only: {@code prepare} sees the recorder before the analysis runs (failure injection). */
045    static ProducerResult produce(List<SqllrInput> inputs, ProducerConfig config,
046            java.util.function.Consumer<SqllrObservations> prepare) {
047        return produce(inputs, config, prepare, null);
048    }
049
050    /**
051     * What the last analysis of a production saw (plan 09, Section 5.4), filled by {@link #produce(List,
052     * ProducerConfig, java.util.function.Consumer, Capture)} for the correctness runner and the coverage probe. It
053     * holds plain values only, so it stays usable after {@code dispose()} and on another thread.
054     */
055    static final class Capture {
056        private final List<String> inputTexts = new ArrayList<String>();
057        private final List<String> analysedTexts = new ArrayList<String>();
058        private final List<int[]> blanked = new ArrayList<int[]>();
059        private int analyses;
060        private String legacyXml;
061        private String legacyJson;
062        private final java.util.Map<Long, Long> relationOwners = new java.util.TreeMap<Long, Long>();
063        private final java.util.Map<Long, String> unitTexts = new java.util.LinkedHashMap<Long, String>();
064        private ProducerResult result;
065        private gudusoft.gsqlparser.dlineage.dataflow.model.SqllrSnapshot snapshot;
066        private String snapshotFailure;
067        private final java.util.Map<Long, String> ledgerRelations = new java.util.TreeMap<Long, String>();
068        private final java.util.Map<Long, String> ledgerElementEvents = new java.util.TreeMap<Long, String>();
069        private final java.util.Map<Long, String> ledgerReferenceEvents = new java.util.TreeMap<Long, String>();
070        private final java.util.Map<Long, Long> ledgerRelationStatements = new java.util.TreeMap<Long, Long>();
071        private final java.util.Map<Long, Long> relationPublishers = new java.util.TreeMap<Long, Long>();
072        private final java.util.Set<Long> ledgerGraphed = new java.util.TreeSet<Long>();
073        private gudusoft.gsqlparser.lineage.v2.shape.Shape shape;
074        /** Tests only: run on the analysis thread before dispose, with the live objects still at hand. */
075        java.util.function.Consumer<gudusoft.gsqlparser.lineage.v2.shape.ShapeBuilder> shapeCheck;
076        /** Tests only: build the document on a thread of its own (it reads the shape, never the analysis). */
077        boolean buildOnAnotherThread;
078
079        /** The shape the document was built from (plan 09, slice 1b). */
080        gudusoft.gsqlparser.lineage.v2.shape.Shape getShape() {
081            return shape;
082        }
083        private final java.util.Map<Long, List<Long>> ledgerEventEdges = new java.util.TreeMap<Long, List<Long>>();
084        private final java.util.Map<Long, List<Long>> ledgerEventDiagnostics =
085                new java.util.TreeMap<Long, List<Long>>();
086        private final java.util.Map<Long, List<Long>> ledgerEventNodes = new java.util.TreeMap<Long, List<Long>>();
087
088        /** Shadow ledger: event sequence -> nodes it stands for without an edge (a merged hop's field). */
089        java.util.Map<Long, List<Long>> getLedgerEventNodes() {
090            return Collections.unmodifiableMap(ledgerEventNodes);
091        }
092
093        /** Shadow ledger: event sequence -> refs of the micro-edges published for it. */
094        java.util.Map<Long, List<Long>> getLedgerEventEdges() {
095            return Collections.unmodifiableMap(ledgerEventEdges);
096        }
097
098        /** Shadow ledger: event sequence -> diagnostics of the UNKNOWN_INPUTs published for it. */
099        java.util.Map<Long, List<Long>> getLedgerEventDiagnostics() {
100            return Collections.unmodifiableMap(ledgerEventDiagnostics);
101        }
102
103        /** Shadow ledger: the refs of the statements a statement graph was built for. */
104        java.util.Set<Long> getLedgerGraphed() {
105            return Collections.unmodifiableSet(ledgerGraphed);
106        }
107
108        /** The value snapshot of the last analysis (plan 09, Section 3.1), or null when it could not be built. */
109        gudusoft.gsqlparser.dlineage.dataflow.model.SqllrSnapshot getSnapshot() {
110            return snapshot;
111        }
112
113        String getSnapshotFailure() {
114            return snapshotFailure;
115        }
116
117        /** Shadow ledger (plan 09, slice 1a): relationship model id -> how the claiming statement graph handled it. */
118        java.util.Map<Long, String> getLedgerRelations() {
119            return Collections.unmodifiableMap(ledgerRelations);
120        }
121
122        /** Shadow ledger: relationship model id -> the statement whose graph claimed it. */
123        java.util.Map<Long, Long> getLedgerRelationStatements() {
124            return Collections.unmodifiableMap(ledgerRelationStatements);
125        }
126
127        /** Shadow ledger: element event sequence -> outcome. */
128        java.util.Map<Long, String> getLedgerElementEvents() {
129            return Collections.unmodifiableMap(ledgerElementEvents);
130        }
131
132        /** Shadow ledger: reference event sequence -> outcome. */
133        java.util.Map<Long, String> getLedgerReferenceEvents() {
134            return Collections.unmodifiableMap(ledgerReferenceEvents);
135        }
136
137        /**
138         * Shadow ledger: frozen relationship model id -> the ref of the published statement its target event maps to
139         * (the event {@code StatementGraph.valueEdges} reads, through {@code DocumentBuilder.publisher}), or -1 when no
140         * published statement encloses it; absent when the relationship has no recorded target.
141         */
142        java.util.Map<Long, Long> getRelationPublishers() {
143            return Collections.unmodifiableMap(relationPublishers);
144        }
145
146        /** Source unit ref -> the decoded text its positions are offsets into (inputs and derived units). */
147        java.util.Map<Long, String> getUnitTexts() {
148            return Collections.unmodifiableMap(unitTexts);
149        }
150
151        /**
152         * Legacy relationship id -> the ref of the published statement that owns its target (the statement the
153         * analysis recorded for the relationship, or the nearest published one enclosing it). A relationship with no
154         * recorded target or no published owner is absent. This is the recorded ownership the coverage census uses
155         * (plan 08, Section 14), never a position guess.
156         */
157        java.util.Map<Long, Long> getRelationOwners() {
158            return Collections.unmodifiableMap(relationOwners);
159        }
160
161        /** The texts of the inputs, in input order. */
162        List<String> getInputTexts() {
163            return Collections.unmodifiableList(inputTexts);
164        }
165
166        /** The texts of the last analysis: the input texts with every statement that did not parse blanked. */
167        List<String> getAnalysedTexts() {
168            return Collections.unmodifiableList(analysedTexts);
169        }
170
171        /** {unit index, UTF-16 start, UTF-16 end} of each blanked statement, in the order they were found. */
172        List<int[]> getBlanked() {
173            return Collections.unmodifiableList(blanked);
174        }
175
176        int getAnalyses() {
177            return analyses;
178        }
179
180        /** The legacy XML of the last analysis, serialized on the analysis thread before {@code dispose()}. */
181        String getLegacyXml() {
182            return legacyXml;
183        }
184
185        /** The legacy JSON model of the last analysis, serialized like {@link #getLegacyXml()}. */
186        String getLegacyJson() {
187            return legacyJson;
188        }
189
190        ProducerResult getResult() {
191            return result;
192        }
193    }
194
195    /** {@code capture}, when given, is filled from the analysis that published the document. */
196    static ProducerResult produce(List<SqllrInput> inputs, ProducerConfig config,
197            java.util.function.Consumer<SqllrObservations> prepare, Capture capture) {
198        if (inputs == null || inputs.isEmpty()) {
199            throw new SqllrProducerException("PRODUCER_INVALID_INPUT", "no input");
200        }
201        if (config == null) {
202            throw new IllegalArgumentException("no config");
203        }
204        Option option = ProducerOptions.option(config);
205        String[] texts = new String[inputs.size()];
206        for (int i = 0; i < texts.length; i++) {
207            String text = inputs.get(i).getText();
208            if (MetadataReader.isMetadata(text) || MetadataReader.isGrabit(text) || MetadataReader.isSqlflow(text)
209                    || MetadataReader.isSqlflowSharded(text)) {
210                // the analyzer would read the input as a metadata export, not as SQL text the document can cite
211                throw new SqllrProducerException("PRODUCER_INVALID_INPUT", "input " + i
212                        + " is a metadata export, not SQL text; give metadata through ProducerConfig.metadata");
213            }
214            texts[i] = text;
215        }
216        // A statement with a syntax error leaves its whole unit unresolved; analyse again with it blanked (plan 06,
217        // Section 3.8). The rerun happens only when a statement failed to parse.
218        List<ParseFailures.Failed> failed = new ArrayList<ParseFailures.Failed>();
219        gudusoft.gsqlparser.lineage.v2.shape.Shape projected = null;
220        IdentityPolicy policy = null;
221        int analyses = 0;
222        for (int analysis = 1; projected == null; analysis++) {
223            DataFlowAnalyzer analyzer = new DataFlowAnalyzer(infos(inputs, texts), option);
224            SqllrObservations observations = new SqllrObservations(true);
225            try {
226                clearThreadState();
227                // Always a private catalog: without one the analyzer reads the DDL of every input into its catalog
228                // before any statement is resolved, so a CREATE after a statement would decide how the statement's
229                // names resolve. The parse registers each declaration where it stands instead.
230                analyzer.setSqlEnv(config.getMetadata() != null
231                        ? MetadataSnapshot.copy(config.getMetadata().getCatalog(), config)
232                        : MetadataSnapshot.empty(config.getVendor()));
233                analyzer.setSqllrObservations(observations);
234                if (prepare != null) {
235                    prepare.accept(observations);
236                }
237                // what the producer reads is projected when the analysis freezes (plan 09, slice 1b): the recorder,
238                // and the producer's own copy of the supplied catalog
239                final gudusoft.gsqlparser.lineage.v2.shape.ShapeBuilder[] shape =
240                        new gudusoft.gsqlparser.lineage.v2.shape.ShapeBuilder[1];
241                final gudusoft.gsqlparser.sqlenv.TSQLEnv supplied = config.getMetadata() == null ? null
242                        : MetadataSnapshot.copy(config.getMetadata().getCatalog(), config);
243                observations.setFreezeHook(o -> shape[0] =
244                        gudusoft.gsqlparser.lineage.v2.shape.ShapeBuilder.freeze(o, supplied));
245                analyzer.generateDataFlow();
246                checkTexts(texts, observations);
247                if (shape[0] == null) {
248                    throw new IllegalStateException("SQL-LR: the analysis was not projected: "
249                            + observations.getFreezeHookFailure(), observations.getFreezeHookFailure());
250                }
251                List<ParseFailures.Failed> found = analysis < ParseFailures.MAX_ANALYSES
252                        ? ParseFailures.collect(shape[0].shape().observations(), texts)
253                        : Collections.<ParseFailures.Failed>emptyList();
254                if (found.isEmpty()) {
255                    // the renderings are taken now, after the analysis and before dispose, as the producer took them
256                    boolean keep = capture != null && capture.shapeCheck != null;
257                    projected = shape[0].complete(keep);
258                    policy = IdentityPolicy.of(config.getVendor(), analyzer.getIdentifierProfile());
259                    analyses = analysis;
260                    if (capture != null) {
261                        capture(capture, inputs, texts, failed, analysis, analyzer, config, observations);
262                        capture.shape = projected;
263                        if (keep) {
264                            capture.shapeCheck.accept(shape[0]);
265                        }
266                    }
267                    shape[0].release();
268                } else {
269                    failed.addAll(found);
270                    texts = ParseFailures.blank(texts, found);
271                }
272            } finally {
273                clearThreadState();
274                observations.close();
275                analyzer.dispose();
276            }
277        }
278        // the document is built from the shape alone: the analysis is disposed
279        DocumentBuilder builder = new DocumentBuilder(config, policy, inputs, projected, failed);
280        if (analyses > 1) {
281            builder.notes().add("analysed " + analyses + " times: " + failed.size()
282                    + " statement(s) that did not parse were blanked");
283        }
284        ProducerResult result = capture != null && capture.buildOnAnotherThread ? buildElsewhere(builder)
285                : builder.build();
286        if (capture != null) {
287            capture.result = result;
288            owners(capture, projected.observations(), builder);
289        }
290        return result;
291    }
292
293    /** Tests only: {@link DocumentBuilder#build()} on a thread of its own, its failure rethrown here. */
294    private static ProducerResult buildElsewhere(final DocumentBuilder builder) {
295        final ProducerResult[] result = new ProducerResult[1];
296        final Throwable[] failure = new Throwable[1];
297        Thread t = new Thread(() -> {
298            try {
299                result[0] = builder.build();
300            } catch (Throwable e) {
301                failure[0] = e;
302            }
303        }, "sqllr-document");
304        t.start();
305        try {
306            t.join();
307        } catch (InterruptedException e) {
308            Thread.currentThread().interrupt();
309            throw new IllegalStateException("SQL-LR: interrupted while the document was built", e);
310        }
311        if (failure[0] instanceof RuntimeException) {
312            throw (RuntimeException) failure[0];
313        }
314        if (failure[0] instanceof Error) {
315            throw (Error) failure[0];
316        }
317        return result[0];
318    }
319
320    /** Runs on the analysis thread, before {@code dispose()}: the legacy model still has its thread context. */
321    private static void capture(Capture capture, List<SqllrInput> inputs, String[] texts,
322            List<ParseFailures.Failed> failed, int analysis, DataFlowAnalyzer analyzer, ProducerConfig config,
323            SqllrObservations observations) {
324        for (SqllrInput input : inputs) {
325            capture.inputTexts.add(input.getText());
326        }
327        Collections.addAll(capture.analysedTexts, texts);
328        for (ParseFailures.Failed f : failed) {
329            capture.blanked.add(new int[] {f.getUnitIndex(), f.getStart(), f.getEnd()});
330        }
331        capture.analyses = analysis;
332        gudusoft.gsqlparser.dlineage.dataflow.model.xml.dataflow legacy = analyzer.getDataFlow();
333        if (legacy != null) {
334            try {
335                capture.legacyXml = gudusoft.gsqlparser.dlineage.util.XML2Model.saveXML(legacy);
336            } catch (Exception e) {
337                throw new IllegalStateException("SQL-LR capture: the legacy model did not serialize", e);
338            }
339            capture.legacyJson = gudusoft.gsqlparser.util.json.JSON.toJSONString(
340                    DataFlowAnalyzer.getSqlflowJSONModel(legacy, config.getVendor()));
341        }
342        capture.snapshot = observations.getSnapshot();
343        capture.snapshotFailure = observations.getSnapshotFailure();
344    }
345
346    /** What the builder decided, read from the shape it was built from. */
347    private static void owners(Capture capture, gudusoft.gsqlparser.lineage.v2.shape.SqllrObservations projected,
348            DocumentBuilder builder) {
349        capture.unitTexts.putAll(builder.unitTexts);
350        capture.ledgerRelations.putAll(builder.ledger().getRelations());
351        capture.ledgerRelationStatements.putAll(builder.ledger().getRelationStatements());
352        capture.ledgerElementEvents.putAll(builder.ledger().getElementEvents());
353        capture.ledgerReferenceEvents.putAll(builder.ledger().getReferenceEvents());
354        capture.ledgerGraphed.addAll(builder.ledger().getGraphed());
355        capture.ledgerEventEdges.putAll(builder.ledger().getEventEdges());
356        capture.ledgerEventDiagnostics.putAll(builder.ledger().getEventDiagnostics());
357        capture.ledgerEventNodes.putAll(builder.ledger().getEventNodes());
358        // the builder knows the mirrors (plan 09, slice 1b)
359        if (projected.getFrozenRelations() != null) {
360            for (gudusoft.gsqlparser.lineage.v2.shape.SqllrObservations.FrozenRelation f
361                    : projected.getFrozenRelations()) {
362                gudusoft.gsqlparser.lineage.v2.shape.SqllrObservations.ElementRecord target =
363                        builder.targetOf(f.getRelation());
364                if (target != null) {
365                    DocumentBuilder.Stmt owner = builder.publisher(target.getStatement());
366                    capture.relationPublishers.put(Long.valueOf(f.getRelation().getId()),
367                            Long.valueOf(owner == null ? -1 : owner.ref));
368                }
369            }
370        }
371        for (gudusoft.gsqlparser.lineage.v2.shape.SqllrObservations.ElementRecord record : projected.getElements()) {
372            if (!record.isTarget() || record.getRelation() == null) {
373                continue;
374            }
375            DocumentBuilder.Stmt owner = builder.publisher(record.getStatement());
376            if (owner != null && !capture.relationOwners.containsKey(record.getRelation().getId())) {
377                capture.relationOwners.put(record.getRelation().getId(), owner.ref);
378            }
379        }
380    }
381
382    private static SqlInfo[] infos(List<SqllrInput> inputs, String[] texts) {
383        SqlInfo[] infos = new SqlInfo[texts.length];
384        for (int i = 0; i < infos.length; i++) {
385            SqllrInput input = inputs.get(i);
386            SqlInfo info = new SqlInfo();
387            info.setSql(texts[i]);
388            // The analyzer reads the file a SqlInfo names when its text is empty or white space only (an empty input,
389            // or a unit whose statements were all blanked). A unit is its text: its logical name is an identity, never
390            // a path to read, so such a unit gets no name (plan 09, slice 0).
391            info.setFileName(gudusoft.gsqlparser.util.SQLUtil.isEmpty(texts[i]) ? null
392                    : input.getLogicalName() != null ? input.getLogicalName() : "input-" + i);
393            infos[i] = info;
394        }
395        return infos;
396    }
397
398    /**
399     * Analysis state that {@code ModelBindingManager.remove()} leaves on the thread (dlineage CLAUDE.md, Concurrency):
400     * an earlier analysis on this thread, or the previous analysis of this production, must not reach this one.
401     */
402    private static void clearThreadState() {
403        ModelBindingManager.removeGlobalOraclePackage();
404        ModelBindingManager.setSyntheticStmtContext(false);
405        if (ModelBindingManager.get() != null) {
406            ModelBindingManager.removeGlobalProcedure();
407        }
408    }
409
410    /** Every published position is an offset into the input text: the analysis must have parsed exactly that text. */
411    private static void checkTexts(String[] texts, SqllrObservations observations) {
412        for (SqllrObservations.UnitRecord unit : observations.getUnits()) {
413            int i = unit.getUnitIndex();
414            if (i < 0 || i >= texts.length || !texts[i].equals(unit.getParsedText())) {
415                throw new IllegalStateException("SQL-LR: the analysis parsed a text that is not input " + i);
416            }
417        }
418    }
419}