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}