001package gudusoft.gsqlparser.dlineage.dynamicsql; 002 003import gudusoft.gsqlparser.EDbVendor; 004import gudusoft.gsqlparser.TCustomSqlStatement; 005import gudusoft.gsqlparser.TStatementList; 006 007import java.util.ArrayList; 008import java.util.HashMap; 009import java.util.List; 010import java.util.Map; 011 012/** 013 * Analysis-scoped index of routine DEFINITIONS and top-level candidate CALL-SITE 014 * statements across the units of a multi-file analysis (plan §5.6, Phase 5 015 * slice 3 — the seed of the shared routine/signature index). 016 * 017 * <p>Lazily built and memoized per unit text: a unit is parsed (through the 018 * shared, caller-owned parse cache) and its statement list walked at most ONCE 019 * per analysis, no matter how many procedures consult the catalog. Callers keep 020 * their own cheap contains-prefilter, so units that never mention a routine of 021 * interest are still never parsed at all — cost semantics are identical to the 022 * previous per-procedure scans, minus the repeated walking. 023 * 024 * <p>The catalog only INDEXES; every matching, vetting, and ambiguity decision 025 * stays in {@link DynamicSqlLineageResolver} so this refactor is behavior- 026 * preserving by construction. 027 * 028 * <h3>How this differs from {@code TSQLEnv} / {@code TSQLProcedure}</h3> 029 * 030 * The sqlenv package already keeps a procedure registry 031 * ({@code gudusoft.gsqlparser.sqlenv.TSQLProcedure} under {@code TSQLSchema}, 032 * with typed parameters and even the definition source string). The two serve 033 * different layers and are NOT redundant: 034 * 035 * <ul> 036 * <li><b>AST working set vs metadata registry.</b> {@code TSQLEnv} stores 037 * metadata — names, parameter TYPES, the definition as a STRING — and 038 * serves NAME RESOLUTION (resolver2/dlineage resolving {@code a.b.c} 039 * against catalog objects). This catalog stores live 040 * {@link gudusoft.gsqlparser.TCustomSqlStatement AST nodes} plus each 041 * statement's unit provenance and effective USE database/schema, because 042 * the dynamic-SQL binding machinery must hand the callee's AST to the 043 * evaluator/interpreter and extract literal arguments from caller ASTs. 044 * An env definition string would have to be re-parsed anyway — which is 045 * exactly what this catalog's parse cache does, while also indexing what 046 * the env has no concept of at all: <b>call sites</b>.</li> 047 * <li><b>Duplicate visibility vs registry merging.</b> An env is keyed by 048 * qualified name, so re-registering a same-named routine merges or 049 * overwrites. The binding rules ("never guess") need the opposite: every 050 * definition OCCURRENCE with its origin unit must stay visible so a 051 * same-named duplicate can be detected and refused. The information a 052 * registry naturally collapses is precisely what the ambiguity verdicts 053 * here require.</li> 054 * <li><b>No environment assumed.</b> dlineage frequently runs with no 055 * {@code TSQLEnv} attached (and env construction is option-gated); this 056 * catalog depends only on the analysis unit texts themselves.</li> 057 * <li><b>Planned convergence.</b> Plan §5.6 promotes one routine/signature 058 * index shared by production dlineage and Analyzer v2. This class starts 059 * analysis-side because behavior preservation was the gate; unifying it 060 * with env registration and v2's {@code BoundProgram} is later Phase 5/6 061 * work.</li> 062 * </ul> 063 * 064 * <h3>When metadata already contains stored-procedure definitions</h3> 065 * 066 * <ul> 067 * <li><b>Metadata carrying routine SOURCE</b> (sqlflow JSON {@code sourceCode}, 068 * grabit exports, DDL dumps) already flows in with no extra wiring: those 069 * definitions become ordinary analysis units, so this catalog indexes them 070 * like any other file while {@code SQLEnvParser} independently registers 071 * them for name resolution.</li> 072 * <li><b>Metadata carrying only SIGNATURES</b> (name + parameter types, no 073 * body — the catalog-input-interface direction) cannot produce lineage 074 * THROUGH a body, but enables correct IN/OUT binding at call sites, 075 * overload disambiguation, and a "known external routine, effects unknown" 076 * diagnostic instead of a bare unresolved.</li> 077 * <li><b>Future slice ("env-backed routine bodies"):</b> when an attached env 078 * holds a {@code TSQLRoutine} definition string for a routine that is NOT 079 * among the analysis units, this catalog could parse that definition as a 080 * virtual unit — covering the "caller file plus metadata-only definition" 081 * scenario.</li> 082 * </ul> 083 */ 084public final class RoutineCatalog { 085 086 /** 087 * B4a: everything needed to re-wrap ONE package member back into a 088 * standalone, analyzable single-member package. 089 * 090 * <p>A member routine node carries its own token range, so its 091 * {@code toString()} yields just {@code PROCEDURE foo(...) IS ... END foo} 092 * — but that fragment is not a parseable top-level statement. Wrapping it 093 * in a synthetic package that keeps the declaration section (globals) and 094 * drops every sibling body is what makes per-member summarization possible: 095 * globals stay in scope (arch-F3 boundary endpoints), while an absent 096 * sibling makes an intra-package call surface as a raw call site, enforcing 097 * no-inline-callee by construction exactly as {@code definitionSlice} does 098 * for top-level routines. 099 */ 100 static final class PackageContext { 101 /** Qualified package name for the wrapper header. */ 102 final String qualifiedName; 103 /** Simple package name for the trailing {@code END <name>;}. */ 104 final String endName; 105 /** Concatenated non-routine declare elements (variables, cursors, types). */ 106 final String globalsText; 107 108 PackageContext(String qualifiedName, String endName, String globalsText) { 109 this.qualifiedName = qualifiedName; 110 this.endName = endName; 111 this.globalsText = globalsText; 112 } 113 } 114 115 /** One routine definition found at the top level of a unit. */ 116 static final class Definition { 117 final TCustomSqlStatement stmt; 118 /** mssql: explicit three-part catalog or effective USE database; oracle: null. */ 119 final String db; 120 /** oracle: schema segment of a qualified definition name; mssql: unused (null). */ 121 final String schema; 122 final String simple; 123 // ---- B0 additive signature/provenance fields (design §2.1a). The 124 // legacy matching fields above keep their exact prior semantics; the 125 // fields below are consumed only by summary-layer code (B2+), never 126 // by the shipped resolver paths — behavior-preserving by construction. 127 /** Kind discriminator; top-level definitions today are PROCEDURE/FUNCTION. */ 128 final RoutineKind kind; 129 /** Raw package simple name for package members; null otherwise. */ 130 final String packageName; 131 /** True for a package SPEC declaration (signature only, no body). */ 132 final boolean specOnly; 133 /** Occurrence provenance: index of the defining statement in its unit. */ 134 final int statementIndex; 135 /** Canonical signature descriptor (never used for legacy matching). */ 136 final RoutineIdentity identity; 137 /** B4a: re-wrap context for package members; null for top-level defs. */ 138 final PackageContext pkgContext; 139 140 Definition(TCustomSqlStatement stmt, String db, String schema, String simple, 141 RoutineKind kind, String packageName, boolean specOnly, 142 int statementIndex, RoutineIdentity identity) { 143 this(stmt, db, schema, simple, kind, packageName, specOnly, 144 statementIndex, identity, null); 145 } 146 147 Definition(TCustomSqlStatement stmt, String db, String schema, String simple, 148 RoutineKind kind, String packageName, boolean specOnly, 149 int statementIndex, RoutineIdentity identity, 150 PackageContext pkgContext) { 151 this.stmt = stmt; 152 this.db = db; 153 this.schema = schema; 154 this.simple = simple; 155 this.kind = kind; 156 this.packageName = packageName; 157 this.specOnly = specOnly; 158 this.statementIndex = statementIndex; 159 this.identity = identity; 160 this.pkgContext = pkgContext; 161 } 162 } 163 164 /** One top-level non-definition statement — a potential caller. */ 165 static final class CallCandidate { 166 final TCustomSqlStatement stmt; 167 /** mssql: effective USE database at the statement; oracle: null. */ 168 final String db; 169 170 CallCandidate(TCustomSqlStatement stmt, String db) { 171 this.stmt = stmt; 172 this.db = db; 173 } 174 } 175 176 /** Everything the catalog knows about one unit. */ 177 static final class UnitIndex { 178 final List<Definition> definitions = new ArrayList<Definition>(); 179 final List<CallCandidate> callCandidates = new ArrayList<CallCandidate>(); 180 /** 181 * B0: Oracle package-member definitions (spec declarations and body 182 * definitions), kept SEPARATE from {@link #definitions} so every 183 * shipped resolver path sees exactly the pre-B0 view. The enclosing 184 * package statement still appears in {@link #callCandidates} exactly 185 * as before (its body can contain call sites). Summary-layer slices 186 * (B2+) are the only consumers. 187 */ 188 final List<Definition> memberDefinitions = new ArrayList<Definition>(); 189 } 190 191 private final EDbVendor vendor; 192 private final List<String> unitTexts; 193 private final Map<String, TStatementList> parseCache; 194 private final Map<String, UnitIndex> unitIndexes = new HashMap<String, UnitIndex>(); 195 196 public RoutineCatalog(EDbVendor vendor, List<String> unitTexts, Map<String, TStatementList> parseCache) { 197 this.vendor = vendor; 198 this.unitTexts = unitTexts; 199 this.parseCache = parseCache; 200 } 201 202 /** The raw texts of every analysis unit, in analysis order (includes the caller's own). */ 203 List<String> unitTexts() { 204 return unitTexts; 205 } 206 207 /** 208 * Index of one unit; parses on first use (cached), walks the statement list on 209 * first use (memoized). Null when the unit failed to parse — a failed unit's 210 * statement boundaries are not trustworthy and contribute nothing. 211 */ 212 UnitIndex indexOf(String unitText) { 213 if (unitIndexes.containsKey(unitText)) { 214 return unitIndexes.get(unitText); 215 } 216 TStatementList stmts; 217 if (parseCache.containsKey(unitText)) { 218 stmts = parseCache.get(unitText); 219 } else { 220 stmts = parseUnit(unitText); 221 parseCache.put(unitText, stmts); 222 } 223 UnitIndex index = stmts == null ? null : buildIndex(stmts); 224 unitIndexes.put(unitText, index); 225 return index; 226 } 227 228 private UnitIndex buildIndex(TStatementList stmts) { 229 UnitIndex index = new UnitIndex(); 230 String currentDb = null; 231 for (int i = 0; i < stmts.size(); i++) { 232 TCustomSqlStatement stmt = stmts.get(i); 233 if (stmt instanceof gudusoft.gsqlparser.stmt.TUseDatabase) { 234 currentDb = String.valueOf( 235 ((gudusoft.gsqlparser.stmt.TUseDatabase) stmt).getDatabaseName()); 236 continue; 237 } 238 Definition definition = definitionOf(stmt, currentDb, i); 239 if (definition != null) { 240 index.definitions.add(definition); 241 } else { 242 indexPackageMembers(stmt, i, index); 243 index.callCandidates.add(new CallCandidate(stmt, 244 vendor == EDbVendor.dbvmssql ? currentDb : null)); 245 } 246 } 247 return index; 248 } 249 250 /** Vendor-specific definition extraction; null when {@code stmt} defines no routine. */ 251 private Definition definitionOf(TCustomSqlStatement stmt, String currentDb, 252 int statementIndex) { 253 if (vendor == EDbVendor.dbvmssql) { 254 if (stmt instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlCreateProcedure) { 255 gudusoft.gsqlparser.stmt.mssql.TMssqlCreateProcedure proc = 256 (gudusoft.gsqlparser.stmt.mssql.TMssqlCreateProcedure) stmt; 257 if (proc.getProcedureName() == null) { 258 return null; 259 } 260 String db = DynamicSqlLineageResolver.mssqlDefinitionDatabase(proc, currentDb); 261 // B3 (codex-B3-r1 finding 6): deprecated numbered procedures 262 // ("p;2") carry the ;N as the ratified overload discriminator 263 // — without it dbo.p;1 and dbo.p;2 collapse into duplicates. 264 // The parser DROPS ";N" from the AST name, so it is recovered 265 // from the token stream right after the name; the text-based 266 // helpers below stay as fallback for callers that carry it. 267 String objectText = proc.getProcedureName().getObjectString(); 268 String discriminator = numberedGroupAfter(proc.getProcedureName()); 269 if (discriminator == null) { 270 discriminator = mssqlNumberedSuffixOf(objectText); 271 } 272 return new Definition(stmt, db, null, 273 DynamicSqlLineageResolver.simpleNameOf(proc.getProcedureName().toString()), 274 RoutineKind.PROCEDURE, null, false, statementIndex, 275 identityOf(RoutineKind.PROCEDURE, db, 276 proc.getProcedureName().getSchemaString(), null, 277 stripMssqlNumberedSuffix(objectText), stmt, 278 discriminator)); 279 } 280 return null; 281 } 282 String simple = DynamicSqlLineageResolver.plsqlRoutineSimpleNameOf(stmt); 283 if (simple == null) { 284 return null; 285 } 286 RoutineKind kind = 287 stmt instanceof gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateFunction 288 ? RoutineKind.FUNCTION : RoutineKind.PROCEDURE; 289 String schema = DynamicSqlLineageResolver.plsqlRoutineSchemaOf(stmt); 290 return new Definition(stmt, null, schema, simple, kind, null, false, 291 statementIndex, identityOf(kind, null, schema, null, simple, stmt)); 292 } 293 294 /** 295 * B0: index Oracle package members (spec declarations and body 296 * definitions) into {@link UnitIndex#memberDefinitions}. The package 297 * statement itself is not consumed — it stays a call candidate exactly as 298 * before, so shipped matching behavior is untouched. 299 */ 300 private void indexPackageMembers(TCustomSqlStatement stmt, int statementIndex, 301 UnitIndex index) { 302 if (!(stmt instanceof gudusoft.gsqlparser.stmt.oracle.TPlsqlCreatePackage)) { 303 return; 304 } 305 gudusoft.gsqlparser.stmt.oracle.TPlsqlCreatePackage pkg = 306 (gudusoft.gsqlparser.stmt.oracle.TPlsqlCreatePackage) stmt; 307 if (pkg.getPackageName() == null) { 308 return; 309 } 310 String pkgSimple = pkg.getPackageName().getObjectString(); 311 String pkgSchema = pkg.getPackageName().getSchemaString(); 312 boolean specOnly = 313 pkg.getKind() != gudusoft.gsqlparser.TBaseType.kind_create_body; 314 if (pkg.getDeclareStatements() == null) { 315 return; 316 } 317 // B4a: the declaration section is not exposed as a unit — derive it as 318 // the complement of the routine filter below (variables, cursors, types, 319 // pragmas). These are the package globals (arch-F3); they must travel 320 // with every member slice or a member referencing one loses that 321 // endpoint entirely. 322 StringBuilder globals = new StringBuilder(); 323 for (int i = 0; i < pkg.getDeclareStatements().size(); i++) { 324 Object decl = pkg.getDeclareStatements().get(i); 325 if (decl instanceof gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateFunction 326 || decl instanceof gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateProcedure) { 327 continue; 328 } 329 String declText = String.valueOf(decl); 330 if (declText == null || declText.length() == 0 331 // non-identifier-compare: TParseTreeNode.toString() returns 332 // null (rendered "null" by valueOf) when token bounds are 333 // missing; such a declaration cannot be reproduced. 334 || "null".equals(declText)) { 335 continue; 336 } 337 declText = declText.trim(); 338 globals.append(" ").append(declText); 339 if (!declText.endsWith(";")) { 340 globals.append(";"); 341 } 342 globals.append("\n"); 343 } 344 PackageContext pkgContext = new PackageContext( 345 pkg.getPackageName().toString(), pkgSimple, globals.toString()); 346 for (int i = 0; i < pkg.getDeclareStatements().size(); i++) { 347 Object member = pkg.getDeclareStatements().get(i); 348 RoutineKind kind; 349 String simple; 350 if (member instanceof gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateFunction) { 351 kind = RoutineKind.PACKAGE_MEMBER_FUNCTION; 352 simple = DynamicSqlLineageResolver.plsqlRoutineSimpleNameOf( 353 (TCustomSqlStatement) member); 354 } else if (member instanceof gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateProcedure) { 355 kind = RoutineKind.PACKAGE_MEMBER_PROCEDURE; 356 simple = DynamicSqlLineageResolver.plsqlRoutineSimpleNameOf( 357 (TCustomSqlStatement) member); 358 } else { 359 continue; 360 } 361 if (simple == null) { 362 continue; 363 } 364 index.memberDefinitions.add(new Definition((TCustomSqlStatement) member, 365 null, pkgSchema, simple, kind, pkgSimple, specOnly, statementIndex, 366 identityOf(kind, null, pkgSchema, pkgSimple, simple, 367 (TCustomSqlStatement) member), 368 pkgContext)); 369 } 370 } 371 372 /** Identity of one definition; parameter shape read from the routine AST. */ 373 private RoutineIdentity identityOf(RoutineKind kind, String catalog, String schema, 374 String packageName, String simple, TCustomSqlStatement stmt) { 375 return identityOf(kind, catalog, schema, packageName, simple, stmt, null); 376 } 377 378 private RoutineIdentity identityOf(RoutineKind kind, String catalog, String schema, 379 String packageName, String simple, TCustomSqlStatement stmt, 380 String overloadDiscriminator) { 381 gudusoft.gsqlparser.nodes.TParameterDeclarationList params = 382 stmt instanceof gudusoft.gsqlparser.stmt.TStoredProcedureSqlStatement 383 ? ((gudusoft.gsqlparser.stmt.TStoredProcedureSqlStatement) stmt) 384 .getParameterDeclarations() 385 : null; 386 return RoutineIdentity.of(vendor, kind, catalog, schema, packageName, simple, 387 params, overloadDiscriminator); 388 } 389 390 /** 391 * The {@code ;N} group of a deprecated numbered procedure, scanned from 392 * the source tokens right after the name — the parser drops it from the 393 * AST name entirely. {@code ;1} normalizes to null ({@code p} and 394 * {@code p;1} are one identity in T-SQL). 395 */ 396 public static String numberedGroupAfter(gudusoft.gsqlparser.nodes.TObjectName name) { 397 if (name == null || name.getEndToken() == null) { 398 return null; 399 } 400 gudusoft.gsqlparser.TSourceToken semi = name.getEndToken().nextSolidToken(); 401 if (semi == null || !";".equals(semi.toString())) { // non-identifier-compare: punctuation token 402 return null; 403 } 404 gudusoft.gsqlparser.TSourceToken num = semi.nextSolidToken(); 405 if (num == null) { 406 return null; 407 } 408 return canonicalGroup(num.toString().trim()); 409 } 410 411 /** Numeric canonical form of a group suffix: {@code ";01"} ≡ {@code ";1"} 412 * (codex-B3-r3 f6); group 1 ≡ the unnumbered procedure ⇒ null. */ 413 private static String canonicalGroup(String text) { 414 if (text == null || text.isEmpty()) { 415 return null; 416 } 417 for (int i = 0; i < text.length(); i++) { 418 if (!Character.isDigit(text.charAt(i))) { 419 return null; 420 } 421 } 422 long value; 423 try { 424 value = Long.parseLong(text); 425 } catch (NumberFormatException overflow) { 426 return text; // absurdly long digit run: keep spelling, never crash 427 } 428 return value == 1 ? null : String.valueOf(value); 429 } 430 431 /** Trailing {@code ;N} of a deprecated numbered-procedure name, or null. 432 * {@code ;1} is the unnumbered procedure itself in T-SQL, so it 433 * normalizes to null — {@code p} and {@code p;1} are one identity. */ 434 static String mssqlNumberedSuffixOf(String objectText) { 435 if (objectText == null) { 436 return null; 437 } 438 int semi = objectText.lastIndexOf(';'); 439 if (semi < 0 || semi == objectText.length() - 1) { 440 return null; 441 } 442 return canonicalGroup(objectText.substring(semi + 1).trim()); 443 } 444 445 /** The name with any trailing numeric {@code ;N} removed (any spelling — 446 * {@code ;1}, {@code ;01}, {@code ;2} — the group itself is carried by 447 * {@link #mssqlNumberedSuffixOf}). */ 448 static String stripMssqlNumberedSuffix(String objectText) { 449 if (objectText == null) { 450 return null; 451 } 452 int semi = objectText.lastIndexOf(';'); 453 if (semi <= 0 || semi == objectText.length() - 1) { 454 return objectText; 455 } 456 String suffix = objectText.substring(semi + 1).trim(); 457 for (int i = 0; i < suffix.length(); i++) { 458 if (!Character.isDigit(suffix.charAt(i))) { 459 return objectText; 460 } 461 } 462 return objectText.substring(0, semi); 463 } 464 465 /** Parse of a unit's text; null when parsing failed or crashed. */ 466 private TStatementList parseUnit(String sqlText) { 467 try { 468 gudusoft.gsqlparser.TGSqlParser parser = new gudusoft.gsqlparser.TGSqlParser(vendor); 469 parser.sqltext = sqlText; 470 if (parser.parse() != 0) { 471 return null; 472 } 473 return parser.getSqlstatements(); 474 } catch (RuntimeException ex) { 475 return null; 476 } catch (Error err) { 477 if (err instanceof ThreadDeath) { 478 throw err; 479 } 480 // StackOverflow on a pathological unit must not kill the analysis. 481 return null; 482 } 483 } 484}