mybatis-extractor-robustness.test.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. import { describe, it, expect } from 'vitest';
  2. import { extractFromSource } from '../src/extraction/tree-sitter';
  3. // Robustness of the MyBatis / iBatis mapper extractor. Four shapes the regex
  4. // scanner previously mishandled, all reported and diagnosed by @ESPINS in #1182:
  5. // 1. single-quoted attribute values,
  6. // 2. tags that live inside XML comments,
  7. // 3. iBatis 2 `<sqlMap>` files (zero statement coverage before),
  8. // 4. two statements that share a qualifiedName *and* a start line colliding
  9. // on the node id (silent statement loss at the DB layer).
  10. // The quoting and comment suites below follow @ESPINS's fix-mybatis-quotes-comments
  11. // branch; the iBatis and collision suites cover the regex-only path taken here
  12. // (no parser dependency).
  13. const methodNodes = (xml: string, file = 'FooMapper.xml') =>
  14. extractFromSource(file, xml).nodes.filter((n) => n.kind === 'method');
  15. const methodNames = (xml: string, file = 'FooMapper.xml') =>
  16. methodNodes(xml, file).map((n) => n.qualifiedName);
  17. describe('MyBatis extractor — attribute quoting', () => {
  18. it('accepts a single-quoted namespace', () => {
  19. const xml =
  20. "<mapper namespace='com.example.FooMapper'>" +
  21. '<select id="getById">SELECT 1</select></mapper>';
  22. expect(methodNames(xml)).toContain('com.example.FooMapper::getById');
  23. });
  24. it('accepts a single-quoted statement id', () => {
  25. const xml =
  26. '<mapper namespace="com.example.FooMapper">' +
  27. "<select id='getById'>SELECT 1</select></mapper>";
  28. expect(methodNames(xml)).toContain('com.example.FooMapper::getById');
  29. });
  30. it('accepts a single-quoted <include refid>', () => {
  31. const xml =
  32. '<mapper namespace="com.example.FooMapper">' +
  33. '<sql id="cols">id, name</sql>' +
  34. "<select id='getById'>SELECT <include refid='cols'/> FROM t</select>" +
  35. '</mapper>';
  36. const refs = extractFromSource('FooMapper.xml', xml).unresolvedReferences.map(
  37. (r) => r.referenceName
  38. );
  39. expect(refs).toContain('com.example.FooMapper::cols');
  40. });
  41. it('reads single-quoted resultType / parameterType into the signature', () => {
  42. const xml =
  43. "<mapper namespace='com.example.FooMapper'>" +
  44. "<select id='getById' resultType='User' parameterType='int'>SELECT 1</select>" +
  45. '</mapper>';
  46. const sig = methodNodes(xml).find((n) => n.name === 'getById')?.signature;
  47. expect(sig).toContain('result=User');
  48. expect(sig).toContain('param=int');
  49. });
  50. it('handles mixed single- and double-quoted attributes in one file', () => {
  51. const xml =
  52. "<mapper namespace='com.example.FooMapper'>" +
  53. "<select id='getById' resultType='User'>SELECT 1</select>" +
  54. '<update id="touch" parameterType="User">UPDATE t SET x=1</update>' +
  55. '</mapper>';
  56. expect(methodNames(xml)).toEqual([
  57. 'com.example.FooMapper::getById',
  58. 'com.example.FooMapper::touch',
  59. ]);
  60. });
  61. it('still accepts double-quoted attributes (regression guard)', () => {
  62. const xml =
  63. '<mapper namespace="com.example.FooMapper">' +
  64. '<select id="getById">SELECT 1</select></mapper>';
  65. expect(methodNames(xml)).toContain('com.example.FooMapper::getById');
  66. });
  67. });
  68. describe('MyBatis extractor — XML comments', () => {
  69. const result = (xml: string) => extractFromSource('FooMapper.xml', xml);
  70. it('does not emit a node for a statement inside a comment', () => {
  71. const xml =
  72. '<mapper namespace="com.example.FooMapper">' +
  73. '<!-- <select id="dead">SELECT 1</select> -->' +
  74. '<select id="live">SELECT 2</select></mapper>';
  75. const names = result(xml)
  76. .nodes.filter((n) => n.kind === 'method')
  77. .map((n) => n.name);
  78. expect(names).toContain('live');
  79. expect(names).not.toContain('dead');
  80. });
  81. it('does not follow an <include> inside a comment', () => {
  82. const xml =
  83. '<mapper namespace="com.example.FooMapper">' +
  84. '<select id="getById">SELECT 1 <!-- <include refid="cols"/> --></select>' +
  85. '</mapper>';
  86. const refs = result(xml).unresolvedReferences.map((r) => r.referenceName);
  87. expect(refs).not.toContain('com.example.FooMapper::cols');
  88. });
  89. it('keeps the correct startLine for a statement after a multi-line comment', () => {
  90. const xml =
  91. '<mapper namespace="com.example.FooMapper">\n' +
  92. '<!--\n' +
  93. ' a commented-out block\n' +
  94. ' spanning several lines\n' +
  95. '-->\n' +
  96. '<select id="getById">SELECT 1</select>\n' +
  97. '</mapper>\n';
  98. const stmt = result(xml).nodes.find((n) => n.name === 'getById');
  99. expect(stmt).toBeDefined();
  100. // The <select> is on the 6th line of the document.
  101. expect(stmt!.startLine).toBe(6);
  102. });
  103. it('treats <!-- and --> inside CDATA as data, not comment delimiters', () => {
  104. const xml =
  105. '<mapper namespace="com.example.FooMapper">' +
  106. '<![CDATA[<!--]]>' +
  107. '<select id="live">SELECT 1</select>' +
  108. '<![CDATA[-->]]>' +
  109. '</mapper>';
  110. const names = result(xml)
  111. .nodes.filter((n) => n.kind === 'method')
  112. .map((n) => n.name);
  113. expect(names).toContain('live');
  114. });
  115. it('does not crash on an unterminated comment (blanks to end of file)', () => {
  116. const xml =
  117. '<mapper namespace="com.example.FooMapper">' +
  118. '<select id="before">SELECT 1</select>' +
  119. '<!-- unterminated, swallowing a <select id="after">SELECT 2</select>';
  120. const names = result(xml)
  121. .nodes.filter((n) => n.kind === 'method')
  122. .map((n) => n.name);
  123. expect(names).toContain('before');
  124. expect(names).not.toContain('after');
  125. });
  126. });
  127. describe('MyBatis extractor — duplicate-id collision (#1182 gap 4)', () => {
  128. it('keeps both statements of a same-line vendor-split databaseId pair', () => {
  129. // Two <select>s share qualifiedName `…::findUser` AND a start line. The node
  130. // id previously hashed only (path, kind, qualifiedName, startLine), so both
  131. // hashed identically and INSERT OR REPLACE dropped one at the DB layer. The
  132. // extractor pushes both regardless, so the collision shows up as *identical
  133. // ids* here — assert the ids are now distinct.
  134. const xml =
  135. '<mapper namespace="com.example.FooMapper">' +
  136. '<select id="findUser" databaseId="oracle">SELECT 1 FROM dual</select>' +
  137. '<select id="findUser" databaseId="mysql">SELECT 1</select>' +
  138. '</mapper>';
  139. const nodes = methodNodes(xml).filter((n) => n.name === 'findUser');
  140. expect(nodes).toHaveLength(2);
  141. expect(new Set(nodes.map((n) => n.id)).size).toBe(2);
  142. // qualifiedName is intentionally unchanged (the Java↔XML bridge keys on it).
  143. expect(nodes.every((n) => n.qualifiedName === 'com.example.FooMapper::findUser')).toBe(true);
  144. // The databaseId keeps the two signatures distinguishable.
  145. expect(nodes.map((n) => n.signature).sort()).toEqual([
  146. 'SELECT databaseId=mysql',
  147. 'SELECT databaseId=oracle',
  148. ]);
  149. });
  150. });
  151. describe('iBatis 2 <sqlMap> coverage (#1182 gap 3)', () => {
  152. it('extracts statements from a namespaced <sqlMap>', () => {
  153. const xml =
  154. '<?xml version="1.0" encoding="UTF-8"?>\n' +
  155. '<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://ibatis.apache.org/dtd/sql-map-2.dtd">\n' +
  156. '<sqlMap namespace="Account">\n' +
  157. ' <select id="getById" resultClass="Account">SELECT * FROM account WHERE id = #id#</select>\n' +
  158. ' <insert id="insert" parameterClass="Account">INSERT INTO account (id) VALUES (#id#)</insert>\n' +
  159. '</sqlMap>\n';
  160. expect(methodNames(xml, 'Account.xml')).toEqual(['Account::getById', 'Account::insert']);
  161. });
  162. it('splits a namespace-less DAO.method id on the last dot', () => {
  163. const xml =
  164. '<sqlMap>\n' +
  165. ' <select id="Account.getById" resultClass="Account">SELECT 1</select>\n' +
  166. '</sqlMap>\n';
  167. const node = methodNodes(xml, 'Account.xml').find((n) => n.name === 'getById');
  168. expect(node).toBeDefined();
  169. expect(node!.qualifiedName).toBe('Account::getById');
  170. });
  171. it('recognizes iBatis <statement> and <procedure> verbs', () => {
  172. const xml =
  173. '<sqlMap namespace="Account">' +
  174. '<statement id="runIt">SELECT 1</statement>' +
  175. '<procedure id="callIt">{ call do_it() }</procedure>' +
  176. '</sqlMap>';
  177. expect(methodNames(xml, 'Account.xml').sort()).toEqual(['Account::callIt', 'Account::runIt']);
  178. });
  179. it('resolves an <include> to a <sql> fragment inside the sqlMap', () => {
  180. const xml =
  181. '<sqlMap namespace="Account">' +
  182. '<sql id="cols">id, name</sql>' +
  183. '<select id="getById">SELECT <include refid="cols"/> FROM account</select>' +
  184. '</sqlMap>';
  185. const refs = extractFromSource('Account.xml', xml).unresolvedReferences.map(
  186. (r) => r.referenceName
  187. );
  188. expect(refs).toContain('Account::cols');
  189. });
  190. it('leaves the iBatis config root (<sqlMapConfig>) with no statement nodes', () => {
  191. const xml =
  192. '<sqlMapConfig>' +
  193. '<sqlMap resource="com/example/Account.xml"/>' +
  194. '</sqlMapConfig>';
  195. expect(methodNodes(xml, 'SqlMapConfig.xml')).toHaveLength(0);
  196. });
  197. });