|
|
@@ -14,15 +14,21 @@ import { generateNodeId } from './tree-sitter-helpers';
|
|
|
*
|
|
|
* This extractor emits one method-shaped node per `<select|insert|update|
|
|
|
* delete>` and per `<sql>` fragment, qualified as `<namespace>::<id>` so the
|
|
|
- * MyBatis framework synthesizer (`src/resolution/frameworks/mybatis.ts`) can
|
|
|
- * link the matching Java method → XML statement by suffix-matching qualified
|
|
|
- * names. `<include refid="...">` inside a statement yields an unresolved
|
|
|
- * reference to the SQL fragment, also keyed by `<namespace>::<refid>`.
|
|
|
+ * MyBatis framework synthesizer can link the matching Java method → XML
|
|
|
+ * statement by suffix-matching qualified names. `<include refid="...">` inside
|
|
|
+ * a statement yields an unresolved reference to the SQL fragment, also keyed
|
|
|
+ * by `<namespace>::<refid>`.
|
|
|
+ *
|
|
|
+ * Both dialects are covered: MyBatis 3 `<mapper namespace="...">` and the
|
|
|
+ * legacy iBatis 2 `<sqlMap>` (namespaced, or namespace-less with `Map.stmt`
|
|
|
+ * ids, plus its extra `<statement>`/`<procedure>` verbs). Attribute values may
|
|
|
+ * use either quote style, and statements commented out with `<!-- ... -->` are
|
|
|
+ * ignored (see the constructor's comment-stripping pre-pass).
|
|
|
*
|
|
|
* Non-mapper XML (Maven `pom.xml`, Spring beans XML, `web.xml`, log4j config,
|
|
|
- * etc.) is detected by the absence of a `<mapper namespace="...">` root and
|
|
|
- * returns just a file node — we still need the file row so the watcher can
|
|
|
- * track it, but we emit no symbols.
|
|
|
+ * etc.) is detected by the absence of a `<mapper namespace="...">` /
|
|
|
+ * `<sqlMap>` root and returns just a file node — we still need the file row so
|
|
|
+ * the watcher can track it, but we emit no symbols.
|
|
|
*/
|
|
|
export class MyBatisExtractor {
|
|
|
private filePath: string;
|
|
|
@@ -35,19 +41,50 @@ export class MyBatisExtractor {
|
|
|
|
|
|
constructor(filePath: string, source: string) {
|
|
|
this.filePath = filePath;
|
|
|
- this.source = source;
|
|
|
+ // Blank out XML comments up front so commented-out statements and includes
|
|
|
+ // aren't matched by the scans below (a `<!-- <select id="old">…</select> -->`
|
|
|
+ // block must not produce a phantom node). Length-preserving — comment bytes
|
|
|
+ // become spaces, newlines are kept — so the offsets and line numbers
|
|
|
+ // computed afterwards still map to the original source. Text inside
|
|
|
+ // `<![CDATA[ … ]]>` is left intact: a literal `<!--` there is SQL data, not
|
|
|
+ // an XML comment.
|
|
|
+ this.source = MyBatisExtractor.stripXmlComments(source);
|
|
|
this.computeLineStarts();
|
|
|
}
|
|
|
|
|
|
+ private static stripXmlComments(source: string): string {
|
|
|
+ const out = source.split('');
|
|
|
+ const n = source.length;
|
|
|
+ let i = 0;
|
|
|
+ while (i < n) {
|
|
|
+ if (source.startsWith('<![CDATA[', i)) {
|
|
|
+ const end = source.indexOf(']]>', i + 9);
|
|
|
+ i = end >= 0 ? end + 3 : n;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (source.startsWith('<!--', i)) {
|
|
|
+ const end = source.indexOf('-->', i + 4);
|
|
|
+ const stop = end >= 0 ? end + 3 : n;
|
|
|
+ for (let j = i; j < stop; j++) {
|
|
|
+ if (source.charCodeAt(j) !== 10) out[j] = ' ';
|
|
|
+ }
|
|
|
+ i = stop;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ i++;
|
|
|
+ }
|
|
|
+ return out.join('');
|
|
|
+ }
|
|
|
+
|
|
|
extract(): ExtractionResult {
|
|
|
const startTime = Date.now();
|
|
|
|
|
|
const fileNode = this.createFileNode();
|
|
|
|
|
|
try {
|
|
|
- const mapperMatch = this.findMapperRoot();
|
|
|
- if (mapperMatch) {
|
|
|
- this.extractMapper(fileNode.id, mapperMatch.namespace, mapperMatch.bodyStart, mapperMatch.bodyEnd);
|
|
|
+ const root = this.findMapperRoot();
|
|
|
+ if (root) {
|
|
|
+ this.extractMapper(fileNode.id, root.namespace, root.dialect, root.bodyStart, root.bodyEnd);
|
|
|
}
|
|
|
} catch (error) {
|
|
|
this.errors.push({
|
|
|
@@ -87,47 +124,98 @@ export class MyBatisExtractor {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Find the `<mapper namespace="X">` opening tag. Returns the namespace and
|
|
|
- * the byte offsets of the body (between the opening and closing tag) so
|
|
|
- * statement extraction can be scoped to mapper contents.
|
|
|
+ * Find the mapper root and its dialect. Two shapes are recognized:
|
|
|
+ * - MyBatis 3: `<mapper namespace="com.foo.Bar">` — namespace required.
|
|
|
+ * - iBatis 2: `<sqlMap namespace="Account">`, or a namespace-less
|
|
|
+ * `<sqlMap>` whose statement ids carry the qualifier as `Map.statement`.
|
|
|
+ * Returns the namespace, the dialect, and the byte offsets of the body
|
|
|
+ * (between the opening and closing tag) so statement extraction is scoped to
|
|
|
+ * the root's contents. Either quote style is accepted for the namespace
|
|
|
+ * (`namespace='X'` is legal XML and common in older mappers).
|
|
|
*/
|
|
|
- private findMapperRoot(): { namespace: string; bodyStart: number; bodyEnd: number } | null {
|
|
|
- const open = /<mapper\b([^>]*)>/.exec(this.source);
|
|
|
- if (!open) return null;
|
|
|
- const attrs = open[1] ?? '';
|
|
|
- const nsMatch = /\bnamespace\s*=\s*"([^"]+)"/.exec(attrs);
|
|
|
- if (!nsMatch) return null;
|
|
|
- const bodyStart = open.index + open[0].length;
|
|
|
- const closeIdx = this.source.indexOf('</mapper>', bodyStart);
|
|
|
- const bodyEnd = closeIdx >= 0 ? closeIdx : this.source.length;
|
|
|
- return { namespace: nsMatch[1]!, bodyStart, bodyEnd };
|
|
|
+ private findMapperRoot():
|
|
|
+ | { namespace: string; dialect: 'mybatis' | 'ibatis'; bodyStart: number; bodyEnd: number }
|
|
|
+ | null {
|
|
|
+ const mapper = /<mapper\b([^>]*)>/.exec(this.source);
|
|
|
+ if (mapper) {
|
|
|
+ const nsMatch = /\bnamespace\s*=\s*(["'])([^"']+)\1/.exec(mapper[1] ?? '');
|
|
|
+ if (nsMatch) {
|
|
|
+ const bodyStart = mapper.index + mapper[0].length;
|
|
|
+ const closeIdx = this.source.indexOf('</mapper>', bodyStart);
|
|
|
+ return {
|
|
|
+ namespace: nsMatch[2]!,
|
|
|
+ dialect: 'mybatis',
|
|
|
+ bodyStart,
|
|
|
+ bodyEnd: closeIdx >= 0 ? closeIdx : this.source.length,
|
|
|
+ };
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // iBatis 2 SqlMap. `\b` keeps `<sqlMapConfig>` (the iBatis config root,
|
|
|
+ // which holds no statements) from matching here. namespace is optional.
|
|
|
+ const sqlMap = /<sqlMap\b([^>]*)>/.exec(this.source);
|
|
|
+ if (sqlMap) {
|
|
|
+ const nsMatch = /\bnamespace\s*=\s*(["'])([^"']+)\1/.exec(sqlMap[1] ?? '');
|
|
|
+ const bodyStart = sqlMap.index + sqlMap[0].length;
|
|
|
+ const closeIdx = this.source.indexOf('</sqlMap>', bodyStart);
|
|
|
+ return {
|
|
|
+ namespace: nsMatch?.[2] ?? '',
|
|
|
+ dialect: 'ibatis',
|
|
|
+ bodyStart,
|
|
|
+ bodyEnd: closeIdx >= 0 ? closeIdx : this.source.length,
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return null;
|
|
|
}
|
|
|
|
|
|
- private extractMapper(fileNodeId: string, namespace: string, bodyStart: number, bodyEnd: number): void {
|
|
|
+ private extractMapper(
|
|
|
+ fileNodeId: string,
|
|
|
+ namespace: string,
|
|
|
+ dialect: 'mybatis' | 'ibatis',
|
|
|
+ bodyStart: number,
|
|
|
+ bodyEnd: number
|
|
|
+ ): void {
|
|
|
const body = this.source.slice(bodyStart, bodyEnd);
|
|
|
// Match each top-level statement-shaped element. The body may have nested
|
|
|
// tags (`<if>`, `<foreach>`, `<include>`), so we scan with a regex that
|
|
|
// pairs an opening tag to its matching close — the simple form below works
|
|
|
- // because MyBatis statement elements are not themselves nested.
|
|
|
- const stmtRegex = /<(select|insert|update|delete|sql)\b([^>]*)>([\s\S]*?)<\/\1>/g;
|
|
|
+ // because MyBatis/iBatis statement elements are not themselves nested.
|
|
|
+ // iBatis 2 adds the generic `<statement>` and `<procedure>` on top of the
|
|
|
+ // MyBatis 3 verbs; gating by dialect keeps MyBatis extraction unchanged.
|
|
|
+ const verbs =
|
|
|
+ dialect === 'ibatis'
|
|
|
+ ? 'select|insert|update|delete|sql|statement|procedure'
|
|
|
+ : 'select|insert|update|delete|sql';
|
|
|
+ const stmtRegex = new RegExp(`<(${verbs})\\b([^>]*)>([\\s\\S]*?)</\\1>`, 'g');
|
|
|
let m: RegExpExecArray | null;
|
|
|
while ((m = stmtRegex.exec(body)) !== null) {
|
|
|
const elemType = m[1]!;
|
|
|
const attrs = m[2] ?? '';
|
|
|
const elemBody = m[3] ?? '';
|
|
|
- const idMatch = /\bid\s*=\s*"([^"]+)"/.exec(attrs);
|
|
|
+ // Accept either quote style (`(["'])…\1`). The identifier-shaped MyBatis
|
|
|
+ // attributes matched here and below (namespace/id/refid/resultType/
|
|
|
+ // parameterType) are Java FQNs, method names, or type aliases and never
|
|
|
+ // contain a quote character, so excluding both quotes from the value is safe.
|
|
|
+ const idMatch = /\bid\s*=\s*(["'])([^"']+)\1/.exec(attrs);
|
|
|
if (!idMatch) continue;
|
|
|
- const id = idMatch[1]!;
|
|
|
+ const id = idMatch[2]!;
|
|
|
const absoluteIndex = bodyStart + m.index;
|
|
|
const startLine = this.getLineNumber(absoluteIndex);
|
|
|
const endLine = this.getLineNumber(absoluteIndex + m[0].length);
|
|
|
- const qualified = `${namespace}::${id}`;
|
|
|
+ const { qualifiedName: qualified, name } = this.qualifyStatement(namespace, id);
|
|
|
const isSqlFragment = elemType === 'sql';
|
|
|
- const nodeId = generateNodeId(this.filePath, 'method', qualified, startLine);
|
|
|
+ // The id-hash folds in the statement's byte offset (unique per statement
|
|
|
+ // in the file), not just the start line: two statements sharing a
|
|
|
+ // qualifiedName AND a start line — e.g. a vendor-split `databaseId` pair
|
|
|
+ // (`<select id="x" databaseId="oracle">…</select><select id="x"
|
|
|
+ // databaseId="mysql">…`) written on one line — would otherwise hash to
|
|
|
+ // the same node id, and `INSERT OR REPLACE INTO nodes` (id is the PRIMARY
|
|
|
+ // KEY) would silently drop one. qualifiedName and startLine are stored
|
|
|
+ // unchanged, so the Java↔XML suffix-match bridge is untouched.
|
|
|
+ const nodeId = generateNodeId(this.filePath, 'method', qualified, absoluteIndex);
|
|
|
const node: Node = {
|
|
|
id: nodeId,
|
|
|
kind: 'method',
|
|
|
- name: id,
|
|
|
+ name,
|
|
|
qualifiedName: qualified,
|
|
|
filePath: this.filePath,
|
|
|
language: 'xml',
|
|
|
@@ -144,11 +232,15 @@ export class MyBatisExtractor {
|
|
|
|
|
|
// <include refid="X"/> → reference to the SQL fragment in this mapper
|
|
|
// (or in another mapper, when the refid is qualified — `ns.X`).
|
|
|
- const includeRegex = /<include\b[^>]*\brefid\s*=\s*"([^"]+)"/g;
|
|
|
+ const includeRegex = /<include\b[^>]*\brefid\s*=\s*(["'])([^"']+)\1/g;
|
|
|
let inc: RegExpExecArray | null;
|
|
|
while ((inc = includeRegex.exec(elemBody)) !== null) {
|
|
|
- const refid = inc[1]!;
|
|
|
- const refQualified = refid.includes('.') ? refid.replace(/\./g, '::') : `${namespace}::${refid}`;
|
|
|
+ const refid = inc[2]!;
|
|
|
+ const refQualified = refid.includes('.')
|
|
|
+ ? refid.replace(/\./g, '::')
|
|
|
+ : namespace
|
|
|
+ ? `${namespace}::${refid}`
|
|
|
+ : refid;
|
|
|
const includeOffset = absoluteIndex + (m[0].length - m[3]!.length - `</${elemType}>`.length) + inc.index;
|
|
|
const line = this.getLineNumber(includeOffset);
|
|
|
this.unresolvedReferences.push({
|
|
|
@@ -165,14 +257,34 @@ export class MyBatisExtractor {
|
|
|
private buildSignature(elemType: string, attrs: string, isSqlFragment: boolean): string {
|
|
|
if (isSqlFragment) return '<sql>';
|
|
|
const verb = elemType.toUpperCase();
|
|
|
- const result = /\bresultType\s*=\s*"([^"]+)"/.exec(attrs)?.[1];
|
|
|
- const param = /\bparameterType\s*=\s*"([^"]+)"/.exec(attrs)?.[1];
|
|
|
+ const result = /\bresultType\s*=\s*(["'])([^"']+)\1/.exec(attrs)?.[2];
|
|
|
+ const param = /\bparameterType\s*=\s*(["'])([^"']+)\1/.exec(attrs)?.[2];
|
|
|
+ // A vendor-split statement carries `databaseId`; surface it so the two
|
|
|
+ // otherwise-identical `<namespace>::<id>` nodes are distinguishable.
|
|
|
+ const dbId = /\bdatabaseId\s*=\s*(["'])([^"']+)\1/.exec(attrs)?.[2];
|
|
|
const parts = [verb];
|
|
|
if (param) parts.push(`param=${param}`);
|
|
|
if (result) parts.push(`result=${result}`);
|
|
|
+ if (dbId) parts.push(`databaseId=${dbId}`);
|
|
|
return parts.join(' ');
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Build the `<namespace>::<id>` qualified name the MyBatis synthesizer
|
|
|
+ * suffix-matches against a Java `<Class>::<method>`, and the display name.
|
|
|
+ * For a namespace-less iBatis `<sqlMap>`, the statement id carries the
|
|
|
+ * qualifier as `Map.statement`, so split on the last dot to reach the same
|
|
|
+ * shape (`Account.getById` → `Account::getById`, name `getById`).
|
|
|
+ */
|
|
|
+ private qualifyStatement(namespace: string, id: string): { qualifiedName: string; name: string } {
|
|
|
+ if (namespace) return { qualifiedName: `${namespace}::${id}`, name: id };
|
|
|
+ const dot = id.lastIndexOf('.');
|
|
|
+ if (dot >= 0) {
|
|
|
+ return { qualifiedName: `${id.slice(0, dot)}::${id.slice(dot + 1)}`, name: id.slice(dot + 1) };
|
|
|
+ }
|
|
|
+ return { qualifiedName: id, name: id };
|
|
|
+ }
|
|
|
+
|
|
|
private previewSql(body: string): string {
|
|
|
return body.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
|
}
|