fix(mybatis): quote/comment robustness, iBatis <sqlMap> coverage, dup-id collision (#1182) (#1204)

Four gaps in the MyBatis mapper extractor, all reported and reproduced by
@ESPINS in #1182 and verified against main:

1. Single-quoted attribute values (namespace/id/refid/resultType/parameterType)
   were dropped — the regexes hardcoded double quotes. Now accept either quote
   via a backreference.
2. Tags inside <!-- ... --> produced phantom statement/include symbols. A
   length-preserving, CDATA-aware pre-pass blanks comments before scanning,
   keeping offsets/line numbers intact.
3. Legacy iBatis 2 <sqlMap> files had zero statement coverage (the root finder
   gated on a <mapper namespace> root). It now also recognizes <sqlMap>
   (namespaced and namespace-less DAO.method ids) and iBatis's extra
   <statement>/<procedure> verbs — closing the gap with no new dependency
   (option (c) from the issue; the batis-xml parser route is declined).
4. Two statements sharing a qualifiedName AND a start line (a vendor-split
   databaseId pair on one line) collided on the node id, so INSERT OR REPLACE
   silently dropped one. The id-hash now folds in the statement's byte offset;
   the stored qualifiedName/startLine are unchanged so the Java<->XML bridge is
   untouched.

Gaps 1 and 2 follow @ESPINS's fix-mybatis-quotes-comments branch. Tests add
extractor-level coverage for all four gaps plus a DB-level e2e that proves
iBatis statements land and both vendor-split nodes survive a real indexAll.

Co-authored-by: Jimin Lee <dlwlalsggg@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-07 09:46:22 -05:00
committed by GitHub
co-authored by Jimin Lee Claude Opus 4.8
parent 356f5f7659
commit f5edf8cf49
4 changed files with 433 additions and 37 deletions
+64
View File
@@ -464,6 +464,70 @@ describe('Java end-to-end — field-injected bean trace (issue #389)', () => {
cg.close();
});
it('covers legacy iBatis <sqlMap> statements and keeps same-line vendor-split pairs (#1182)', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ibatis-'));
const xmlDir = path.join(tmpDir, 'src/main/resources/sqlmaps');
fs.mkdirSync(xmlDir, { recursive: true });
// iBatis 2 sqlMap with an explicit namespace.
fs.writeFileSync(
path.join(xmlDir, 'Account.xml'),
'<?xml version="1.0" encoding="UTF-8"?>\n' +
'<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://ibatis.apache.org/dtd/sql-map-2.dtd">\n' +
"<sqlMap namespace='Account'>\n" +
" <sql id='cols'>id, name, email</sql>\n" +
" <select id='getById' resultClass='Account'>SELECT <include refid='cols'/> FROM account WHERE id = #id#</select>\n" +
" <insert id='insert' parameterClass='Account'>INSERT INTO account (id) VALUES (#id#)</insert>\n" +
' <!-- <select id="disabled">SELECT 0</select> -->\n' +
'</sqlMap>\n'
);
// Namespace-less sqlMap whose ids carry the qualifier as `Map.statement`.
fs.writeFileSync(
path.join(xmlDir, 'LegacyDao.xml'),
'<sqlMap>\n' +
' <select id="LegacyDao.findAll" resultClass="Row">SELECT * FROM t</select>\n' +
'</sqlMap>\n'
);
// MyBatis mapper with a vendor-split databaseId pair written on ONE line —
// same qualifiedName + same start line. Before the id-hash fold both nodes
// hashed identically and INSERT OR REPLACE dropped one.
fs.writeFileSync(
path.join(xmlDir, 'VendorMapper.xml'),
'<mapper namespace="com.example.VendorMapper">\n' +
'<select id="findUser" databaseId="oracle">SELECT 1 FROM dual</select><select id="findUser" databaseId="mysql">SELECT 1</select>\n' +
'</mapper>\n'
);
const cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const xmlMethods = cg.getNodesByKind('method').filter((n) => n.language === 'xml');
const qnames = xmlMethods.map((n) => n.qualifiedName);
// iBatis statements now land in the graph (was zero coverage before #1182).
expect(qnames).toContain('Account::getById');
expect(qnames).toContain('Account::insert');
expect(qnames).toContain('Account::cols');
expect(qnames).toContain('LegacyDao::findAll');
// The commented-out statement produced no node.
expect(qnames).not.toContain('Account::disabled');
// <include refid='cols'/> resolves to the <sql> fragment in the same map.
const getById = xmlMethods.find((n) => n.qualifiedName === 'Account::getById');
const cols = xmlMethods.find((n) => n.qualifiedName === 'Account::cols');
expect(getById).toBeDefined();
expect(cols).toBeDefined();
const incEdge = cg.getOutgoingEdges(getById!.id).find((e) => e.target === cols!.id);
expect(incEdge, "iBatis <include refid='cols'/> should reach the <sql> fragment").toBeDefined();
// Both vendor-split statements survive the DB write (the collision fix).
const findUser = xmlMethods.filter((n) => n.name === 'findUser');
expect(findUser, 'both databaseId variants of findUser should survive').toHaveLength(2);
expect(new Set(findUser.map((n) => n.id)).size).toBe(2);
cg.close();
});
it('binds @Value / @ConfigurationProperties to YAML + .properties keys (incl. relaxed binding)', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-spring-config-'));
const javaDir = path.join(tmpDir, 'src/main/java/com/example');