001package gudusoft.gsqlparser.dlineage.dynamicsql; 002 003import gudusoft.gsqlparser.EDbVendor; 004import gudusoft.gsqlparser.TCustomSqlStatement; 005import gudusoft.gsqlparser.nodes.TObjectName; 006import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType; 007import gudusoft.gsqlparser.util.SQLUtil; 008 009import java.util.ArrayList; 010import java.util.Collections; 011import java.util.LinkedHashMap; 012import java.util.List; 013import java.util.Map; 014 015/** 016 * B3 (design {@code routine-summary-scc-design.md} §2.1b/§2.3): the catalog- 017 * backed {@link RoutineCallBinder}. Resolution is a function over the 018 * candidate DEFINITION set — never identity field-equality against a single 019 * key — and the binding maps follow the inline engine's semantics exactly 020 * (arch-F8: SHADOW must not diff): 021 * 022 * <ul> 023 * <li>expression actual ⇒ fan-in of all leaf source references + the 024 * expression text as transform carrier;</li> 025 * <li>literal actual ⇒ no source (omitted from the inputs map);</li> 026 * <li>OUT/INOUT target must be a bare writable lvalue — an expression, 027 * literal, or caller pure-IN formal is diagnosed with 028 * {@code PROC_CALL_OUT_TARGET_NOT_WRITABLE}; summary application then 029 * quarantines the whole non-executable call;</li> 030 * <li>no unique candidate ⇒ AMBIGUOUS with the candidate list, nothing 031 * bound (codex-C5: duplicate definitions of one identity never 032 * overwrite each other).</li> 033 * </ul> 034 * 035 * <p>Overload resolution in B3 is the structural subset of §2.1b: named / 036 * positional binding plus default-fill over the candidate set. The vendor 037 * type-family ranking (§2.1b step 2) arrives with the SCC slice — until 038 * then two same-name candidates that both survive arity/name binding are 039 * AMBIGUOUS, which is the conservative direction. 040 */ 041public final class CatalogRoutineCallBinder implements RoutineCallBinder { 042 043 /** One resolvable candidate: identity + defining unit provenance. */ 044 static final class Candidate { 045 final RoutineCatalog.Definition definition; 046 final String unitText; 047 048 Candidate(RoutineCatalog.Definition definition, String unitText) { 049 this.definition = definition; 050 this.unitText = unitText; 051 } 052 } 053 054 /** 055 * Binder output enriched with what the SHADOW composition step needs 056 * beyond the B0 contract: the formal-position → actual-position map 057 * (named notation makes the two diverge) and the winning candidate's 058 * defining unit. 059 */ 060 public static final class BoundCall { 061 private final BindingResult result; 062 private final Map<Integer, Integer> actualPositionByFormal; 063 private final String calleeUnitText; 064 065 BoundCall(BindingResult result, Map<Integer, Integer> actualPositionByFormal, 066 String calleeUnitText) { 067 this.result = result; 068 this.actualPositionByFormal = actualPositionByFormal == null 069 ? Collections.<Integer, Integer>emptyMap() 070 : Collections.unmodifiableMap(actualPositionByFormal); 071 this.calleeUnitText = calleeUnitText; 072 } 073 074 public BindingResult getResult() { return result; } 075 /** Declaration position of a formal → call-site position of its actual. */ 076 public Map<Integer, Integer> getActualPositionByFormal() { 077 return actualPositionByFormal; 078 } 079 /** Unit text defining the resolved callee (identity-first lookups key 080 * summaries by identity; this is provenance for diagnostics). */ 081 public String getCalleeUnitText() { return calleeUnitText; } 082 } 083 084 private final EDbVendor vendor; 085 private final RoutineCatalog catalog; 086 087 public CatalogRoutineCallBinder(EDbVendor vendor, RoutineCatalog catalog) { 088 this.vendor = vendor; 089 this.catalog = catalog; 090 } 091 092 /** B0 contract entry: bind a raw call statement. The extractor-produced 093 * {@link CallSiteRef} path below is the primary consumer; this entry 094 * serves callers that hold only the AST. */ 095 @Override 096 public BindingResult bind(TCustomSqlStatement callSite, CallerContext callerContext) { 097 RoutineSummaryExtractor extractor = new RoutineSummaryExtractor(vendor); 098 // A nested INSERT…EXEC execute node must not lose its consuming 099 // INSERT (codex-B3-r3 finding 4): reparse the ENCLOSING statement so 100 // the column list / missing-list flag / positional mapping survive. 101 TCustomSqlStatement toParse = callSite; 102 if (callSite instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlExecute 103 && callSite.getParentStmt() 104 instanceof gudusoft.gsqlparser.stmt.TInsertSqlStatement) { 105 gudusoft.gsqlparser.stmt.TInsertSqlStatement parent = 106 (gudusoft.gsqlparser.stmt.TInsertSqlStatement) 107 callSite.getParentStmt(); 108 if (parent.getExecuteStmt() != null) { 109 toParse = parent; 110 } 111 } 112 CallSiteRef ref = null; 113 List<CallSiteRef> sites = extractor.callSitesOfStatementText( 114 toParse.toString()); 115 if (!sites.isEmpty()) { 116 ref = sites.get(0); 117 } 118 if (ref == null) { 119 return new BindingResult(BindingStatus.UNRESOLVED, null, null, null, 120 null, null, null, BindingCompleteness.PARTIAL, 121 Collections.singletonList(new BindingDiagnostic( 122 BindingDiagnosticCode.PROC_CALL_UNRESOLVED, 123 "unsupported call statement shape"))); 124 } 125 return bind(ref, callerContext == null ? null : callerContext.getDatabase()) 126 .getResult(); 127 } 128 129 /** 130 * Bind one extractor-produced call site against every definition the 131 * catalog knows, under the caller's effective database context. 132 */ 133 public BoundCall bind(CallSiteRef site, String callerDb) { 134 List<BindingDiagnostic> diagnostics = new ArrayList<BindingDiagnostic>(); 135 List<Candidate> candidates = candidatesFor(site.getRaw().getRawName(), 136 callerDb, site.getActuals()); 137 if (candidates.isEmpty()) { 138 diagnostics.add(new BindingDiagnostic( 139 BindingDiagnosticCode.PROC_CALL_UNRESOLVED, 140 site.getRaw().getRawName())); 141 return new BoundCall(new BindingResult(BindingStatus.UNRESOLVED, null, 142 null, null, null, null, null, BindingCompleteness.PARTIAL, 143 diagnostics), null, null); 144 } 145 if (candidates.size() > 1) { 146 List<RoutineIdentity> ids = new ArrayList<RoutineIdentity>(); 147 for (Candidate c : candidates) { 148 ids.add(c.definition.identity); 149 } 150 diagnostics.add(new BindingDiagnostic( 151 BindingDiagnosticCode.PROC_CALL_AMBIGUOUS_OVERLOAD, 152 site.getRaw().getRawName() + ": " + ids.size() + " candidates")); 153 return new BoundCall(new BindingResult(BindingStatus.AMBIGUOUS, null, 154 ids, null, null, null, null, BindingCompleteness.PARTIAL, 155 diagnostics), null, null); 156 } 157 Candidate winner = candidates.get(0); 158 RoutineIdentity identity = winner.definition.identity; 159 Map<Integer, Integer> actualByFormal = 160 mapActualsToFormals(site.getActuals(), identity); 161 if (actualByFormal == null) { 162 // structurally compatible earlier, but named binding failed — 163 // treat as unresolved rather than guessing positions 164 diagnostics.add(new BindingDiagnostic( 165 BindingDiagnosticCode.PROC_CALL_ARGUMENT_UNBOUND, 166 site.getRaw().getRawName() + ": named argument binding failed")); 167 return new BoundCall(new BindingResult(BindingStatus.UNRESOLVED, null, 168 null, null, null, null, null, BindingCompleteness.PARTIAL, 169 diagnostics), null, null); 170 } 171 boolean partial = false; 172 Map<Integer, ActualSources> inputs = new LinkedHashMap<Integer, ActualSources>(); 173 Map<Integer, WritableTarget> outputs = new LinkedHashMap<Integer, WritableTarget>(); 174 for (Map.Entry<Integer, Integer> e : actualByFormal.entrySet()) { 175 int formalPos = e.getKey(); 176 CallSiteRef.Actual actual = site.getActuals().get(e.getValue()); 177 if (actual.isDefaultToken()) { 178 continue; // declared default applies — no source, no target 179 } 180 // Oracle pure OUT formals receive NO input value (the callee 181 // initializes them) — binding the actual's prior value would 182 // fabricate flow (codex-B3-r2 finding 3). T-SQL OUTPUT and 183 // INOUT stay input-capable. 184 boolean pureOracleOut = vendor == EDbVendor.dbvoracle 185 && isPureOutMode(identity, formalPos); 186 if (!actual.isLiteral() && !pureOracleOut) { 187 List<TObjectName> leafs = actual.getBareReference() != null 188 ? Collections.singletonList(actual.getBareReference()) 189 : actual.getLeafReferences(); 190 if (!leafs.isEmpty() || actual.getExpressionText() != null) { 191 inputs.put(formalPos, new ActualSources( 192 new ArrayList<TObjectName>(leafs), 193 actual.getExpressionText())); 194 } 195 } 196 if (declaredOut(identity, formalPos)) { 197 // T-SQL projects OUT only when the CALL SITE says OUTPUT too; 198 // omitting it passes by value (codex-B3-r1 finding 3). PL/SQL 199 // has no call-site marker — the declared mode alone decides. 200 boolean callSiteWantsOut = vendor == EDbVendor.dbvoracle 201 || actual.isOutFlagged(); 202 if (!callSiteWantsOut) { 203 // no OUT projection, no diagnostic: passing a value into 204 // an OUTPUT-capable formal without OUTPUT is legal T-SQL 205 } else if (actual.getBareReference() != null) { 206 outputs.put(formalPos, 207 new WritableTarget(actual.getBareReference())); 208 } else { 209 // expression OR literal in an OUT position: dropped with 210 // the required diagnostic, never fabricated. 211 diagnostics.add(new BindingDiagnostic( 212 BindingDiagnosticCode.PROC_CALL_OUT_TARGET_NOT_WRITABLE, 213 "formal #" + formalPos + " of " 214 + site.getRaw().getRawName())); 215 partial = true; 216 } 217 } else if (actual.isOutFlagged()) { 218 diagnostics.add(new BindingDiagnostic( 219 BindingDiagnosticCode.PROC_CALL_OUTPUT_MODE_MISMATCH, 220 "call-site OUTPUT flag on non-OUT formal #" + formalPos 221 + " of " + site.getRaw().getRawName())); 222 partial = true; 223 } 224 } 225 ResultSetMapping resultSets = null; 226 if (!site.getInsertColumnNames().isEmpty()) { 227 resultSets = new ResultSetMapping( 228 new ArrayList<TObjectName>(site.getInsertColumnNames())); 229 } else if (site.isInsertWithoutColumnList()) { 230 // INSERT…EXEC without an explicit column list: positional 231 // ordinals are unknowable without a catalog — reported, never 232 // guessed (codex-B3-r1 finding 4). 233 diagnostics.add(new BindingDiagnostic( 234 BindingDiagnosticCode.PROC_CALL_ARGUMENT_UNBOUND, 235 "INSERT...EXEC without column list consuming " 236 + site.getRaw().getRawName())); 237 partial = true; 238 } 239 // @ret = EXEC p: the status capture is a validated writable lvalue. 240 // Its VALUE lineage stays deferred (RETURN_ENDPOINT_DEFERRED keeps 241 // callee summaries PARTIAL), so binding it never fabricates edges. 242 WritableTarget returnTarget = site.getReturnStatusTarget() == null 243 ? null : new WritableTarget(site.getReturnStatusTarget()); 244 return new BoundCall(new BindingResult(BindingStatus.RESOLVED, identity, 245 null, inputs, outputs, returnTarget, resultSets, 246 partial ? BindingCompleteness.PARTIAL : BindingCompleteness.COMPLETE, 247 diagnostics), actualByFormal, winner.unitText); 248 } 249 250 /** 251 * Summary-composition binding with the caller signature available for 252 * writable-lvalue validation. A PL/SQL pure-IN formal can be read inside 253 * its routine but cannot receive a callee OUT value; treating its bare 254 * spelling as writable publishes lineage for a call Oracle rejects. 255 */ 256 public BoundCall bind(CallSiteRef site, String callerDb, 257 RoutineIdentity callerIdentity) { 258 BoundCall bound = bind(site, callerDb); 259 BindingResult result = bound.getResult(); 260 if (vendor != EDbVendor.dbvoracle || callerIdentity == null 261 || result.getStatus() != BindingStatus.RESOLVED 262 || result.getOutputs().isEmpty()) { 263 return bound; 264 } 265 Map<Integer, WritableTarget> outputs = 266 new LinkedHashMap<Integer, WritableTarget>(result.getOutputs()); 267 List<BindingDiagnostic> diagnostics = 268 new ArrayList<BindingDiagnostic>(result.getDiagnostics()); 269 boolean rejected = false; 270 for (Map.Entry<Integer, WritableTarget> output 271 : result.getOutputs().entrySet()) { 272 Integer actualPos = bound.getActualPositionByFormal().get( 273 output.getKey()); 274 if (actualPos == null || actualPos < 0 275 || actualPos >= site.getActuals().size()) { 276 continue; 277 } 278 CallSiteRef.Actual actual = site.getActuals().get(actualPos); 279 int callerFormal = callerFormalPosition(actual.getBareReference(), 280 callerIdentity); 281 if (callerFormal >= 0 282 && !declaredOut(callerIdentity, callerFormal)) { 283 outputs.remove(output.getKey()); 284 diagnostics.add(new BindingDiagnostic( 285 BindingDiagnosticCode.PROC_CALL_OUT_TARGET_NOT_WRITABLE, 286 "caller pure-IN formal " 287 + callerIdentity.getRawParamNames().get( 288 callerFormal) 289 + " bound to OUT formal #" + output.getKey() 290 + " of " + site.getRaw().getRawName())); 291 rejected = true; 292 } 293 } 294 if (!rejected) { 295 return bound; 296 } 297 BindingResult filtered = new BindingResult(result.getStatus(), 298 result.getResolved(), result.getCandidates(), 299 result.getInputs(), outputs, result.getReturnTarget(), 300 result.getResultSets(), BindingCompleteness.PARTIAL, 301 diagnostics); 302 return new BoundCall(filtered, bound.getActualPositionByFormal(), 303 bound.getCalleeUnitText()); 304 } 305 306 /** Caller formal position of one bare actual, or -1 for a local/table 307 * reference. Matching uses raw declaration spellings through the vendor 308 * equality facade; stored canonical text is never folded twice. */ 309 private int callerFormalPosition(TObjectName bare, 310 RoutineIdentity callerIdentity) { 311 if (bare == null) { 312 return -1; 313 } 314 String actualName = bare.toString(); 315 List<String> parts = SQLUtil.parseNames(actualName); 316 // Only an unqualified reference can be identified as the caller's 317 // formal from spelling alone. Suffix-matching a qualified reference 318 // such as rec.p_in against formal p_in would reject a valid writable 319 // record field as though it were the read-only formal. 320 if (parts.size() != 1) { 321 return -1; 322 } 323 actualName = parts.get(0); 324 if (actualName.startsWith("@")) { 325 actualName = actualName.substring(1); 326 } 327 for (int i = 0; i < callerIdentity.getRawParamNames().size(); i++) { 328 String declared = callerIdentity.getRawParamNames().get(i); 329 if (declared.startsWith("@")) { 330 declared = declared.substring(1); 331 } 332 if (SQLUtil.sameName(vendor, ESQLDataObjectType.dotColumn, 333 actualName, declared)) { 334 return i; 335 } 336 } 337 return -1; 338 } 339 340 /** Pure OUT (not INOUT/OUTPUT) — Oracle's no-input-value mode. */ 341 private static boolean isPureOutMode(RoutineIdentity identity, int formalPos) { 342 if (formalPos >= identity.getParamModes().size()) { 343 return false; 344 } 345 String mode = identity.getParamModes().get(formalPos); 346 if (mode == null) { 347 return false; 348 } 349 String upper = mode.toUpperCase(java.util.Locale.ROOT); // non-identifier-compare: parameter-mode token vocabulary 350 return upper.equals("OUT"); 351 } 352 353 /** Declared OUT/OUTPUT/INOUT check over the identity's mode vocabulary. */ 354 private static boolean declaredOut(RoutineIdentity identity, int formalPos) { 355 if (formalPos >= identity.getParamModes().size()) { 356 return false; 357 } 358 String mode = identity.getParamModes().get(formalPos); 359 if (mode == null) { 360 return false; 361 } 362 String upper = mode.toUpperCase(java.util.Locale.ROOT); // non-identifier-compare: parameter-mode token vocabulary 363 return upper.equals("OUT") || upper.equals("OUTPUT") || upper.equals("INOUT"); 364 } 365 366 /** 367 * The candidate definitions compatible with one call: name-matched under 368 * the caller context, then named/positional + default-fill compatible. 369 */ 370 private List<Candidate> candidatesFor(String callName, String callerDb, 371 List<CallSiteRef.Actual> actuals) { 372 List<Candidate> result = new ArrayList<Candidate>(); 373 for (String unitText : catalog.unitTexts()) { 374 RoutineCatalog.UnitIndex index = catalog.indexOf(unitText); 375 if (index == null) { 376 continue; 377 } 378 for (RoutineCatalog.Definition def : index.definitions) { 379 if (def.identity == null) { 380 continue; 381 } 382 if (!nameMatches(callName, callerDb, def)) { 383 continue; 384 } 385 if (!arityCompatible(actuals, def.identity)) { 386 continue; 387 } 388 result.add(new Candidate(def, unitText)); 389 } 390 // Package members are PACKAGE_SCOPE_DEFERRED until B4: they share 391 // one statement and cannot be summarized per definition yet, so 392 // binding to them would compose against a summary that does not 393 // exist. They are deliberately NOT candidates in B3. 394 } 395 return result; 396 } 397 398 /** Name match under caller context — EXACTLY the P5 resolver rules 399 * ({@code DynamicSqlLineageResolver.callMatchesProc}): a one-part call 400 * matches by simple name under database agreement; equal part counts 401 * compare fully; the 2↔3 part asymmetric pairs match only with the 402 * documented database checks; every other arity combination REFUSES — 403 * schema/database defaulting is environment-dependent and an unknown 404 * segment is never treated as a wildcard (codex-B3-r1 finding 1). */ 405 private boolean nameMatches(String callName, String callerDb, 406 RoutineCatalog.Definition def) { 407 List<String> callParts = SQLUtil.parseNames(callName); 408 if (callParts.isEmpty()) { 409 return false; 410 } 411 String callSimple = callParts.get(callParts.size() - 1); 412 String defSimple = def.simple; 413 if (vendor == EDbVendor.dbvmssql) { 414 // Numbered procedures: base names must match AND the ;N group 415 // must agree (";1" ≡ unnumbered) — codex-B3-r1 finding 6. The 416 // definition's group lives on its IDENTITY (token-scanned at 417 // catalog time; the parser drops ";N" from the AST name text). 418 String callGroup = RoutineCatalog.mssqlNumberedSuffixOf(callSimple); 419 String defGroup = def.identity.getOverloadDiscriminator(); 420 if (defGroup != null && (defGroup.length() == 0 || "1".equals(defGroup))) { // non-identifier-compare: numeric group suffix 421 defGroup = null; 422 } 423 String textGroup = RoutineCatalog.mssqlNumberedSuffixOf(defSimple); 424 if (defGroup == null && textGroup != null) { 425 defGroup = textGroup; 426 } 427 if (callGroup == null ? defGroup != null : !callGroup.equals(defGroup)) { // non-identifier-compare: numeric group suffix 428 return false; 429 } 430 callSimple = RoutineCatalog.stripMssqlNumberedSuffix(callSimple); 431 defSimple = RoutineCatalog.stripMssqlNumberedSuffix(defSimple); 432 } 433 if (!SQLUtil.sameName(vendor, ESQLDataObjectType.dotProcedure, 434 callSimple, defSimple)) { 435 return false; 436 } 437 if (vendor == EDbVendor.dbvmssql) { 438 // Database agreement first (a 3-part call carries its own db). 439 if (callParts.size() < 3 && !sameEffectiveDb(def.db, callerDb)) { 440 return false; 441 } 442 List<String> defParts = definitionNameParts(def); 443 if (callParts.size() == 1) { 444 return true; // simple-name rule under db agreement (P5) 445 } 446 if (callParts.size() == defParts.size()) { 447 // The simple name (last segment) was already compared above 448 // with the ;N group stripped — compare the container 449 // segments only. 450 for (int i = 2; i <= callParts.size(); i++) { 451 ESQLDataObjectType type = i == 2 452 ? ESQLDataObjectType.dotSchema 453 : ESQLDataObjectType.dotCatalog; 454 if (!SQLUtil.sameName(vendor, type, 455 callParts.get(callParts.size() - i), 456 defParts.get(defParts.size() - i))) { 457 return false; 458 } 459 } 460 return true; 461 } 462 if (callParts.size() == 2 && defParts.size() == 3) { 463 // db agreement already established; compare schema.name 464 return SQLUtil.sameName(vendor, ESQLDataObjectType.dotSchema, 465 callParts.get(0), defParts.get(1)); 466 } 467 if (callParts.size() == 3 && defParts.size() == 2) { 468 // explicit call catalog must equal the KNOWN effective db 469 return def.db != null 470 && SQLUtil.sameName(vendor, ESQLDataObjectType.dotCatalog, 471 callParts.get(0), def.db) 472 && SQLUtil.sameName(vendor, ESQLDataObjectType.dotSchema, 473 callParts.get(1), defParts.get(0)); 474 } 475 // Other combinations (e.g. qualified call vs one-part definition): 476 // conservative no-match, exactly like the P5 resolver. 477 return false; 478 } 479 // Oracle: one-part call matches by simple name; a schema-qualified 480 // call requires the definition's schema KNOWN and equal (an unknown 481 // definition schema is not a wildcard). 482 if (callParts.size() == 1) { 483 return true; 484 } 485 if (callParts.size() == 2) { 486 return def.schema != null 487 && SQLUtil.sameName(vendor, ESQLDataObjectType.dotSchema, 488 callParts.get(0), def.schema); 489 } 490 return false; 491 } 492 493 /** P5 {@code sameEffectiveDatabase} for one/two-part calls: both unknown 494 * ⇒ same analysis default; one unknown ⇒ NOT provably same, refuse. */ 495 private boolean sameEffectiveDb(String defDb, String callerDb) { 496 if (defDb == null && callerDb == null) { 497 return true; 498 } 499 if (defDb == null || callerDb == null) { 500 return false; 501 } 502 return SQLUtil.sameName(vendor, ESQLDataObjectType.dotCatalog, 503 defDb, callerDb); 504 } 505 506 /** The definition's declared name segments (as written, catalog-first). */ 507 private List<String> definitionNameParts(RoutineCatalog.Definition def) { 508 if (def.stmt instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlCreateProcedure) { 509 gudusoft.gsqlparser.stmt.mssql.TMssqlCreateProcedure proc = 510 (gudusoft.gsqlparser.stmt.mssql.TMssqlCreateProcedure) def.stmt; 511 if (proc.getProcedureName() != null) { 512 return SQLUtil.parseNames(proc.getProcedureName().toString()); 513 } 514 } 515 List<String> parts = new ArrayList<String>(); 516 if (def.schema != null) { 517 parts.add(def.schema); 518 } 519 parts.add(def.simple); 520 return parts; 521 } 522 523 /** Schema segment of an MSSQL definition name (identity carries it). */ 524 private String definitionSchema(RoutineCatalog.Definition def) { 525 return def.identity == null || def.identity.getCanonSchema() == null 526 || def.identity.getCanonSchema().length() == 0 527 ? null : def.identity.getCanonSchema(); 528 } 529 530 /** Named/positional + default-fill compatibility (§2.1b step 1). */ 531 private boolean arityCompatible(List<CallSiteRef.Actual> actuals, 532 RoutineIdentity identity) { 533 int paramCount = identity.getParamNames().size(); 534 if (actuals.size() > paramCount) { 535 return false; 536 } 537 Map<Integer, Integer> mapped = mapActualsToFormals(actuals, identity); 538 if (mapped == null) { 539 return false; 540 } 541 for (int p = 0; p < paramCount; p++) { 542 if (!mapped.containsKey(p) && !identity.getParamHasDefault().get(p)) { 543 return false; 544 } 545 } 546 // An explicit DEFAULT actual requires a DECLARED default on its 547 // formal (codex-B3-r1 finding 11). 548 for (Map.Entry<Integer, Integer> e : mapped.entrySet()) { 549 if (actuals.get(e.getValue()).isDefaultToken() 550 && !identity.getParamHasDefault().get(e.getKey())) { 551 return false; 552 } 553 } 554 return true; 555 } 556 557 /** Formal position → actual position; null when a named actual matches no 558 * formal or positional/named mixing is inconsistent. */ 559 private Map<Integer, Integer> mapActualsToFormals( 560 List<CallSiteRef.Actual> actuals, RoutineIdentity identity) { 561 Map<Integer, Integer> result = new LinkedHashMap<Integer, Integer>(); 562 List<String> paramNames = identity.getParamNames(); 563 boolean namedSeen = false; 564 for (CallSiteRef.Actual actual : actuals) { 565 if (actual.getNamedFormal() != null) { 566 namedSeen = true; 567 int formal = formalPositionOf(actual.getNamedFormal(), identity); 568 if (formal < 0 || result.containsKey(formal)) { 569 return null; 570 } 571 result.put(formal, actual.getPosition()); 572 } else { 573 if (namedSeen) { 574 // positional after named is invalid in both vendors 575 return null; 576 } 577 int formal = actual.getPosition(); 578 if (formal >= paramNames.size()) { 579 return null; 580 } 581 result.put(formal, actual.getPosition()); 582 } 583 } 584 return result; 585 } 586 587 /** Position of a named actual's formal; -1 when absent. Compared over 588 * the RAW declared spellings via the equality facade — re-keying the 589 * stored canonical text would double-fold quoted names (a declared 590 * {@code "x"} stored as {@code x} must not re-fold to {@code X} — 591 * codex-B3-r2 finding 12). */ 592 private int formalPositionOf(String namedFormal, RoutineIdentity identity) { 593 List<String> rawNames = identity.getRawParamNames(); 594 for (int i = 0; i < rawNames.size(); i++) { 595 if (SQLUtil.sameName(vendor, ESQLDataObjectType.dotColumn, 596 stripAt(namedFormal), stripAt(rawNames.get(i)))) { 597 return i; 598 } 599 } 600 return -1; 601 } 602 603 /** Both call-site and declaration @-prefix variants occur; the prefix is 604 * not an identifier character, so it is stripped outside folding. */ 605 private static String stripAt(String name) { 606 return name != null && name.startsWith("@") ? name.substring(1) : name; 607 } 608}