001package gudusoft.gsqlparser.dlineage;
002
003import gudusoft.gsqlparser.*;
004import gudusoft.gsqlparser.dlineage.dataflow.model.*;
005import gudusoft.gsqlparser.dlineage.util.DlineageUtil;
006import gudusoft.gsqlparser.nodes.*;
007import gudusoft.gsqlparser.stmt.*;
008import gudusoft.gsqlparser.stmt.oracle.*;
009import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType;
010import gudusoft.gsqlparser.sqlenv.QualifiedNameHierarchy;
011import gudusoft.gsqlparser.util.Logger;
012import gudusoft.gsqlparser.util.LoggerFactory;
013import gudusoft.gsqlparser.util.SQLUtil;
014
015import java.util.*;
016
017/**
018 * Handles Oracle pipelined table function cross-boundary data lineage stitching.
019 *
020 * <p>Two-phase approach:
021 * <ol>
022 *   <li>Index phase: indexes object types, collection types, and pipelined function signatures</li>
023 *   <li>Stitch phase: at call sites, appends lineage from function body to caller columns</li>
024 * </ol>
025 */
026public class PipelinedFunctionAnalyzer {
027
028    private static final Logger logger = LoggerFactory.getLogger(PipelinedFunctionAnalyzer.class);
029
030    private final ModelBindingManager modelManager;
031    private final ModelFactory modelFactory;
032    private final Option option;
033
034    public PipelinedFunctionAnalyzer(ModelBindingManager modelManager, ModelFactory modelFactory, Option option) {
035        this.modelManager = modelManager;
036        this.modelFactory = modelFactory;
037        this.option = option;
038    }
039
040    // ---- Phase 1: Index CREATE TYPE AS OBJECT ----
041
042    public void indexObjectType(TPlsqlCreateType stmt) {
043        if (!option.isEnablePipelinedStitching()) return;
044        if (stmt.getTypeAttributes() == null || stmt.getTypeAttributes().size() == 0) return;
045        if (stmt.getTypeName() == null) return;
046
047        String typeName = stmt.getTypeName().toString().trim();
048        List<String> fieldNames = new ArrayList<>();
049        for (int i = 0; i < stmt.getTypeAttributes().size(); i++) {
050            TTypeAttribute attr = stmt.getTypeAttributes().getAttributeItem(i);
051            if (attr.getAttributeName() != null) {
052                fieldNames.add(attr.getAttributeName().toString());
053            }
054        }
055        modelManager.indexObjectTypeFields(typeKey(typeName), fieldNames);
056        // Also index by bare name (no schema prefix)
057        String bareName = getBareName(typeName);
058        if (!bareName.equals(typeName)) {
059            modelManager.indexObjectTypeFields(typeKey(bareName), fieldNames);
060        }
061        logger.trace("Indexed object type: " + typeName + " (bare=" + bareName + "), fields=" + fieldNames);
062    }
063
064    // ---- Phase 1: Index TABLE OF collection types ----
065
066    public void indexCollectionType(TPlsqlTableTypeDefStmt stmt) {
067        if (!option.isEnablePipelinedStitching()) return;
068        if (stmt.getTypeName() == null || stmt.getElementDataType() == null) return;
069
070        String collectionName = stmt.getTypeName().toString().trim();
071        String elementTypeName = stmt.getElementDataType().toString().trim();
072        modelManager.indexCollectionElementType(typeKey(collectionName), elementTypeName);
073        // Also index by bare name
074        String bareCollection = getBareName(collectionName);
075        if (!bareCollection.equals(collectionName)) {
076            modelManager.indexCollectionElementType(typeKey(bareCollection), elementTypeName);
077        }
078        logger.trace("Indexed collection type: " + collectionName + " -> " + elementTypeName);
079    }
080
081    // ---- Phase 2: Detect pipelined function and build signature ----
082
083    public void analyzePipelinedFunction(TPlsqlCreateFunction func) {
084        if (!option.isEnablePipelinedStitching()) return;
085
086        // Scan body for PIPE ROW statements using iterative traversal
087        boolean hasPipeRow = false;
088        List<TPlsqlPipeRowStmt> pipeRowStmts = new ArrayList<>();
089        List<TAssignStmt> assignStmts = new ArrayList<>();
090        List<TLoopStmt> cursorLoops = new ArrayList<>();
091
092        Deque<TCustomSqlStatement> stack = new ArrayDeque<>();
093        // Push body statements
094        for (int i = func.getBodyStatements().size() - 1; i >= 0; i--) {
095            stack.push(func.getBodyStatements().get(i));
096        }
097        // Also check inner statements
098        for (int i = func.getStatements().size() - 1; i >= 0; i--) {
099            stack.push(func.getStatements().get(i));
100        }
101
102        while (!stack.isEmpty()) {
103            TCustomSqlStatement s = stack.pop();
104            if (s instanceof TPlsqlPipeRowStmt) {
105                pipeRowStmts.add((TPlsqlPipeRowStmt) s);
106                hasPipeRow = true;
107            } else if (s instanceof TAssignStmt) {
108                assignStmts.add((TAssignStmt) s);
109            } else if (s instanceof TLoopStmt) {
110                TLoopStmt loop = (TLoopStmt) s;
111                if (loop.getSubquery() != null || loop.getCursorName() != null) {
112                    cursorLoops.add(loop);
113                }
114                // Push loop body statements
115                for (int i = loop.getBodyStatements().size() - 1; i >= 0; i--) {
116                    stack.push(loop.getBodyStatements().get(i));
117                }
118            } else if (s instanceof TIfStmt) {
119                TIfStmt ifStmt = (TIfStmt) s;
120                if (ifStmt.getThenStatements() != null) {
121                    for (int i = ifStmt.getThenStatements().size() - 1; i >= 0; i--) {
122                        stack.push(ifStmt.getThenStatements().get(i));
123                    }
124                }
125                if (ifStmt.getElseStatements() != null) {
126                    for (int i = ifStmt.getElseStatements().size() - 1; i >= 0; i--) {
127                        stack.push(ifStmt.getElseStatements().get(i));
128                    }
129                }
130                if (ifStmt.getElseifStatements() != null) {
131                    for (int i = ifStmt.getElseifStatements().size() - 1; i >= 0; i--) {
132                        stack.push(ifStmt.getElseifStatements().get(i));
133                    }
134                }
135            } else if (s instanceof TCommonBlock) {
136                TCommonBlock block = (TCommonBlock) s;
137                if (block.getBodyStatements() != null) {
138                    for (int i = block.getBodyStatements().size() - 1; i >= 0; i--) {
139                        stack.push(block.getBodyStatements().get(i));
140                    }
141                }
142                if (block.getStatements() != null) {
143                    for (int i = block.getStatements().size() - 1; i >= 0; i--) {
144                        stack.push(block.getStatements().get(i));
145                    }
146                }
147            }
148            // Fallback: try to push sub-statements for any other compound statement type
149            else {
150                if (s.getStatements() != null && s.getStatements().size() > 0) {
151                    for (int i = s.getStatements().size() - 1; i >= 0; i--) {
152                        stack.push(s.getStatements().get(i));
153                    }
154                }
155                if (s instanceof TBlockSqlStatement) {
156                    TBlockSqlStatement blockStmt = (TBlockSqlStatement) s;
157                    if (blockStmt.getBodyStatements() != null && blockStmt.getBodyStatements().size() > 0) {
158                        for (int i = blockStmt.getBodyStatements().size() - 1; i >= 0; i--) {
159                            stack.push(blockStmt.getBodyStatements().get(i));
160                        }
161                    }
162                }
163            }
164        }
165
166        if (!hasPipeRow) return;
167
168        // Build signature
169        PipelinedFunctionSignature sig = new PipelinedFunctionSignature();
170        String funcName = func.getFunctionName() != null ? func.getFunctionName().toString() : "";
171
172        // Build function key with package context
173        String packagePrefix = "";
174        if (ModelBindingManager.getGlobalOraclePackage() != null) {
175            packagePrefix = ModelBindingManager.getGlobalOraclePackage().getName();
176            if (packagePrefix != null && !packagePrefix.isEmpty()) {
177                packagePrefix = packagePrefix + ".";
178            } else {
179                packagePrefix = "";
180            }
181        }
182
183        int argCount = func.getParameterDeclarations() != null ? func.getParameterDeclarations().size() : 0;
184
185        String fullName = packagePrefix + funcName;
186        String nakedName = getBareName(funcName);
187
188        sig.setFunctionKey(fullName);
189        sig.setNakedKey(nakedName);
190        sig.setFunctionIdentity(routineKey(fullName));
191        sig.setNakedIdentity(routineKey(nakedName));
192        sig.setArgCount(argCount);
193
194        // Resolve return type → collection type → element/object type → output columns
195        TTypeName returnType = func.getReturnDataType();
196        logger.trace("Pipelined func " + funcName + ": returnType=" + (returnType != null ? returnType.toString() : "null"));
197        if (returnType != null) {
198            String returnTypeName = returnType.toString().trim();
199            logger.trace("  returnTypeName=" + returnTypeName);
200            // Try as collection type first
201            String elementType = modelManager.getCollectionElementType(typeKey(returnTypeName));
202            if (elementType == null) {
203                elementType = modelManager.getCollectionElementType(typeKey(getBareName(returnTypeName)));
204            }
205            if (elementType != null) {
206                sig.setCollectionTypeKey(returnTypeName);
207                sig.setRowTypeKey(elementType);
208            } else {
209                // Maybe the return type IS the object type directly
210                sig.setRowTypeKey(returnTypeName);
211            }
212
213            // Resolve output columns from object type index
214            String rowTypeKey = sig.getRowTypeKey();
215            List<String> fields = null;
216            if (rowTypeKey != null) {
217                fields = modelManager.getObjectTypeFieldsByKey(typeKey(rowTypeKey));
218                if (fields == null) {
219                    fields = modelManager.getObjectTypeFieldsByKey(typeKey(getBareName(rowTypeKey)));
220                }
221            }
222            if (fields != null) {
223                sig.setOutputColumns(new ArrayList<>(fields));
224            }
225        }
226
227        // Phase 3: Extract PIPE ROW lineage
228        if (sig.getOutputColumns() != null && !sig.getOutputColumns().isEmpty()) {
229            resolvePipeRowLineage(func, sig, pipeRowStmts, assignStmts, cursorLoops);
230        } else {
231            sig.setStatus(PipelinedFunctionSignature.ResolutionStatus.UNRESOLVED);
232        }
233
234        modelManager.addPipelinedSignature(sig);
235        logger.trace("Pipelined signature built: key=" + sig.getFunctionKey()
236                + ", nakedKey=" + sig.getNakedKey()
237                + ", resolved=" + sig.isResolved()
238                + ", status=" + sig.getStatus()
239                + ", outputCols=" + sig.getOutputColumns()
240                + ", lineageKeys=" + sig.getLineageByOutputColumn().keySet()
241                + ", lineageEntries=" + sig.getLineageByOutputColumn().size());
242        for (Map.Entry<String, List<PipelinedSourceRef>> entry : sig.getLineageByOutputColumn().entrySet()) {
243            logger.trace("  " + entry.getKey() + " <- " + entry.getValue());
244        }
245    }
246
247    // ---- Phase 3: PIPE ROW lineage extraction ----
248
249    private void resolvePipeRowLineage(TPlsqlCreateFunction func, PipelinedFunctionSignature sig,
250                                       List<TPlsqlPipeRowStmt> pipeRowStmts,
251                                       List<TAssignStmt> assignStmts,
252                                       List<TLoopStmt> cursorLoops) {
253        // Build cursor bindings from explicit CURSOR declarations
254        // Check both declare section (getStatements) and body (getBodyStatements)
255        Map<String, TSelectSqlStatement> cursorBindings = new LinkedHashMap<>();
256        for (int i = 0; i < func.getStatements().size(); i++) {
257            TCustomSqlStatement s = func.getStatements().get(i);
258            if (s instanceof TCursorDeclStmt) {
259                TCursorDeclStmt cursorDecl = (TCursorDeclStmt) s;
260                if (cursorDecl.getCursorName() != null && cursorDecl.getSubquery() != null) {
261                    cursorBindings.put(cursorDecl.getCursorName().toString().toLowerCase(Locale.ROOT), cursorDecl.getSubquery());
262                }
263            }
264        }
265        for (int i = 0; i < func.getBodyStatements().size(); i++) {
266            TCustomSqlStatement s = func.getBodyStatements().get(i);
267            if (s instanceof TCursorDeclStmt) {
268                TCursorDeclStmt cursorDecl = (TCursorDeclStmt) s;
269                if (cursorDecl.getCursorName() != null && cursorDecl.getSubquery() != null) {
270                    cursorBindings.put(cursorDecl.getCursorName().toString().toLowerCase(Locale.ROOT), cursorDecl.getSubquery());
271                }
272            }
273        }
274
275        // Build loop variable bindings: loopVar -> cursor SELECT
276        Map<String, TSelectSqlStatement> loopVarBindings = new LinkedHashMap<>();
277        for (TLoopStmt loop : cursorLoops) {
278            String loopVar = null;
279            if (loop.getRecordName() != null) {
280                loopVar = loop.getRecordName().toString().toLowerCase(Locale.ROOT);
281            } else if (loop.getIndexName() != null) {
282                loopVar = loop.getIndexName().toString().toLowerCase(Locale.ROOT);
283            }
284            if (loopVar == null) continue;
285
286            if (loop.getSubquery() != null) {
287                // Inline SELECT: FOR item IN (SELECT ...) LOOP
288                loopVarBindings.put(loopVar, loop.getSubquery());
289            } else if (loop.getCursorName() != null) {
290                // Named cursor: FOR item IN cursorName LOOP
291                String cursorName = loop.getCursorName().toString().toLowerCase(Locale.ROOT);
292                TSelectSqlStatement cursorSelect = cursorBindings.get(cursorName);
293                if (cursorSelect != null) {
294                    loopVarBindings.put(loopVar, cursorSelect);
295                }
296                logger.trace("Named cursor loop: var=" + loopVar + ", cursor=" + cursorName
297                        + ", resolved=" + (cursorSelect != null));
298            }
299        }
300
301        // Build assignment maps
302        // outRow := ROW_TYPE(...) --> variable -> list of constructor expressions (union from multiple branches)
303        Map<String, List<TExpression>> varConstructors = new LinkedHashMap<>();
304        // outRow.field := expr --> variable -> field -> expression
305        Map<String, Map<String, TExpression>> varFieldAssignments = new LinkedHashMap<>();
306
307        for (TAssignStmt assign : assignStmts) {
308            TExpression leftExpr = assign.getLeft();
309            TExpression rightExpr = assign.getExpression();
310            if (leftExpr == null || rightExpr == null) continue;
311
312            String leftText = leftExpr.toString().trim();
313            int dotIdx = leftText.indexOf('.');
314            if (dotIdx > 0) {
315                // outRow.field := expr
316                String varName = leftText.substring(0, dotIdx).toLowerCase(Locale.ROOT);
317                String fieldName = leftText.substring(dotIdx + 1);
318                varFieldAssignments.computeIfAbsent(varName, k -> new LinkedHashMap<>())
319                        .put(fieldName, rightExpr);
320            } else {
321                // outRow := ROW_TYPE(...)
322                String varName = leftText.toLowerCase(Locale.ROOT);
323                varConstructors.computeIfAbsent(varName, k -> new ArrayList<>())
324                        .add(rightExpr);
325            }
326        }
327
328        // Check for cursor FOR loops using named cursors (FOR item IN cursorName LOOP)
329        for (TLoopStmt loop : cursorLoops) {
330            if (loop.getIndexName() != null && loop.getSubquery() == null) {
331                // Might reference a named cursor
332                String loopVar = loop.getIndexName().toString().toLowerCase(Locale.ROOT);
333                // Look for cursor name in the loop definition
334                // The cursor name would be in the loop's cursor expression
335                // This is handled differently - the subquery is already resolved
336            }
337        }
338
339        // Process each PIPE ROW statement
340        for (TPlsqlPipeRowStmt pipeRow : pipeRowStmts) {
341            TExpression expr = pipeRow.getExpression();
342            if (expr == null) continue;
343
344            String exprText = expr.toString().trim();
345
346            if (isObjectConstructor(expr)) {
347                // PIPE ROW(ROW_TYPE(arg1, arg2, ...))
348                bindConstructorArgs(sig, expr, loopVarBindings, cursorBindings);
349            } else {
350                // PIPE ROW(outRow) - variable reference
351                String varName = exprText.toLowerCase(Locale.ROOT);
352
353                // Check for constructor assignments: outRow := ROW_TYPE(...)
354                List<TExpression> constructors = varConstructors.get(varName);
355                if (constructors != null) {
356                    for (TExpression constructor : constructors) {
357                        if (isObjectConstructor(constructor)) {
358                            bindConstructorArgs(sig, constructor, loopVarBindings, cursorBindings);
359                        }
360                    }
361                }
362
363                // Check for field assignments: outRow.field := expr
364                Map<String, TExpression> fieldAssigns = varFieldAssignments.get(varName);
365                if (fieldAssigns != null) {
366                    overlayFieldAssignments(sig, fieldAssigns, loopVarBindings, cursorBindings);
367                }
368            }
369        }
370
371        sig.setResolved(true);
372        sig.setStatus(PipelinedFunctionSignature.ResolutionStatus.OK);
373    }
374
375    private boolean isObjectConstructor(TExpression expr) {
376        if (expr == null) return false;
377        // A constructor call looks like a function call: ROW_TYPE(arg1, arg2, ...)
378        if (expr.getExpressionType() == EExpressionType.function_t && expr.getFunctionCall() != null) {
379            return true;
380        }
381        // Also handle simple_object_expr_t
382        if (expr.getExpressionType() == EExpressionType.simple_object_name_t) {
383            return false;
384        }
385        // Check if it's a function call expression
386        String text = expr.toString().trim();
387        if (text.contains("(") && text.endsWith(")")) {
388            return expr.getFunctionCall() != null;
389        }
390        return false;
391    }
392
393    private void bindConstructorArgs(PipelinedFunctionSignature sig, TExpression constructorExpr,
394                                     Map<String, TSelectSqlStatement> loopVarBindings,
395                                     Map<String, TSelectSqlStatement> cursorBindings) {
396        TFunctionCall funcCall = constructorExpr.getFunctionCall();
397        if (funcCall == null || funcCall.getArgs() == null) return;
398
399        TExpressionList args = funcCall.getArgs();
400        List<String> outputColumns = sig.getOutputColumns();
401
402        for (int i = 0; i < args.size() && i < outputColumns.size(); i++) {
403            TExpression arg = args.getExpression(i);
404            String outputCol = outputColumns.get(i).toLowerCase(Locale.ROOT);
405            resolveArgLineage(sig, outputCol, arg, loopVarBindings, cursorBindings);
406        }
407    }
408
409    private void resolveArgLineage(PipelinedFunctionSignature sig, String outputCol,
410                                   TExpression arg,
411                                   Map<String, TSelectSqlStatement> loopVarBindings,
412                                   Map<String, TSelectSqlStatement> cursorBindings) {
413        if (arg == null) return;
414
415        String argText = arg.toString().trim();
416
417        // Check if it's a wrapper function like util.TO_NUMBER(item.xxx) - extract inner argument
418        TExpression innerArg = unwrapFunctionCalls(arg);
419        if (innerArg != arg) {
420            argText = innerArg.toString().trim();
421        }
422
423        // Check for loop variable reference: item.COLUMN_NAME
424        int dotIdx = argText.indexOf('.');
425        if (dotIdx > 0) {
426            String prefix = argText.substring(0, dotIdx).toLowerCase(Locale.ROOT);
427            String fieldName = argText.substring(dotIdx + 1);
428
429            // Check loop variable bindings
430            TSelectSqlStatement cursorSelect = loopVarBindings.get(prefix);
431            if (cursorSelect == null) {
432                cursorSelect = cursorBindings.get(prefix);
433            }
434            if (cursorSelect != null) {
435                // Resolve the field from the cursor's select list
436                resolveFromCursorSelect(sig, outputCol, fieldName, cursorSelect);
437                return;
438            }
439        }
440
441        // Check for simple column reference (no prefix)
442        if (dotIdx < 0 && !argText.contains("(")) {
443            // Could be a local variable or parameter - mark as unresolved for now
444            sig.addSourceRef(outputCol, new PipelinedSourceRef(
445                    "unknown", argText, PipelinedSourceRef.SourceKind.UNRESOLVED));
446            return;
447        }
448
449        // Check for literal/constant
450        if (argText.equals("null") || argText.startsWith("'") || argText.matches("-?\\d+.*")) {
451            // Constant - empty source, just preserve column structure
452            return;
453        }
454
455        // For qualified names like table.column that aren't loop variables
456        if (dotIdx > 0) {
457            String tablePart = argText.substring(0, dotIdx);
458            String colPart = argText.substring(dotIdx + 1);
459            sig.addSourceRef(outputCol, new PipelinedSourceRef(
460                    tablePart, colPart, PipelinedSourceRef.SourceKind.BASE_TABLE));
461        }
462    }
463
464    private TExpression unwrapFunctionCalls(TExpression expr) {
465        // Unwrap wrapper functions like TO_NUMBER(x), TO_CHAR(x), TRIM(x), util.TO_NUMBER(x)
466        if (expr.getExpressionType() == EExpressionType.function_t && expr.getFunctionCall() != null) {
467            TFunctionCall func = expr.getFunctionCall();
468            if (func.getArgs() != null && func.getArgs().size() == 1) {
469                String funcName = func.getFunctionName().toString().toUpperCase(Locale.ROOT);
470                // Common wrapper functions
471                if (funcName.endsWith("TO_NUMBER") || funcName.endsWith("TO_CHAR")
472                        || funcName.endsWith("TO_DATE") || funcName.equals("TRIM")
473                        || funcName.equals("NVL") || funcName.equals("COALESCE")
474                        || funcName.equals("UPPER") || funcName.equals("LOWER")
475                        || funcName.equals("CAST") || funcName.endsWith(".TO_NUMBER")) {
476                    return unwrapFunctionCalls(func.getArgs().getExpression(0));
477                }
478            }
479        }
480        return expr;
481    }
482
483    private void resolveFromCursorSelect(PipelinedFunctionSignature sig, String outputCol,
484                                         String fieldName, TSelectSqlStatement cursorSelect) {
485        // Find the column in the cursor's SELECT list that matches fieldName
486        if (cursorSelect.getResultColumnList() == null) return;
487
488        String fieldNameLower = fieldName.toLowerCase(Locale.ROOT);
489        // Strip quotes
490        fieldNameLower = SQLUtil.trimColumnStringQuote(fieldNameLower);
491
492        for (int i = 0; i < cursorSelect.getResultColumnList().size(); i++) {
493            TResultColumn rc = cursorSelect.getResultColumnList().getResultColumn(i);
494            String alias = null;
495            if (rc.getAliasClause() != null) {
496                alias = rc.getAliasClause().toString().toLowerCase(Locale.ROOT);
497                alias = SQLUtil.trimColumnStringQuote(alias);
498            }
499            String colName = null;
500            if (rc.getExpr() != null) {
501                colName = rc.getExpr().toString().toLowerCase(Locale.ROOT);
502                // Extract just the column part from table.column
503                int dot = colName.lastIndexOf('.');
504                if (dot >= 0) {
505                    colName = colName.substring(dot + 1);
506                }
507                colName = SQLUtil.trimColumnStringQuote(colName);
508            }
509
510            if (fieldNameLower.equals(alias) || fieldNameLower.equals(colName)) {
511                // Found the matching column - trace its source
512                resolveResultColumnSources(sig, outputCol, rc, cursorSelect);
513                return;
514            }
515        }
516
517        // Didn't find by name, try position if same number of columns
518        sig.addSourceRef(outputCol, new PipelinedSourceRef(
519                "cursor", fieldName, PipelinedSourceRef.SourceKind.UNRESOLVED));
520    }
521
522    private void resolveResultColumnSources(PipelinedFunctionSignature sig, String outputCol,
523                                            TResultColumn rc, TSelectSqlStatement select) {
524        resolveResultColumnSourcesWithDepth(sig, outputCol, rc, select, 0);
525    }
526
527    private static final int MAX_CTE_TRACE_DEPTH = 10;
528
529    private void resolveResultColumnSourcesWithDepth(PipelinedFunctionSignature sig, String outputCol,
530                                            TResultColumn rc, TSelectSqlStatement select, int depth) {
531        if (rc.getExpr() == null) return;
532        if (depth > MAX_CTE_TRACE_DEPTH) return;
533
534        TExpression expr = rc.getExpr();
535        // Unwrap wrapper functions
536        expr = unwrapFunctionCalls(expr);
537        String exprText = expr.toString().trim();
538
539        // Try to resolve to a base table column
540        int dotIdx = exprText.lastIndexOf('.');
541        if (dotIdx > 0) {
542            String tableRef = exprText.substring(0, dotIdx);
543            String colRef = exprText.substring(dotIdx + 1);
544
545            // Check if the table reference points to a CTE - if so, trace through it
546            TTable sourceTable = findTableByRefInSelect(tableRef, select);
547            if (sourceTable != null && sourceTable.isCTEName()) {
548                // Use the actual table name (not the alias) to find the CTE definition
549                String cteName = getFullTableName(sourceTable);
550                if (traceThroughCTE(sig, outputCol, colRef, cteName, select, depth)) {
551                    return;
552                }
553            }
554
555            // Resolve table reference against FROM clause
556            String resolvedTable = resolveTableRef(tableRef, select);
557            if (resolvedTable != null) {
558                sig.addSourceRef(outputCol, new PipelinedSourceRef(
559                        resolvedTable, SQLUtil.trimColumnStringQuote(colRef),
560                        PipelinedSourceRef.SourceKind.BASE_TABLE));
561                return;
562            }
563            // Fallback: use the table ref as-is
564            sig.addSourceRef(outputCol, new PipelinedSourceRef(
565                    tableRef, SQLUtil.trimColumnStringQuote(colRef),
566                    PipelinedSourceRef.SourceKind.BASE_TABLE));
567        } else if (exprText.equals("null") || exprText.startsWith("'") || exprText.matches("-?\\d+.*")) {
568            // Constant - just preserve column structure
569            sig.addSourceRef(outputCol, new PipelinedSourceRef(
570                    "constant", exprText, PipelinedSourceRef.SourceKind.CONST));
571        } else if (!exprText.contains("(") && !exprText.contains(" ")) {
572            // Simple column name without table prefix
573            // Try to resolve by checking sourceTable from the parser's column resolution
574            if (rc.getExpr().getObjectOperand() != null
575                    && rc.getExpr().getObjectOperand().getSourceTable() != null) {
576                TTable sourceTable = rc.getExpr().getObjectOperand().getSourceTable();
577                // If the source table is a CTE, trace through it to find actual base tables
578                if (sourceTable.isCTEName()) {
579                    String cteName = getFullTableName(sourceTable);
580                    if (traceThroughCTE(sig, outputCol, exprText, cteName, select, depth)) {
581                        return;
582                    }
583                }
584                String tableName = getFullTableName(sourceTable);
585                sig.addSourceRef(outputCol, new PipelinedSourceRef(
586                        tableName, SQLUtil.trimColumnStringQuote(exprText),
587                        PipelinedSourceRef.SourceKind.BASE_TABLE));
588            } else {
589                // Try to find which table it belongs to from the FROM clause
590                String resolvedTable = resolveColumnTable(exprText, select);
591                if (resolvedTable != null) {
592                    sig.addSourceRef(outputCol, new PipelinedSourceRef(
593                            resolvedTable, SQLUtil.trimColumnStringQuote(exprText),
594                            PipelinedSourceRef.SourceKind.BASE_TABLE));
595                } else {
596                    // Fallback: use the SELECT itself as the source - the analyzer will trace further
597                    // Use the cursor/select name as a proxy source
598                    String selectName = "cursor_result";
599                    if (select.tables != null && select.tables.size() > 0) {
600                        selectName = getFullTableName(select.tables.getTable(0));
601                    }
602                    sig.addSourceRef(outputCol, new PipelinedSourceRef(
603                            selectName, SQLUtil.trimColumnStringQuote(exprText),
604                            PipelinedSourceRef.SourceKind.CTE));
605                }
606            }
607        } else {
608            // Complex expression - try extracting column refs
609            extractColumnRefsFromExpr(sig, outputCol, expr, select);
610        }
611    }
612
613    /**
614     * Traces a column reference through a CTE to find actual base tables.
615     * Returns true if at least one base table source was found.
616     */
617    private boolean traceThroughCTE(PipelinedFunctionSignature sig, String outputCol,
618                                    String colName, String cteRef, TSelectSqlStatement select, int depth) {
619        if (depth > MAX_CTE_TRACE_DEPTH) return false;
620
621        // Find the CTE definition matching cteRef
622        TCTE cte = findCTE(cteRef, select);
623        if (cte == null || cte.getSubquery() == null) return false;
624
625        TSelectSqlStatement cteSelect = cte.getSubquery();
626        // Handle the outermost SELECT of the CTE - for UNION, use leftStmt chain
627        if (cteSelect.getSetOperatorType() != ESetOperatorType.none) {
628            // For UNION queries, trace through the first branch
629            Deque<TSelectSqlStatement> unionStack = new ArrayDeque<>();
630            unionStack.push(cteSelect);
631            while (!unionStack.isEmpty()) {
632                TSelectSqlStatement current = unionStack.pop();
633                if (current.getSetOperatorType() != ESetOperatorType.none) {
634                    if (current.getRightStmt() != null) unionStack.push(current.getRightStmt());
635                    if (current.getLeftStmt() != null) unionStack.push(current.getLeftStmt());
636                } else {
637                    // Process this leaf SELECT
638                    traceCTESelectColumn(sig, outputCol, colName, current, select, depth);
639                    return true; // Just use the first branch for lineage
640                }
641            }
642            return false;
643        }
644
645        return traceCTESelectColumn(sig, outputCol, colName, cteSelect, select, depth);
646    }
647
648    /**
649     * Traces a column through a CTE's SELECT statement to find its source.
650     */
651    private boolean traceCTESelectColumn(PipelinedFunctionSignature sig, String outputCol,
652                                          String colName, TSelectSqlStatement cteSelect,
653                                          TSelectSqlStatement outerSelect, int depth) {
654        if (cteSelect.getResultColumnList() == null) return false;
655
656        String colNameLower = SQLUtil.trimColumnStringQuote(colName.toLowerCase(Locale.ROOT));
657
658        // Find the matching result column in the CTE's SELECT list
659        for (int i = 0; i < cteSelect.getResultColumnList().size(); i++) {
660            TResultColumn rc = cteSelect.getResultColumnList().getResultColumn(i);
661            String alias = null;
662            if (rc.getAliasClause() != null) {
663                alias = rc.getAliasClause().toString().toLowerCase(Locale.ROOT);
664                alias = SQLUtil.trimColumnStringQuote(alias);
665            }
666            String rcColName = null;
667            if (rc.getExpr() != null) {
668                rcColName = rc.getExpr().toString().toLowerCase(Locale.ROOT);
669                int dot = rcColName.lastIndexOf('.');
670                if (dot >= 0) {
671                    rcColName = rcColName.substring(dot + 1);
672                }
673                rcColName = SQLUtil.trimColumnStringQuote(rcColName);
674            }
675
676            if (colNameLower.equals(alias) || colNameLower.equals(rcColName)) {
677                // Found the column - recursively resolve its source
678                resolveResultColumnSourcesWithDepth(sig, outputCol, rc, cteSelect, depth + 1);
679                return true;
680            }
681        }
682        return false;
683    }
684
685    /**
686     * Finds a CTE by name in the given SELECT or its parent statements.
687     */
688    private TCTE findCTE(String cteRef, TSelectSqlStatement select) {
689        String cteRefLower = SQLUtil.trimColumnStringQuote(cteRef.toLowerCase(Locale.ROOT));
690        // Also try bare name
691        String cteRefBare = getBareName(cteRefLower);
692
693        TCustomSqlStatement current = select;
694        while (current != null) {
695            if (current instanceof TSelectSqlStatement) {
696                TSelectSqlStatement sel = (TSelectSqlStatement) current;
697                if (sel.getCteList() != null) {
698                    for (int i = 0; i < sel.getCteList().size(); i++) {
699                        TCTE cte = sel.getCteList().getCTE(i);
700                        if (cte.getTableName() != null) {
701                            String cteName = cte.getTableName().toString().toLowerCase(Locale.ROOT);
702                            if (cteRefLower.equals(cteName) || cteRefBare.equals(cteName)) {
703                                return cte;
704                            }
705                        }
706                    }
707                }
708            }
709            current = current.getParentStmt();
710        }
711        return null;
712    }
713
714    /**
715     * Finds a TTable in the SELECT's FROM clause by reference (name or alias).
716     */
717    private TTable findTableByRefInSelect(String tableRef, TSelectSqlStatement select) {
718        if (select == null || select.tables == null) return null;
719        String refLower = SQLUtil.trimColumnStringQuote(tableRef.toLowerCase(Locale.ROOT));
720
721        for (int i = 0; i < select.tables.size(); i++) {
722            TTable t = select.tables.getTable(i);
723            if (t.getAliasClause() != null) {
724                String alias = SQLUtil.trimColumnStringQuote(t.getAliasClause().toString().toLowerCase(Locale.ROOT));
725                if (alias.equals(refLower)) return t;
726            }
727            String tName = t.getFullName() != null ? t.getFullName().toLowerCase(Locale.ROOT) : "";
728            String bareTableName = getBareName(tName);
729            if (refLower.equals(bareTableName) || refLower.equals(tName)) return t;
730        }
731        return null;
732    }
733
734    private void extractColumnRefsFromExpr(PipelinedFunctionSignature sig, String outputCol,
735                                           TExpression expr, TSelectSqlStatement select) {
736        // Use iterative traversal to find object names in the expression
737        Deque<TExpression> exprStack = new ArrayDeque<>();
738        exprStack.push(expr);
739        boolean found = false;
740
741        while (!exprStack.isEmpty()) {
742            TExpression current = exprStack.pop();
743            if (current == null) continue;
744
745            if (current.getExpressionType() == EExpressionType.simple_object_name_t
746                    && current.getObjectOperand() != null) {
747                String objName = current.getObjectOperand().toString();
748                int dot = objName.lastIndexOf('.');
749                if (dot > 0) {
750                    String tableRef = objName.substring(0, dot);
751                    String colRef = objName.substring(dot + 1);
752                    String resolvedTable = resolveTableRef(tableRef, select);
753                    sig.addSourceRef(outputCol, new PipelinedSourceRef(
754                            resolvedTable != null ? resolvedTable : tableRef,
755                            SQLUtil.trimColumnStringQuote(colRef),
756                            PipelinedSourceRef.SourceKind.BASE_TABLE));
757                    found = true;
758                }
759            }
760
761            // Push children
762            if (current.getLeftOperand() != null) exprStack.push(current.getLeftOperand());
763            if (current.getRightOperand() != null) exprStack.push(current.getRightOperand());
764            if (current.getFunctionCall() != null && current.getFunctionCall().getArgs() != null) {
765                for (int i = 0; i < current.getFunctionCall().getArgs().size(); i++) {
766                    exprStack.push(current.getFunctionCall().getArgs().getExpression(i));
767                }
768            }
769        }
770
771        if (!found) {
772            sig.addSourceRef(outputCol, new PipelinedSourceRef(
773                    "expression", expr.toString(), PipelinedSourceRef.SourceKind.UNRESOLVED));
774        }
775    }
776
777    private String resolveTableRef(String tableRef, TSelectSqlStatement select) {
778        if (select == null || select.tables == null) return null;
779        String tableRefLower = tableRef.toLowerCase(Locale.ROOT);
780        String tableRefNorm = SQLUtil.trimColumnStringQuote(tableRefLower);
781
782        for (int i = 0; i < select.tables.size(); i++) {
783            TTable t = select.tables.getTable(i);
784            // Check alias
785            if (t.getAliasClause() != null) {
786                String alias = t.getAliasClause().toString().toLowerCase(Locale.ROOT);
787                alias = SQLUtil.trimColumnStringQuote(alias);
788                if (alias.equals(tableRefNorm)) {
789                    return getFullTableName(t);
790                }
791            }
792            // Check table name
793            String tName = t.getFullName() != null ? t.getFullName().toLowerCase(Locale.ROOT) : "";
794            String bareTableName = getBareName(tName);
795            if (tableRefNorm.equals(bareTableName) || tableRefNorm.equals(tName)) {
796                return getFullTableName(t);
797            }
798        }
799
800        // Check CTEs
801        if (select.getCteList() != null) {
802            for (int i = 0; i < select.getCteList().size(); i++) {
803                TCTE cte = select.getCteList().getCTE(i);
804                if (cte.getTableName() != null) {
805                    String cteName = cte.getTableName().toString().toLowerCase(Locale.ROOT);
806                    if (tableRefNorm.equals(cteName)) {
807                        return cteName;
808                    }
809                }
810            }
811        }
812
813        // If this is a subquery's select, walk up to find CTEs
814        TCustomSqlStatement parent = select.getParentStmt();
815        if (parent instanceof TSelectSqlStatement) {
816            return resolveTableRef(tableRef, (TSelectSqlStatement) parent);
817        }
818
819        return null;
820    }
821
822    private String resolveColumnTable(String colName, TSelectSqlStatement select) {
823        if (select == null || select.tables == null) return null;
824        // Simplified: just return null to indicate we can't determine the table
825        return null;
826    }
827
828    private String getFullTableName(TTable table) {
829        if (table.getFullName() != null) {
830            return table.getFullName();
831        }
832        return table.getName();
833    }
834
835    private void overlayFieldAssignments(PipelinedFunctionSignature sig,
836                                         Map<String, TExpression> fieldAssigns,
837                                         Map<String, TSelectSqlStatement> loopVarBindings,
838                                         Map<String, TSelectSqlStatement> cursorBindings) {
839        for (Map.Entry<String, TExpression> entry : fieldAssigns.entrySet()) {
840            String fieldName = entry.getKey();
841            TExpression expr = entry.getValue();
842
843            // Find matching output column
844            String outputCol = null;
845            for (String col : sig.getOutputColumns()) {
846                if (SQLUtil.sameName(option.getVendor(), ESQLDataObjectType.dotColumn, col, fieldName)) {
847                    outputCol = col.toLowerCase(Locale.ROOT);
848                    break;
849                }
850            }
851            if (outputCol == null) continue;
852
853            resolveArgLineage(sig, outputCol, expr, loopVarBindings, cursorBindings);
854        }
855    }
856
857    // ---- Phase 4: Call-site stitching ----
858
859    /**
860     * Handle TABLE(func(...)) which is parsed as tableExpr with funcCall=null.
861     * The table name is the function name. Always defers to finalize because
862     * table columns are not yet populated during from-clause processing.
863     */
864    public void tryStitchTableExpr(TTable table) {
865        if (!option.isEnablePipelinedStitching()) return;
866        if (table.getName() == null) return;
867
868        String tableName = table.getName().trim();
869        List<String> names = new ArrayList<>();
870        names.add(tableName);
871        String bareName = getBareName(tableName);
872        if (!bareName.equals(tableName)) {
873            names.add(bareName);
874        }
875        List<Object> candidates = routineKeys(names);
876
877        // Always defer to finalize - columns aren't populated yet during FROM processing
878        modelManager.addPendingPipelinedCallSite(
879                PendingPipelinedCallSite.forFunctionKeys(names, candidates, -1, table, null));
880    }
881
882    /**
883     * Called when TABLE(func(...)) is encountered in a FROM clause.
884     * Generates candidate keys and tries to stitch immediately.
885     * If the signature isn't available yet, adds to pending list.
886     */
887    public boolean tryStitchCallSite(TTable table, TFunctionCall funcCall) {
888        if (!option.isEnablePipelinedStitching()) return false;
889
890        List<String> names = generateFunctionNameCandidates(funcCall);
891        List<Object> candidates = routineKeys(names);
892        int argCount = funcCall.getArgs() != null ? funcCall.getArgs().size() : 0;
893
894        PipelinedFunctionSignature sig = modelManager.findPipelinedSignatureByKeys(candidates, argCount);
895        if (sig == null) {
896            sig = modelManager.findPipelinedSignatureAnyByKeys(candidates);
897        }
898
899        if (sig != null && sig.isResolved()
900                && sig.getStatus() == PipelinedFunctionSignature.ResolutionStatus.OK) {
901            logger.trace("Pipelined stitch: found signature " + sig.getFunctionKey()
902                    + " for call " + funcCall.getFunctionName() + ", cols=" + sig.getOutputColumns().size()
903                    + ", lineage keys=" + sig.getLineageByOutputColumn().keySet());
904            stitchOneCallSite(table, sig);
905            return true;
906        }
907
908        // Add to pending for finalize
909        logger.trace("Pipelined stitch: no signature found for candidates=" + candidates
910                + ", adding to pending");
911        modelManager.addPendingPipelinedCallSite(
912                PendingPipelinedCallSite.forFunctionKeys(names, candidates, argCount, table, funcCall));
913        return false;
914    }
915
916    /**
917     * Stitch a single pending call site. Called from DataFlowAnalyzer with
918     * the proper statement stack context.
919     */
920    public void stitchOnePending(PendingPipelinedCallSite cs) {
921        if (!option.isEnablePipelinedStitching()) return;
922
923        PipelinedFunctionSignature sig = modelManager.findPipelinedSignatureByKeys(
924                cs.getFunctionKeys(), cs.getArgCount());
925        if (sig == null) {
926            sig = modelManager.findPipelinedSignatureAnyByKeys(cs.getFunctionKeys());
927        }
928        if (sig != null && sig.isResolved()
929                && sig.getStatus() == PipelinedFunctionSignature.ResolutionStatus.OK) {
930            stitchOneCallSite(cs.getTable(), sig);
931        }
932    }
933
934    /**
935     * Finalize hook: process all pending call sites. Called before XML emit.
936     */
937    public void stitchPendingCallSites() {
938        if (!option.isEnablePipelinedStitching()) return;
939
940        for (PendingPipelinedCallSite cs : modelManager.getPendingPipelinedCallSites()) {
941            stitchOnePending(cs);
942        }
943    }
944
945    private void stitchOneCallSite(TTable table, PipelinedFunctionSignature sig) {
946        Set<String> resolving = modelManager.getResolvingPipelinedFunctions();
947        if (resolving.contains(sig.getFunctionKey())
948                || resolving.size() >= option.getMaxPipelinedExpansionDepth()) {
949            return;
950        }
951
952        resolving.add(sig.getFunctionKey());
953        try {
954            // Get the table model bound to this function call table
955            Object tableModel = modelManager.getModel(table);
956
957            if (tableModel instanceof Table) {
958                Table t = (Table) tableModel;
959                if (t.getColumns() != null && !t.getColumns().isEmpty()) {
960                    stitchToTable(t, sig);
961                    return;
962                }
963            }
964
965            // For tableExpr tables (TABLE(func())), the table model is created
966            // differently. Try createTableFromCreateDDL which returns existing if bound.
967            Table functionTable = modelFactory.createTableFromCreateDDL(table, true);
968            if (functionTable != null && functionTable.getColumns() != null && !functionTable.getColumns().isEmpty()) {
969                stitchToTable(functionTable, sig);
970                return;
971            }
972
973            // Last resort: create output columns from the signature and stitch
974            if (functionTable != null) {
975                for (String outputCol : sig.getOutputColumns()) {
976                    TObjectName colName = new TObjectName();
977                    colName.setString(outputCol);
978                    TableColumn column = modelFactory.createTableColumn(functionTable, colName, true);
979
980                    List<PipelinedSourceRef> refs = sig.getLineageByOutputColumn().get(outputCol.toLowerCase(Locale.ROOT));
981                    if (refs == null || refs.isEmpty()) continue;
982
983                    Set<String> addedSources = new HashSet<>();
984                    int added = 0;
985                    for (PipelinedSourceRef ref : refs) {
986                        if (added >= option.getMaxStitchedSourcesPerColumn()) break;
987                        if (ref.getSourceKind() == PipelinedSourceRef.SourceKind.UNRESOLVED
988                                || ref.getSourceKind() == PipelinedSourceRef.SourceKind.CONST) {
989                            continue;
990                        }
991                        String sourceKey = ref.getParentName() + "." + ref.getColumnName();
992                        if (!addedSources.add(sourceKey)) continue; // dedup
993
994                        Table sourceTable = modelFactory.createTableByName(ref.getParentName(), false);
995                        TObjectName sourceColName = new TObjectName();
996                        sourceColName.setString(ref.getColumnName());
997                        TableColumn sourceCol = modelFactory.createTableColumn(sourceTable, sourceColName, true);
998
999                        DataFlowRelationship relation = modelFactory.createDataFlowRelation();
1000                        relation.setEffectType(EffectType.select);
1001                        relation.addSource(new TableColumnRelationshipElement(sourceCol));
1002                        relation.setTarget(new TableColumnRelationshipElement(column));
1003                        added++;
1004                    }
1005                }
1006            }
1007        } catch (Exception e) {
1008            logger.error("Error stitching pipelined call site for " + sig.getFunctionKey(), e);
1009        } finally {
1010            resolving.remove(sig.getFunctionKey());
1011        }
1012    }
1013
1014    private void stitchToTable(Table functionTable, PipelinedFunctionSignature sig) {
1015        if (functionTable == null || functionTable.getColumns() == null) return;
1016
1017        for (TableColumn callerCol : functionTable.getColumns()) {
1018            String callerColName = callerCol.getName();
1019            if (callerColName == null) continue;
1020
1021            String mappedKey = mapByNameThenPosition(callerColName, callerCol, functionTable, sig);
1022            if (mappedKey == null) continue;
1023
1024            List<PipelinedSourceRef> refs = sig.getLineageByOutputColumn().get(mappedKey);
1025            if (refs == null || refs.isEmpty()) continue;
1026
1027            Set<String> addedSources = new HashSet<>();
1028            int added = 0;
1029            for (PipelinedSourceRef ref : refs) {
1030                if (added >= option.getMaxStitchedSourcesPerColumn()) break;
1031                if (ref.getSourceKind() == PipelinedSourceRef.SourceKind.UNRESOLVED
1032                        || ref.getSourceKind() == PipelinedSourceRef.SourceKind.CONST) {
1033                    continue;
1034                }
1035
1036                // Dedup: skip if already added
1037                String sourceKey = ref.getParentName() + "." + ref.getColumnName();
1038                if (!addedSources.add(sourceKey)) continue;
1039
1040                // Create the stitched relationship
1041                Table sourceTable = modelFactory.createTableByName(ref.getParentName(), false);
1042                TObjectName sourceColName = new TObjectName();
1043                sourceColName.setString(ref.getColumnName());
1044                TableColumn sourceCol = modelFactory.createTableColumn(sourceTable, sourceColName, true);
1045
1046                DataFlowRelationship relation = modelFactory.createDataFlowRelation();
1047                relation.setEffectType(EffectType.select);
1048                relation.addSource(new TableColumnRelationshipElement(sourceCol));
1049                relation.setTarget(new TableColumnRelationshipElement(callerCol));
1050
1051                added++;
1052            }
1053        }
1054    }
1055
1056    private String mapByNameThenPosition(String callerColName, TableColumn callerCol,
1057                                         Table functionTable, PipelinedFunctionSignature sig) {
1058        List<String> outputColumns = sig.getOutputColumns();
1059        if (outputColumns == null || outputColumns.isEmpty()) return null;
1060
1061        // 1. Exact name match
1062        for (String out : outputColumns) {
1063            if (out.equals(callerColName)) return out.toLowerCase(Locale.ROOT);
1064        }
1065
1066        // 2. Normalized name match (case-insensitive, strip quotes)
1067        String normalizedCaller = SQLUtil.trimColumnStringQuote(callerColName.toLowerCase(Locale.ROOT));
1068        for (String out : outputColumns) {
1069            String normalizedOut = SQLUtil.trimColumnStringQuote(out.toLowerCase(Locale.ROOT));
1070            if (normalizedOut.equals(normalizedCaller)) return normalizedOut;
1071        }
1072
1073        // 3. Position fallback (strict prerequisites)
1074        if (functionTable.getColumns() != null
1075                && functionTable.getColumns().size() == outputColumns.size()) {
1076            int idx = functionTable.getColumns().indexOf(callerCol);
1077            if (idx >= 0 && idx < outputColumns.size()) {
1078                return outputColumns.get(idx).toLowerCase(Locale.ROOT);
1079            }
1080        }
1081
1082        return null;
1083    }
1084
1085    // ---- Utility methods ----
1086
1087    /** The names, as written, a call's function may be registered under, most specific first. */
1088    private List<String> generateFunctionNameCandidates(TFunctionCall funcCall) {
1089        List<String> candidates = new ArrayList<>();
1090        String funcName = funcCall.getFunctionName().toString().trim();
1091
1092        // Try various levels of qualification
1093        // Full name as-is
1094        candidates.add(funcName);
1095
1096        // With package prefix from current context
1097        if (ModelBindingManager.getGlobalOraclePackage() != null) {
1098            String pkgName = ModelBindingManager.getGlobalOraclePackage().getName();
1099            if (pkgName != null && !pkgName.isEmpty()) {
1100                candidates.add(pkgName + "." + funcName);
1101            }
1102        }
1103
1104        // Bare name (strip schema/package prefix)
1105        String bareName = getBareName(funcName);
1106        if (!bareName.equals(funcName)) {
1107            candidates.add(bareName);
1108        }
1109
1110        return candidates;
1111    }
1112
1113    private static List<Object> routineKeys(List<String> names) {
1114        List<Object> keys = new ArrayList<>(names.size());
1115        for (String name : names) {
1116            keys.add(routineKey(name));
1117        }
1118        return keys;
1119    }
1120
1121    /** The identity of a type name as written, under the analysis's rules; an Oracle type is named as a table. */
1122    private static Object typeKey(String written) {
1123        return written == null ? null
1124                : DlineageUtil.lexicalNameKey(ModelBindingManager.getGlobalVendor(), written.trim(),
1125                        ESQLDataObjectType.dotTable);
1126    }
1127
1128    /** The identity of a function name as written, package or schema included, under the analysis's rules. */
1129    private static Object routineKey(String written) {
1130        return written == null ? null
1131                : DlineageUtil.lexicalNameKey(ModelBindingManager.getGlobalVendor(), written.trim(),
1132                        ESQLDataObjectType.dotFunction);
1133    }
1134
1135    /** The last segment of a name as written: the name without its schema or package prefix. */
1136    private static String getBareName(String name) {
1137        if (name == null) return "";
1138        String written = name.trim();
1139        EDbVendor vendor = ModelBindingManager.getGlobalVendor();
1140        if (vendor == null) return written;
1141        List<String> segments = QualifiedNameHierarchy.split(vendor, written);
1142        return segments == null || segments.isEmpty() ? written : segments.get(segments.size() - 1);
1143    }
1144}