001package gudusoft.gsqlparser.lineage2.api;
002
003import gudusoft.gsqlparser.EDbVendor;
004import gudusoft.gsqlparser.EResolverType;
005import gudusoft.gsqlparser.ESqlStatementType;
006import gudusoft.gsqlparser.TCustomSqlStatement;
007import gudusoft.gsqlparser.TGSqlParser;
008import gudusoft.gsqlparser.TSourceToken;
009import gudusoft.gsqlparser.TStatementList;
010import gudusoft.gsqlparser.ir.semantic.DiagnosticCode;
011import gudusoft.gsqlparser.ir.semantic.catalog.Catalog;
012import gudusoft.gsqlparser.lineage2.contract.ContractDiagnostic;
013import gudusoft.gsqlparser.lineage2.contract.ContractEdge;
014import gudusoft.gsqlparser.lineage2.contract.ContractStatement;
015import gudusoft.gsqlparser.lineage2.contract.ContractVersions;
016import gudusoft.gsqlparser.lineage2.contract.DiagnosticSeverity;
017import gudusoft.gsqlparser.lineage2.contract.FingerprintV1;
018import gudusoft.gsqlparser.lineage2.contract.LineageDocument;
019import gudusoft.gsqlparser.lineage2.contract.SourceLocation;
020import gudusoft.gsqlparser.lineage2.contract.TransformGraph;
021import gudusoft.gsqlparser.lineage2.engine.StatementLineageExtractor;
022import gudusoft.gsqlparser.stmt.TSelectSqlStatement;
023
024import java.io.File;
025import java.io.IOException;
026import java.nio.charset.StandardCharsets;
027import java.nio.file.Files;
028import java.util.ArrayList;
029import java.util.Collections;
030import java.util.List;
031
032/**
033 * lineage2 engine entry point (lineage2-execution-plan.md §3): analyzes one
034 * <b>unit</b> (a SQL file or script) and produces the uniform contract
035 * document (lineage2-contract.md).
036 *
037 * <p>The document carries the statement inventory — 1-based
038 * {@code statementIndex} in source order, contract {@code kind},
039 * FINGERPRINT-V1 {@code queryFingerprint}, absolute {@code sourceLocation}
040 * (1-based, half-open, trailing statement separator excluded) — plus
041 * statement-level degrade [D-09]: a statement that fails to parse yields a
042 * {@code PARSE_FAILED} diagnostic on its own entry and the remaining
043 * statements are unaffected; the engine never throws for malformed SQL.
044 * Edge extraction is per statement kind: {@code SELECT} statements flow
045 * through {@link StatementLineageExtractor} (US-021); the remaining kinds
046 * land with their stories (US-022+), so {@code transformGraphs} is still
047 * empty.
048 *
049 * <p>The {@code artifactLocator} is bound here, at the API boundary where
050 * the unit is known: from {@link LineageConfig#getArtifactLocator()} for
051 * text input, or the file's base name for the {@link File} overload (the
052 * config value wins when non-empty).
053 *
054 * <p>The {@link Catalog} parameter feeds the semantic gate and the
055 * endpoint {@code resolutionStatus} computation; the full BindingGate
056 * (AMBIGUOUS / PARSER_UNSUPPORTED facts) arrives with US-025.
057 *
058 * <p>This class is stateless and its static methods are safe to call from
059 * multiple threads.
060 *
061 * <p>Internal API: not part of the public gsqlparser surface.
062 */
063public final class LineageAnalyzer {
064
065    /** Gold-fixture-pinned degrade message (GF-034a). */
066    private static final String PARSE_FAILED_MESSAGE =
067            "statement could not be parsed; no lineage extracted";
068
069    private LineageAnalyzer() {
070        // utility — no instances
071    }
072
073    /**
074     * Analyze a unit supplied as text.
075     *
076     * @param unitText non-null SQL text of the whole unit
077     * @param vendor   non-null vendor enum
078     * @param catalog  optional catalog snapshot; may be {@code null}
079     * @param config   optional configuration; {@code null} means
080     *                 {@link LineageConfig#defaults()}
081     * @return the contract document wrapped in a {@link LineageResult};
082     *         never throws for malformed SQL (runtime exceptions signal
083     *         engine bugs, per contract §2)
084     */
085    public static LineageResult analyze(String unitText, EDbVendor vendor,
086                                        Catalog catalog, LineageConfig config) {
087        if (unitText == null) {
088            throw new IllegalArgumentException("unitText must not be null");
089        }
090        if (vendor == null) {
091            throw new IllegalArgumentException("vendor must not be null");
092        }
093        LineageConfig effective = (config == null)
094                ? LineageConfig.defaults() : config;
095        return analyzeUnit(unitText, vendor, catalog, effective,
096                effective.getArtifactLocator());
097    }
098
099    /**
100     * Analyze a unit supplied as a file (read as UTF-8). The
101     * {@code artifactLocator} defaults to the file's base name; a non-empty
102     * {@link LineageConfig#getArtifactLocator()} overrides it.
103     *
104     * @throws IOException when the file cannot be read — I/O failures are
105     *                     the caller's concern, unlike SQL-level failures
106     *                     which degrade into diagnostics
107     */
108    public static LineageResult analyze(File unitFile, EDbVendor vendor,
109                                        Catalog catalog, LineageConfig config)
110            throws IOException {
111        if (unitFile == null) {
112            throw new IllegalArgumentException("unitFile must not be null");
113        }
114        if (vendor == null) {
115            throw new IllegalArgumentException("vendor must not be null");
116        }
117        String unitText = new String(
118                Files.readAllBytes(unitFile.toPath()), StandardCharsets.UTF_8);
119        LineageConfig effective = (config == null)
120                ? LineageConfig.defaults() : config;
121        String locator = effective.getArtifactLocator().isEmpty()
122                ? unitFile.getName() : effective.getArtifactLocator();
123        return analyzeUnit(unitText, vendor, catalog, effective, locator);
124    }
125
126    private static LineageResult analyzeUnit(String unitText, EDbVendor vendor,
127                                             Catalog catalog,
128                                             LineageConfig config,
129                                             String artifactLocator) {
130        TGSqlParser parser = new TGSqlParser(vendor);
131        parser.setResolverType(EResolverType.RESOLVER2);
132        parser.sqltext = unitText;
133        int rc = parser.parse();
134
135        List<ContractStatement> statements = new ArrayList<>();
136        List<ContractEdge> edges = new ArrayList<>();
137        List<ContractDiagnostic> unitDiagnostics = new ArrayList<>();
138
139        TStatementList stmts = parser.sqlstatements;
140        if (stmts == null || stmts.size() == 0) {
141            if (rc != 0) {
142                // Whole-unit parse catastrophe: nothing was split into
143                // statements, so degrade at document level.
144                String errorMessage = parser.getErrormessage();
145                if (errorMessage == null || errorMessage.isEmpty()) {
146                    errorMessage = "parser returned non-zero rc=" + rc;
147                }
148                unitDiagnostics.add(new ContractDiagnostic(
149                        DiagnosticCode.PARSE_FAILED.name(),
150                        DiagnosticSeverity.ERROR,
151                        "SQL parse failed: " + errorMessage, null, null));
152            }
153            // rc == 0 with no statements (e.g. comment-only unit): an empty
154            // statement inventory with no diagnostics.
155        } else {
156            LineIndex lines = new LineIndex(unitText);
157            for (int i = 0; i < stmts.size(); i++) {
158                TCustomSqlStatement raw = stmts.get(i);
159                ContractStatement statement = buildStatement(raw, i + 1,
160                        unitText, lines, artifactLocator);
161                statements.add(statement);
162                if (statement.getDiagnostics().isEmpty()
163                        && "SELECT".equals(statement.getKind())
164                        && raw instanceof TSelectSqlStatement) {
165                    int[] span = statementSpan(raw, unitText);
166                    String statementText = (span != null)
167                            ? unitText.substring(span[0], span[1])
168                            : String.valueOf(raw);
169                    edges.addAll(StatementLineageExtractor.extract(
170                            (TSelectSqlStatement) raw, statementText, i + 1,
171                            statement.getQueryFingerprint(), vendor, catalog,
172                            config, artifactLocator, unitText));
173                }
174            }
175        }
176
177        LineageDocument document = new LineageDocument(
178                ContractVersions.CONTRACT_VERSION,
179                ContractVersions.engineVersion(),
180                vendorName(vendor),
181                artifactLocator,
182                statements,
183                edges,
184                Collections.<TransformGraph>emptyList(),
185                unitDiagnostics);
186        return new LineageResult(document);
187    }
188
189    /**
190     * One contract statement entry: source-ordered index, kind,
191     * FINGERPRINT-V1 of the statement's verbatim text (trailing statement
192     * separator excluded — the same slice convention as
193     * {@code SqlSemanticAnalyzer.statementText}), absolute half-open span
194     * of that text, and the degrade diagnostic when the statement failed
195     * to parse.
196     */
197    private static ContractStatement buildStatement(TCustomSqlStatement stmt,
198                                                    int statementIndex,
199                                                    String unitText,
200                                                    LineIndex lines,
201                                                    String artifactLocator) {
202        int[] span = statementSpan(stmt, unitText);
203        String text;
204        SourceLocation location = null;
205        if (span != null) {
206            text = unitText.substring(span[0], span[1]);
207            location = new SourceLocation(artifactLocator, statementIndex,
208                    lines.lineOf(span[0]), lines.colOf(span[0]),
209                    lines.lineOf(span[1]), lines.colOf(span[1]));
210        } else {
211            // Defensive fallback — boundary tokens unusable. No fabricated
212            // position (contract §6); fingerprint over the parser's text.
213            text = String.valueOf(stmt);
214        }
215
216        boolean parseFailed = stmt.sqlstatementtype == ESqlStatementType.sstinvalid
217                || stmt.getErrorCount() > 0;
218        List<ContractDiagnostic> diagnostics;
219        if (parseFailed) {
220            diagnostics = Collections.singletonList(new ContractDiagnostic(
221                    DiagnosticCode.PARSE_FAILED.name(),
222                    DiagnosticSeverity.ERROR, PARSE_FAILED_MESSAGE,
223                    statementIndex, location));
224        } else {
225            diagnostics = Collections.emptyList();
226        }
227
228        String kind = (stmt.sqlstatementtype == ESqlStatementType.sstinvalid)
229                ? "UNKNOWN" : statementKind(stmt);
230        return new ContractStatement(statementIndex, kind,
231                FingerprintV1.fingerprint(text), location, diagnostics);
232    }
233
234    /**
235     * Half-open {@code [start, end)} character span of the statement's
236     * verbatim text within the unit: boundary-token offsets, minus trailing
237     * whitespace and one trailing {@code ';'}. Returns {@code null} when
238     * the boundary tokens are unusable.
239     */
240    private static int[] statementSpan(TCustomSqlStatement stmt, String unitText) {
241        TSourceToken startToken = stmt.getStartToken();
242        TSourceToken endToken = stmt.getEndToken();
243        if (startToken == null || endToken == null) {
244            return null;
245        }
246        int start = (int) startToken.offset;
247        String endText = endToken.astext;
248        int end = (int) endToken.offset + (endText == null ? 0 : endText.length());
249        if (start < 0 || end < start || end > unitText.length()) {
250            return null;
251        }
252        // Defensive: skip leading whitespace (the start token is normally a
253        // solid token, so this is a no-op).
254        while (start < end && Character.isWhitespace(unitText.charAt(start))) {
255            start++;
256        }
257        while (end > start && Character.isWhitespace(unitText.charAt(end - 1))) {
258            end--;
259        }
260        if (end > start && unitText.charAt(end - 1) == ';') {
261            end--;
262            while (end > start && Character.isWhitespace(unitText.charAt(end - 1))) {
263                end--;
264            }
265        }
266        if (end == start) {
267            return null;
268        }
269        return new int[]{start, end};
270    }
271
272    /**
273     * Contract statement-kind string (contract §2.1). Kinds outside the
274     * documented M2 set fall back to {@code "UNKNOWN"} until their
275     * extraction stories define them (the kind list is open-ended and the
276     * semantic layer is draft until the M2 freeze).
277     */
278    private static String statementKind(TCustomSqlStatement stmt) {
279        ESqlStatementType type = stmt.sqlstatementtype;
280        switch (type) {
281            case sstselect:
282                boolean selectInto = stmt instanceof TSelectSqlStatement
283                        && ((TSelectSqlStatement) stmt).getIntoClause() != null;
284                return selectInto ? "SELECT_INTO" : "SELECT";
285            case sstinsert:
286                return "INSERT";
287            case sstupdate:
288                return "UPDATE";
289            case sstdelete:
290                return "DELETE";
291            case sstmerge:
292                return "MERGE";
293            case sstcreatetable:
294                return "CREATE_TABLE";
295            case sstcreateview:
296                return "CREATE_VIEW";
297            case sstcreatesynonym:
298                return "CREATE_SYNONYM";
299            default:
300                return "UNKNOWN";
301        }
302    }
303
304    /** Contract vendor string: the {@link EDbVendor} name minus its
305     * {@code dbv} prefix (e.g. {@code dbvmssql} → {@code "mssql"}). */
306    private static String vendorName(EDbVendor vendor) {
307        String name = vendor.name();
308        return name.startsWith("dbv") ? name.substring(3) : name;
309    }
310
311    /** Offset → 1-based line/column conversion over the unit text. */
312    private static final class LineIndex {
313        private final int[] lineStarts;
314
315        LineIndex(String text) {
316            int lineCount = 1;
317            for (int i = 0; i < text.length(); i++) {
318                if (text.charAt(i) == '\n') {
319                    lineCount++;
320                }
321            }
322            lineStarts = new int[lineCount];
323            int line = 1;
324            for (int i = 0; i < text.length(); i++) {
325                if (text.charAt(i) == '\n') {
326                    lineStarts[line++] = i + 1;
327                }
328            }
329        }
330
331        int lineOf(int offset) {
332            int lo = 0, hi = lineStarts.length - 1;
333            while (lo < hi) {
334                int mid = (lo + hi + 1) >>> 1;
335                if (lineStarts[mid] <= offset) {
336                    lo = mid;
337                } else {
338                    hi = mid - 1;
339                }
340            }
341            return lo + 1;
342        }
343
344        int colOf(int offset) {
345            return offset - lineStarts[lineOf(offset) - 1] + 1;
346        }
347    }
348}