mybatis-extractor.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. import { Edge, ExtractionError, ExtractionResult, Node, UnresolvedReference } from '../types';
  2. import { generateNodeId } from './tree-sitter-helpers';
  3. /**
  4. * MyBatisExtractor — parses MyBatis mapper XML files.
  5. *
  6. * MyBatis splits a DAO interface across two files: a Java interface (parsed by
  7. * tree-sitter) declares the method, and an XML mapper file holds the SQL keyed
  8. * by `<namespace>` (the fully-qualified Java type name) and `id` (the method
  9. * name). Without the XML side in the graph, `trace(Controller, ...DAO.method)`
  10. * dead-ends at the interface method — the SQL it actually runs is invisible,
  11. * and "what does this query touch" / "where is this column written" can't be
  12. * answered.
  13. *
  14. * This extractor emits one method-shaped node per `<select|insert|update|
  15. * delete>` and per `<sql>` fragment, qualified as `<namespace>::<id>` so the
  16. * MyBatis framework synthesizer can link the matching Java method → XML
  17. * statement by suffix-matching qualified names. `<include refid="...">` inside
  18. * a statement yields an unresolved reference to the SQL fragment, also keyed
  19. * by `<namespace>::<refid>`.
  20. *
  21. * Both dialects are covered: MyBatis 3 `<mapper namespace="...">` and the
  22. * legacy iBatis 2 `<sqlMap>` (namespaced, or namespace-less with `Map.stmt`
  23. * ids, plus its extra `<statement>`/`<procedure>` verbs). Attribute values may
  24. * use either quote style, and statements commented out with `<!-- ... -->` are
  25. * ignored (see the constructor's comment-stripping pre-pass).
  26. *
  27. * Non-mapper XML (Maven `pom.xml`, Spring beans XML, `web.xml`, log4j config,
  28. * etc.) is detected by the absence of a `<mapper namespace="...">` /
  29. * `<sqlMap>` root and returns just a file node — we still need the file row so
  30. * the watcher can track it, but we emit no symbols.
  31. */
  32. export class MyBatisExtractor {
  33. private filePath: string;
  34. private source: string;
  35. private nodes: Node[] = [];
  36. private edges: Edge[] = [];
  37. private unresolvedReferences: UnresolvedReference[] = [];
  38. private errors: ExtractionError[] = [];
  39. private lineStarts: number[] = [];
  40. constructor(filePath: string, source: string) {
  41. this.filePath = filePath;
  42. // Blank out XML comments up front so commented-out statements and includes
  43. // aren't matched by the scans below (a `<!-- <select id="old">…</select> -->`
  44. // block must not produce a phantom node). Length-preserving — comment bytes
  45. // become spaces, newlines are kept — so the offsets and line numbers
  46. // computed afterwards still map to the original source. Text inside
  47. // `<![CDATA[ … ]]>` is left intact: a literal `<!--` there is SQL data, not
  48. // an XML comment.
  49. this.source = MyBatisExtractor.stripXmlComments(source);
  50. this.computeLineStarts();
  51. }
  52. private static stripXmlComments(source: string): string {
  53. const out = source.split('');
  54. const n = source.length;
  55. let i = 0;
  56. while (i < n) {
  57. if (source.startsWith('<![CDATA[', i)) {
  58. const end = source.indexOf(']]>', i + 9);
  59. i = end >= 0 ? end + 3 : n;
  60. continue;
  61. }
  62. if (source.startsWith('<!--', i)) {
  63. const end = source.indexOf('-->', i + 4);
  64. const stop = end >= 0 ? end + 3 : n;
  65. for (let j = i; j < stop; j++) {
  66. if (source.charCodeAt(j) !== 10) out[j] = ' ';
  67. }
  68. i = stop;
  69. continue;
  70. }
  71. i++;
  72. }
  73. return out.join('');
  74. }
  75. extract(): ExtractionResult {
  76. const startTime = Date.now();
  77. const fileNode = this.createFileNode();
  78. try {
  79. const root = this.findMapperRoot();
  80. if (root) {
  81. this.extractMapper(fileNode.id, root.namespace, root.dialect, root.bodyStart, root.bodyEnd);
  82. }
  83. } catch (error) {
  84. this.errors.push({
  85. message: `MyBatis extraction error: ${error instanceof Error ? error.message : String(error)}`,
  86. severity: 'error',
  87. code: 'parse_error',
  88. });
  89. }
  90. return {
  91. nodes: this.nodes,
  92. edges: this.edges,
  93. unresolvedReferences: this.unresolvedReferences,
  94. errors: this.errors,
  95. durationMs: Date.now() - startTime,
  96. };
  97. }
  98. private createFileNode(): Node {
  99. const lines = this.source.split('\n');
  100. const id = generateNodeId(this.filePath, 'file', this.filePath, 1);
  101. const node: Node = {
  102. id,
  103. kind: 'file',
  104. name: this.filePath.split('/').pop() || this.filePath,
  105. qualifiedName: this.filePath,
  106. filePath: this.filePath,
  107. language: 'xml',
  108. startLine: 1,
  109. endLine: lines.length || 1,
  110. startColumn: 0,
  111. endColumn: lines[lines.length - 1]?.length ?? 0,
  112. updatedAt: Date.now(),
  113. };
  114. this.nodes.push(node);
  115. return node;
  116. }
  117. /**
  118. * Find the mapper root and its dialect. Two shapes are recognized:
  119. * - MyBatis 3: `<mapper namespace="com.foo.Bar">` — namespace required.
  120. * - iBatis 2: `<sqlMap namespace="Account">`, or a namespace-less
  121. * `<sqlMap>` whose statement ids carry the qualifier as `Map.statement`.
  122. * Returns the namespace, the dialect, and the byte offsets of the body
  123. * (between the opening and closing tag) so statement extraction is scoped to
  124. * the root's contents. Either quote style is accepted for the namespace
  125. * (`namespace='X'` is legal XML and common in older mappers).
  126. */
  127. private findMapperRoot():
  128. | { namespace: string; dialect: 'mybatis' | 'ibatis'; bodyStart: number; bodyEnd: number }
  129. | null {
  130. const mapper = /<mapper\b([^>]*)>/.exec(this.source);
  131. if (mapper) {
  132. const nsMatch = /\bnamespace\s*=\s*(["'])([^"']+)\1/.exec(mapper[1] ?? '');
  133. if (nsMatch) {
  134. const bodyStart = mapper.index + mapper[0].length;
  135. const closeIdx = this.source.indexOf('</mapper>', bodyStart);
  136. return {
  137. namespace: nsMatch[2]!,
  138. dialect: 'mybatis',
  139. bodyStart,
  140. bodyEnd: closeIdx >= 0 ? closeIdx : this.source.length,
  141. };
  142. }
  143. }
  144. // iBatis 2 SqlMap. `\b` keeps `<sqlMapConfig>` (the iBatis config root,
  145. // which holds no statements) from matching here. namespace is optional.
  146. const sqlMap = /<sqlMap\b([^>]*)>/.exec(this.source);
  147. if (sqlMap) {
  148. const nsMatch = /\bnamespace\s*=\s*(["'])([^"']+)\1/.exec(sqlMap[1] ?? '');
  149. const bodyStart = sqlMap.index + sqlMap[0].length;
  150. const closeIdx = this.source.indexOf('</sqlMap>', bodyStart);
  151. return {
  152. namespace: nsMatch?.[2] ?? '',
  153. dialect: 'ibatis',
  154. bodyStart,
  155. bodyEnd: closeIdx >= 0 ? closeIdx : this.source.length,
  156. };
  157. }
  158. return null;
  159. }
  160. private extractMapper(
  161. fileNodeId: string,
  162. namespace: string,
  163. dialect: 'mybatis' | 'ibatis',
  164. bodyStart: number,
  165. bodyEnd: number
  166. ): void {
  167. const body = this.source.slice(bodyStart, bodyEnd);
  168. // Match each top-level statement-shaped element. The body may have nested
  169. // tags (`<if>`, `<foreach>`, `<include>`), so we scan with a regex that
  170. // pairs an opening tag to its matching close — the simple form below works
  171. // because MyBatis/iBatis statement elements are not themselves nested.
  172. // iBatis 2 adds the generic `<statement>` and `<procedure>` on top of the
  173. // MyBatis 3 verbs; gating by dialect keeps MyBatis extraction unchanged.
  174. const verbs =
  175. dialect === 'ibatis'
  176. ? 'select|insert|update|delete|sql|statement|procedure'
  177. : 'select|insert|update|delete|sql';
  178. const stmtRegex = new RegExp(`<(${verbs})\\b([^>]*)>([\\s\\S]*?)</\\1>`, 'g');
  179. let m: RegExpExecArray | null;
  180. while ((m = stmtRegex.exec(body)) !== null) {
  181. const elemType = m[1]!;
  182. const attrs = m[2] ?? '';
  183. const elemBody = m[3] ?? '';
  184. // Accept either quote style (`(["'])…\1`). The identifier-shaped MyBatis
  185. // attributes matched here and below (namespace/id/refid/resultType/
  186. // parameterType) are Java FQNs, method names, or type aliases and never
  187. // contain a quote character, so excluding both quotes from the value is safe.
  188. const idMatch = /\bid\s*=\s*(["'])([^"']+)\1/.exec(attrs);
  189. if (!idMatch) continue;
  190. const id = idMatch[2]!;
  191. const absoluteIndex = bodyStart + m.index;
  192. const startLine = this.getLineNumber(absoluteIndex);
  193. const endLine = this.getLineNumber(absoluteIndex + m[0].length);
  194. const { qualifiedName: qualified, name } = this.qualifyStatement(namespace, id);
  195. const isSqlFragment = elemType === 'sql';
  196. // The id-hash folds in the statement's byte offset (unique per statement
  197. // in the file), not just the start line: two statements sharing a
  198. // qualifiedName AND a start line — e.g. a vendor-split `databaseId` pair
  199. // (`<select id="x" databaseId="oracle">…</select><select id="x"
  200. // databaseId="mysql">…`) written on one line — would otherwise hash to
  201. // the same node id, and `INSERT OR REPLACE INTO nodes` (id is the PRIMARY
  202. // KEY) would silently drop one. qualifiedName and startLine are stored
  203. // unchanged, so the Java↔XML suffix-match bridge is untouched.
  204. const nodeId = generateNodeId(this.filePath, 'method', qualified, absoluteIndex);
  205. const node: Node = {
  206. id: nodeId,
  207. kind: 'method',
  208. name,
  209. qualifiedName: qualified,
  210. filePath: this.filePath,
  211. language: 'xml',
  212. signature: this.buildSignature(elemType, attrs, isSqlFragment),
  213. startLine,
  214. endLine,
  215. startColumn: 0,
  216. endColumn: 0,
  217. docstring: this.previewSql(elemBody),
  218. updatedAt: Date.now(),
  219. };
  220. this.nodes.push(node);
  221. this.edges.push({ source: fileNodeId, target: nodeId, kind: 'contains' });
  222. // <include refid="X"/> → reference to the SQL fragment in this mapper
  223. // (or in another mapper, when the refid is qualified — `ns.X`).
  224. const includeRegex = /<include\b[^>]*\brefid\s*=\s*(["'])([^"']+)\1/g;
  225. let inc: RegExpExecArray | null;
  226. while ((inc = includeRegex.exec(elemBody)) !== null) {
  227. const refid = inc[2]!;
  228. const refQualified = refid.includes('.')
  229. ? refid.replace(/\./g, '::')
  230. : namespace
  231. ? `${namespace}::${refid}`
  232. : refid;
  233. const includeOffset = absoluteIndex + (m[0].length - m[3]!.length - `</${elemType}>`.length) + inc.index;
  234. const line = this.getLineNumber(includeOffset);
  235. this.unresolvedReferences.push({
  236. fromNodeId: nodeId,
  237. referenceName: refQualified,
  238. referenceKind: 'references',
  239. line,
  240. column: 0,
  241. });
  242. }
  243. }
  244. }
  245. private buildSignature(elemType: string, attrs: string, isSqlFragment: boolean): string {
  246. if (isSqlFragment) return '<sql>';
  247. const verb = elemType.toUpperCase();
  248. const result = /\bresultType\s*=\s*(["'])([^"']+)\1/.exec(attrs)?.[2];
  249. const param = /\bparameterType\s*=\s*(["'])([^"']+)\1/.exec(attrs)?.[2];
  250. // A vendor-split statement carries `databaseId`; surface it so the two
  251. // otherwise-identical `<namespace>::<id>` nodes are distinguishable.
  252. const dbId = /\bdatabaseId\s*=\s*(["'])([^"']+)\1/.exec(attrs)?.[2];
  253. const parts = [verb];
  254. if (param) parts.push(`param=${param}`);
  255. if (result) parts.push(`result=${result}`);
  256. if (dbId) parts.push(`databaseId=${dbId}`);
  257. return parts.join(' ');
  258. }
  259. /**
  260. * Build the `<namespace>::<id>` qualified name the MyBatis synthesizer
  261. * suffix-matches against a Java `<Class>::<method>`, and the display name.
  262. * For a namespace-less iBatis `<sqlMap>`, the statement id carries the
  263. * qualifier as `Map.statement`, so split on the last dot to reach the same
  264. * shape (`Account.getById` → `Account::getById`, name `getById`).
  265. */
  266. private qualifyStatement(namespace: string, id: string): { qualifiedName: string; name: string } {
  267. if (namespace) return { qualifiedName: `${namespace}::${id}`, name: id };
  268. const dot = id.lastIndexOf('.');
  269. if (dot >= 0) {
  270. return { qualifiedName: `${id.slice(0, dot)}::${id.slice(dot + 1)}`, name: id.slice(dot + 1) };
  271. }
  272. return { qualifiedName: id, name: id };
  273. }
  274. private previewSql(body: string): string {
  275. return body.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200);
  276. }
  277. private computeLineStarts(): void {
  278. this.lineStarts = [0];
  279. for (let i = 0; i < this.source.length; i++) {
  280. if (this.source.charCodeAt(i) === 10) this.lineStarts.push(i + 1);
  281. }
  282. }
  283. private getLineNumber(offset: number): number {
  284. // Binary search
  285. let lo = 0;
  286. let hi = this.lineStarts.length - 1;
  287. while (lo < hi) {
  288. const mid = (lo + hi + 1) >>> 1;
  289. if (this.lineStarts[mid]! <= offset) lo = mid;
  290. else hi = mid - 1;
  291. }
  292. return lo + 1;
  293. }
  294. }