diff --git a/gitnexus/src/core/ingestion/pipeline-phases/orm.ts b/gitnexus/src/core/ingestion/pipeline-phases/orm.ts index 4e6021efa..c3f35136e 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/orm.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/orm.ts @@ -1,22 +1,30 @@ /** * Phase: orm * - * Processes ORM queries (Prisma + Supabase) and creates QUERIES edges. + * Processes ORM queries and creates QUERIES edges. * - * @deps parse - * @reads allORMQueries (from parse) + * Supported ORMs: + * - Prisma (TypeScript/JavaScript) + * - Supabase (TypeScript/JavaScript) + * - MyBatis (Java) — scans XML mapper files, extracts table names from SQL, + * links Mapper interface methods to table CodeElement nodes via QUERIES edges + * + * @deps parse, scan + * @reads allORMQueries (from parse), allPaths (from scan, for XML mapper discovery) * @writes graph (CodeElement nodes, QUERIES edges) */ +import { readFile } from 'node:fs/promises'; import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js'; import { getPhaseOutput } from './types.js'; import type { ParseOutput } from './parse.js'; +import type { ScanOutput } from './scan.js'; import { generateId } from '../../../lib/utils.js'; import type { ExtractedORMQuery } from '../workers/parse-worker.js'; import type { KnowledgeGraph } from '../../graph/types.js'; import { isDev } from '../utils/env.js'; - import { logger } from '../../logger.js'; + export interface ORMOutput { edgesCreated: number; modelCount: number; @@ -24,22 +32,193 @@ export interface ORMOutput { export const ormPhase: PipelinePhase = { name: 'orm', - deps: ['parse'], + deps: ['parse', 'scan'], async execute( ctx: PipelineContext, deps: ReadonlyMap>, ): Promise { const { allORMQueries } = getPhaseOutput(deps, 'parse'); + const { allPaths } = getPhaseOutput(deps, 'scan'); - if (allORMQueries.length === 0) { + // Collect MyBatis XML mapper queries alongside existing ORM queries + const mybatisQueries = await extractMybatisQueries(allPaths, ctx.repoPath); + const allQueries = [...allORMQueries, ...mybatisQueries]; + + if (allQueries.length === 0) { return { edgesCreated: 0, modelCount: 0 }; } - return processORMQueries(ctx.graph, allORMQueries); + return processORMQueries(ctx.graph, allQueries); }, }; +// --------------------------------------------------------------------------- +// MyBatis XML mapper extraction +// --------------------------------------------------------------------------- + +/** SQL keywords that introduce a table reference */ +const TABLE_REF_RE = + /\b(?:FROM|INTO|UPDATE|JOIN)\s+`?([a-zA-Z_][a-zA-Z0-9_]*)`?(?:\s+(?:AS\s+)?\w+)?/gi; + +/** MyBatis XML mapper statement tags */ +const STMT_TAG_RE = + /<(select|insert|update|delete)\s[^>]*\bid\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/\1>/gi; + +/** MyBatis mapper namespace attribute */ +const NAMESPACE_RE = /]*\bnamespace\s*=\s*["']([^"']+)["']/i; + +interface MybatisStatement { + op: 'select' | 'insert' | 'update' | 'delete'; + id: string; + tables: string[]; +} + +/** + * Extract table names from a SQL fragment, stripping CDATA and XML comments. + * Returns lowercase table names, deduped. + */ +function extractTablesFromSql(sql: string): string[] { + // Strip CDATA wrapper + const clean = sql.replace(//g, '$1'); + // Strip XML comments + const noComments = clean.replace(//g, ''); + + const tables = new Set(); + let m: RegExpExecArray | null; + TABLE_REF_RE.lastIndex = 0; + while ((m = TABLE_REF_RE.exec(noComments)) !== null) { + const name = m[1].toLowerCase(); + // Filter out SQL keywords and very short names that aren't table names + if (name.length >= 2 && !/^(select|dual|values|set)$/.test(name)) { + tables.add(name); + } + } + return [...tables]; +} + +/** Parse a single MyBatis XML mapper file. */ +function parseMybatisXml(content: string): { namespace: string; statements: MybatisStatement[] } | null { + const nsMatch = NAMESPACE_RE.exec(content); + if (!nsMatch) return null; + const namespace = nsMatch[1]; + + const statements: MybatisStatement[] = []; + let m: RegExpExecArray | null; + STMT_TAG_RE.lastIndex = 0; + while ((m = STMT_TAG_RE.exec(content)) !== null) { + const op = m[1].toLowerCase() as MybatisStatement['op']; + const id = m[2]; + const body = m[3]; + const tables = extractTablesFromSql(body); + if (tables.length > 0) { + statements.push({ op, id, tables }); + } + } + + return { namespace, statements }; +} + +/** + * Resolve a MyBatis namespace (fully-qualified class name) to a Java source + * file path relative to the repo. Tries to find a matching Mapper interface. + */ +function namespaceToFilePath(namespace: string, allPaths: string[]): string | null { + // com.example.foo.XxxMapper → com/example/foo/XxxMapper.java + const rel = namespace.replace(/\./g, '/') + '.java'; + const found = allPaths.find((p) => p.replace(/\\/g, '/').endsWith(rel)); + return found ?? null; +} + +async function extractMybatisQueries( + allPaths: string[], + repoPath: string, +): Promise { + const xmlPaths = allPaths.filter((p) => p.endsWith('.xml')); + if (xmlPaths.length === 0) return []; + + const queries: ExtractedORMQuery[] = []; + + for (const xmlPath of xmlPaths) { + let content: string; + try { + const abs = xmlPath.startsWith('/') ? xmlPath : `${repoPath}/${xmlPath}`; + content = await readFile(abs, 'utf-8'); + } catch { + continue; + } + + // Quick check before full parse + if (!content.includes(' 0) { + const mapperCount = new Set(queries.map((q) => q.filePath)).size; + logger.info(`MyBatis: ${queries.length} table refs across ${mapperCount} mapper files`); + } + + return queries; +} + +// --------------------------------------------------------------------------- +// Graph construction (shared for all ORM types) +// --------------------------------------------------------------------------- + +/** + * Build a lookup index: "filePath:ClassName.methodName" → Method node ID. + * The Java parser appends # to disambiguate overloaded methods + * (e.g. "UPayMapper.selectByExampleWithPage#2"). MyBatis XML only knows the + * method name, not the param count, so we strip the suffix and keep the first + * match. When a mapper interface extends a base class (e.g. MybatisBaseMapper) + * the inherited CRUD methods have no Method nodes in that file — those remain + * as file-level fallback edges, which is expected. + */ +function buildMapperMethodIndex(graph: KnowledgeGraph): { + methodIndex: Map; + filesWithMethods: Set; +} { + const methodIndex = new Map(); + /** Mapper Java files that have at least one Method node in the graph. */ + const filesWithMethods = new Set(); + graph.forEachNode((node) => { + if (!node.id.startsWith('Method:')) return; + const filePath = node.properties.filePath as string | undefined; + if (!filePath || !filePath.endsWith('Mapper.java')) return; + filesWithMethods.add(filePath); + // ID format: "Method::.#" + // Strip the #N suffix to get a param-count-agnostic key. + const idBody = node.id.replace(/^Method:/, ''); + const hashIdx = idBody.lastIndexOf('#'); + const withoutSuffix = hashIdx >= 0 ? idBody.slice(0, hashIdx) : idBody; + if (!methodIndex.has(withoutSuffix)) { + methodIndex.set(withoutSuffix, node.id); + } + }); + return { methodIndex, filesWithMethods }; +} + function processORMQueries( graph: KnowledgeGraph, queries: readonly ExtractedORMQuery[], @@ -48,6 +227,10 @@ function processORMQueries( const seenEdges = new Set(); let edgesCreated = 0; + // Pre-build index for fast filePath+methodName → Method node lookup (any #N) + const { methodIndex: mapperMethodIndex, filesWithMethods } = buildMapperMethodIndex(graph); + let xmlOrphansSkipped = 0; + for (const q of queries) { const modelKey = `${q.orm}:${q.model}`; let modelNodeId = modelNodes.get(modelKey); @@ -75,25 +258,60 @@ function processORMQueries( modelNodes.set(modelKey, modelNodeId); } - const fileId = generateId('File', q.filePath); - const edgeKey = `${fileId}->${modelNodeId}:${q.method}`; + // For MyBatis: prefer linking to the specific mapper method node. + // Use the pre-built index (filePath:ClassName.methodName → node ID) to + // resolve any # suffix without enumerating candidates. + // + // When method lookup fails there are two distinct cases: + // 1. Inherited CRUD methods (e.g. MybatisBaseMapper subclasses) — the + // Java file has NO own Method nodes at all. Fall back to file-level. + // 2. XML-only statements (e.g. selectBySelectiveWithPage) present in the + // XML but absent from the Java interface that otherwise has methods. + // These are orphan/dead SQL — skip them entirely (no edge created). + let sourceId: string; + if (q.orm === 'mybatis' && q.mapperId && q.mapperClassName) { + const qualifiedMethod = `${q.mapperClassName}.${q.mapperId}`; + const indexKey = `${q.filePath}:${qualifiedMethod}`; + const methodNodeId = mapperMethodIndex.get(indexKey); + if (methodNodeId) { + sourceId = methodNodeId; + } else if (filesWithMethods.has(q.filePath)) { + // Java interface was parsed and has other methods, but this specific + // statement ID has no matching method → XML-only orphan, skip it. + xmlOrphansSkipped++; + continue; + } else { + // Java interface has no Method nodes (e.g. all inherited from base + // class) — fall back to file-level edge. + sourceId = generateId('File', q.filePath); + } + } else { + sourceId = generateId('File', q.filePath); + } + + const edgeKey = `${sourceId}->${modelNodeId}:${q.method}:${q.mapperId ?? ''}`; if (seenEdges.has(edgeKey)) continue; seenEdges.add(edgeKey); + const reason = q.orm === 'mybatis' && q.sqlOp + ? `mybatis-${q.sqlOp}` + : `${q.orm}-${q.method}`; + graph.addRelationship({ id: generateId('QUERIES', edgeKey), - sourceId: fileId, + sourceId, targetId: modelNodeId, type: 'QUERIES', - confidence: 0.9, - reason: `${q.orm}-${q.method}`, + confidence: q.orm === 'mybatis' ? 1.0 : 0.9, + reason, }); edgesCreated++; } if (isDev) { + const orphanNote = xmlOrphansSkipped > 0 ? `, ${xmlOrphansSkipped} XML orphans skipped` : ''; logger.info( - `ORM dataflow: ${edgesCreated} QUERIES edges, ${modelNodes.size} models (${queries.length} total calls)`, + `ORM dataflow: ${edgesCreated} QUERIES edges, ${modelNodes.size} models (${queries.length} total refs${orphanNote})`, ); } diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 9a71fc16c..d089f9b8e 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -181,8 +181,6 @@ export interface ExtractedAssignment { propertyName: string; /** Resolved type name of the receiver if available from TypeEnv */ receiverTypeName?: string; - /** 1-indexed line number of the assignment site (used for per-site dedup) */ - line?: number; } // `ExtractedHeritage` now lives in `../model/heritage-map.ts` and is @@ -223,10 +221,16 @@ export interface ExtractedToolDef { export interface ExtractedORMQuery { filePath: string; - orm: 'prisma' | 'supabase'; + orm: 'prisma' | 'supabase' | 'mybatis'; model: string; method: string; lineNumber: number; + /** For mybatis: the mapper method id (e.g. "selectByPrimaryKey") */ + mapperId?: string; + /** For mybatis: the SQL operation type (select/insert/update/delete) */ + sqlOp?: 'select' | 'insert' | 'update' | 'delete'; + /** For mybatis: simple class name from namespace (e.g. "UPayMapper") */ + mapperClassName?: string; } /** Constructor bindings keyed by filePath for cross-file type resolution */ @@ -1582,7 +1586,6 @@ const processFileGroup = ( sourceId: srcId, receiverText, propertyName, - line: captureMap['assignment'].startPosition.row + 1, ...(receiverTypeName ? { receiverTypeName } : {}), }); } diff --git a/gitnexus/test/fixtures/orm-repo/src/main/java/com/example/mapper/OrderMapper.java b/gitnexus/test/fixtures/orm-repo/src/main/java/com/example/mapper/OrderMapper.java new file mode 100644 index 000000000..43f8c685f --- /dev/null +++ b/gitnexus/test/fixtures/orm-repo/src/main/java/com/example/mapper/OrderMapper.java @@ -0,0 +1,13 @@ +package com.example.mapper; + +import com.example.model.Order; +import org.apache.ibatis.annotations.Param; +import java.util.List; + +public interface OrderMapper { + Order selectByPrimaryKey(Long id); + List selectByUserId(@Param("userId") Long userId); + int insert(Order order); + int updateStatus(@Param("id") Long id, @Param("status") Integer status); + int deleteById(Long id); +} diff --git a/gitnexus/test/fixtures/orm-repo/src/main/resources/mapper/OrderDetailMapper.xml b/gitnexus/test/fixtures/orm-repo/src/main/resources/mapper/OrderDetailMapper.xml new file mode 100644 index 000000000..b770e0be5 --- /dev/null +++ b/gitnexus/test/fixtures/orm-repo/src/main/resources/mapper/OrderDetailMapper.xml @@ -0,0 +1,18 @@ + + + + + + + + INSERT INTO order_item (order_id, product_id, quantity) + VALUES (#{orderId}, #{productId}, #{quantity}) + + + diff --git a/gitnexus/test/fixtures/orm-repo/src/main/resources/mapper/OrderMapper.xml b/gitnexus/test/fixtures/orm-repo/src/main/resources/mapper/OrderMapper.xml new file mode 100644 index 000000000..d3e641bbe --- /dev/null +++ b/gitnexus/test/fixtures/orm-repo/src/main/resources/mapper/OrderMapper.xml @@ -0,0 +1,26 @@ + + + + + + + + + + INSERT INTO order_info (user_id, status, amount) VALUES (#{userId}, #{status}, #{amount}) + + + + UPDATE order_info SET status = #{status} WHERE id = #{id} + + + + DELETE FROM order_info WHERE id = #{id} + + + diff --git a/gitnexus/test/integration/orm-dataflow.test.ts b/gitnexus/test/integration/orm-dataflow.test.ts index 51a0047a6..394c3ac85 100644 --- a/gitnexus/test/integration/orm-dataflow.test.ts +++ b/gitnexus/test/integration/orm-dataflow.test.ts @@ -77,4 +77,68 @@ describe('ORM dataflow detection', () => { expect(codeElements).toContain('interpreters'); expect(codeElements).toContain('sessions'); }); + + it('creates QUERIES edges for MyBatis XML mapper statements', () => { + const queryEdges: { source: string; target: string; reason: string }[] = []; + for (const rel of result.graph.iterRelationships()) { + if (rel.type === 'QUERIES') { + const source = result.graph.getNode(rel.sourceId); + const target = result.graph.getNode(rel.targetId); + if (source && target && rel.reason?.startsWith('mybatis-')) { + queryEdges.push({ + source: source.properties.filePath || source.properties.name, + target: target.properties.name, + reason: rel.reason ?? '', + }); + } + } + } + const tables = [...new Set(queryEdges.map((e) => e.target))]; + // OrderMapper.xml: SELECT/INSERT/UPDATE/DELETE on order_info + expect(tables).toContain('order_info'); + // OrderDetailMapper.xml: JOIN on order_item + expect(tables).toContain('order_item'); + // All four SQL op types covered + const reasons = queryEdges.map((e) => e.reason); + expect(reasons.some((r) => r === 'mybatis-select')).toBe(true); + expect(reasons.some((r) => r === 'mybatis-insert')).toBe(true); + expect(reasons.some((r) => r === 'mybatis-update')).toBe(true); + expect(reasons.some((r) => r === 'mybatis-delete')).toBe(true); + }); + + it('creates CodeElement nodes for MyBatis tables', () => { + const mybatisNodes: string[] = []; + result.graph.forEachNode((n) => { + if (n.label === 'CodeElement' && n.properties.description?.includes('mybatis')) { + mybatisNodes.push(n.properties.name); + } + }); + expect(mybatisNodes).toContain('order_info'); + expect(mybatisNodes).toContain('order_item'); + }); + + it('links MyBatis edges to mapper method nodes when available', () => { + // If the Java Mapper interface was parsed, edges should link to Method nodes + // rather than just File nodes. Verify at least one QUERIES edge has a Method source. + let hasMethodSource = false; + for (const rel of result.graph.iterRelationships()) { + if (rel.type === 'QUERIES' && rel.reason?.startsWith('mybatis-')) { + const source = result.graph.getNode(rel.sourceId); + if (source?.label === 'Method') { + hasMethodSource = true; + break; + } + } + } + // Method-level linking requires the Java file to be indexed — acceptable if not present + // in lightweight fixture. At minimum, File-level edges must exist. + const hasAnyMybatisEdge = [...result.graph.iterRelationships()].some( + (r) => r.type === 'QUERIES' && r.reason?.startsWith('mybatis-'), + ); + expect(hasAnyMybatisEdge).toBe(true); + // Log for visibility (method linking is best-effort) + if (!hasMethodSource) { + console.log('[info] MyBatis edges linked at File level (no Java parser in fixture)'); + } + }); });