a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
348 lines
12 KiB
Go
348 lines
12 KiB
Go
package languages
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/xml"
|
|
"io"
|
|
"path"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/parser"
|
|
)
|
|
|
|
// MyBatisExtractor indexes MyBatis mapper XML — the SQL-mapping layer of
|
|
// a Java/MyBatis application. A mapper file binds a Java DAO/Mapper
|
|
// interface (named by the `<mapper namespace="…">` FQCN) to a set of SQL
|
|
// statements, one per `<select>/<insert>/<update>/<delete>` element keyed
|
|
// by its `id`.
|
|
//
|
|
// What it surfaces:
|
|
// - the file node, stamped `mybatis_namespace` (the bound interface FQCN);
|
|
// - one node per statement element, with ID `<namespace>::<id>`
|
|
// (e.g. namespace `com.app.UserMapper`, statement id `findUser` →
|
|
// `com.app.UserMapper::findUser`). Each statement node is shaped like
|
|
// a method (graph.KindMethod), carries the SQL kind
|
|
// (select/insert/update/delete) in `mybatis_sql_kind` and the raw SQL
|
|
// body in `mybatis_sql`, and emits an unresolved::-keyed EdgeCalls
|
|
// placeholder back to the Java DAO method the synthesizer (see
|
|
// internal/resolver/mybatis_calls.go::ResolveMyBatisCalls) lands.
|
|
//
|
|
// MyBatis mapper XML uses the plain `.xml` extension — shared with every
|
|
// other XML document — so the extractor is gated on the document content:
|
|
// IsMyBatisMapper recognises the `<mapper namespace=…>` root and/or the
|
|
// MyBatis DOCTYPE, and Extract returns just the file node (no statement
|
|
// nodes) for any XML that is not a mapper. The registry routes a `.xml`
|
|
// file here only when the content sniff matches, so ordinary XML is left
|
|
// to the generic XML extractor.
|
|
type MyBatisExtractor struct{}
|
|
|
|
// NewMyBatisExtractor constructs a MyBatisExtractor.
|
|
func NewMyBatisExtractor() *MyBatisExtractor { return &MyBatisExtractor{} }
|
|
|
|
func (e *MyBatisExtractor) Language() string { return "mybatis" }
|
|
func (e *MyBatisExtractor) Extensions() []string { return []string{".xml"} }
|
|
|
|
// myBatisStatementElems is the set of mapper child elements that name an
|
|
// executable SQL statement. `<sql>` fragments and `<resultMap>` are
|
|
// deliberately excluded — they are reusable building blocks, not
|
|
// statements a DAO method invokes directly.
|
|
var myBatisStatementElems = map[string]bool{
|
|
"select": true,
|
|
"insert": true,
|
|
"update": true,
|
|
"delete": true,
|
|
}
|
|
|
|
// myBatisUnresolvedPrefix keys the placeholder EdgeCalls target the
|
|
// extractor emits from each statement node. The cross-file synthesizer
|
|
// (ResolveMyBatisCalls) rewrites it onto the matching Java DAO method.
|
|
const myBatisUnresolvedPrefix = "unresolved::mybatis::"
|
|
|
|
// IsMyBatisMapper reports whether src is a MyBatis mapper XML document.
|
|
// It accepts either the `<mapper namespace="…">` root element or the
|
|
// MyBatis DOCTYPE (`mybatis.org//DTD Mapper`). Only the document head is
|
|
// scanned, so the probe is cheap on large files.
|
|
func IsMyBatisMapper(src []byte) bool {
|
|
head := src
|
|
const headCap = 8 * 1024
|
|
if len(head) > headCap {
|
|
head = head[:headCap]
|
|
}
|
|
lower := bytes.ToLower(head)
|
|
// DOCTYPE form: `<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" ...>`
|
|
if bytes.Contains(lower, []byte("mybatis.org//dtd mapper")) {
|
|
return true
|
|
}
|
|
// Root-element form: a `<mapper` start tag carrying a `namespace`
|
|
// attribute. Tolerant of attribute ordering and whitespace.
|
|
if i := bytes.Index(lower, []byte("<mapper")); i >= 0 {
|
|
rest := lower[i:]
|
|
if end := bytes.IndexByte(rest, '>'); end >= 0 {
|
|
rest = rest[:end]
|
|
}
|
|
if bytes.Contains(rest, []byte("namespace")) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Extract parses a MyBatis mapper XML file. A document that is not a
|
|
// mapper (no `<mapper namespace>` root / MyBatis DOCTYPE) yields only the
|
|
// file node, so a misrouted plain-XML file degrades gracefully. A
|
|
// malformed mapper yields whatever was decoded before the error — never a
|
|
// hard failure.
|
|
func (e *MyBatisExtractor) Extract(filePath string, src []byte) (*parser.ExtractionResult, error) {
|
|
result := &parser.ExtractionResult{}
|
|
fileNode := &graph.Node{
|
|
ID: filePath,
|
|
Kind: graph.KindFile,
|
|
Name: path.Base(filePath),
|
|
FilePath: filePath,
|
|
Language: "mybatis",
|
|
}
|
|
result.Nodes = append(result.Nodes, fileNode)
|
|
|
|
if !IsMyBatisMapper(src) {
|
|
return result, nil
|
|
}
|
|
|
|
// One up-front line-start scan; every element offset resolves to a line via
|
|
// binary search instead of a per-element full-prefix newline count.
|
|
lineStarts := lineStartOffsets(src)
|
|
|
|
dec := xml.NewDecoder(bytes.NewReader(src))
|
|
dec.Strict = false
|
|
namespace := ""
|
|
currentStmt := "" // node ID of the statement / fragment currently open
|
|
seenID := map[string]bool{}
|
|
|
|
for {
|
|
tok, err := dec.Token()
|
|
if err == io.EOF || err != nil {
|
|
break // EOF or malformed — keep what was decoded
|
|
}
|
|
se, ok := tok.(xml.StartElement)
|
|
if !ok {
|
|
continue
|
|
}
|
|
local := strings.ToLower(se.Name.Local)
|
|
|
|
if local == "mapper" {
|
|
if ns := myBatisAttr(se, "namespace"); ns != "" {
|
|
namespace = ns
|
|
fileNode.Meta = map[string]any{"mybatis_namespace": ns}
|
|
}
|
|
continue
|
|
}
|
|
|
|
elemLine := lineForOffset(lineStarts, int(clampOffset(dec.InputOffset(), len(src))))
|
|
|
|
// `<sql id="…">` is a reusable SQL fragment — a first-class node other
|
|
// statements `<include refid>` into. It becomes the current element so a
|
|
// nested `<include>` attributes to it.
|
|
if local == "sql" {
|
|
fragID := myBatisAttr(se, "id")
|
|
if fragID == "" || namespace == "" {
|
|
continue
|
|
}
|
|
fragNodeID := namespace + "::" + fragID
|
|
if !seenID["sql:"+fragID] {
|
|
seenID["sql:"+fragID] = true
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: fragNodeID, Kind: graph.KindFunction, Name: fragID,
|
|
QualName: namespace + "." + fragID, FilePath: filePath,
|
|
StartLine: elemLine, EndLine: elemLine, Language: "mybatis",
|
|
Meta: map[string]any{
|
|
"mybatis_namespace": namespace,
|
|
"mybatis_fragment": true,
|
|
"signature": "<sql>",
|
|
},
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: filePath, To: fragNodeID, Kind: graph.EdgeDefines,
|
|
FilePath: filePath, Line: elemLine,
|
|
})
|
|
}
|
|
currentStmt = fragNodeID
|
|
continue
|
|
}
|
|
|
|
// `<include refid="…">` reuses a fragment. A bare refid resolves within
|
|
// this mapper; a dotted `otherNs.Frag` is a cross-mapper reference,
|
|
// resolved to that mapper's `otherNs::Frag` node ID.
|
|
if local == "include" {
|
|
refid := myBatisAttr(se, "refid")
|
|
if refid == "" || currentStmt == "" {
|
|
continue
|
|
}
|
|
target := namespace + "::" + refid
|
|
if i := strings.LastIndexByte(refid, '.'); i >= 0 {
|
|
target = refid[:i] + "::" + refid[i+1:]
|
|
}
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: currentStmt, To: target, Kind: graph.EdgeReferences,
|
|
FilePath: filePath, Line: elemLine,
|
|
Meta: map[string]any{"via": "mybatis.include", "refid": refid},
|
|
})
|
|
continue
|
|
}
|
|
|
|
if !myBatisStatementElems[local] {
|
|
continue
|
|
}
|
|
stmtID := myBatisAttr(se, "id")
|
|
if stmtID == "" || namespace == "" || seenID[stmtID] {
|
|
continue
|
|
}
|
|
seenID[stmtID] = true
|
|
|
|
// The statement's SQL body is read from the source slice rather
|
|
// than the decoder, so nested dynamic-SQL elements (<if>, <where>,
|
|
// <foreach>, …) are flattened to their text content. The decoder
|
|
// loop then walks those nested elements; none are statement elems,
|
|
// so they are skipped by the filter above.
|
|
startLine := lineForOffset(lineStarts, int(clampOffset(dec.InputOffset(), len(src))))
|
|
// Scan the statement body from the start of the start-tag's line (which
|
|
// the decoder offset has already moved past), matching the original
|
|
// line-derived behaviour without re-scanning the source from zero.
|
|
sql := myBatisStatementSQL(src, lineStarts[startLine-1])
|
|
|
|
stmtNodeID := namespace + "::" + stmtID
|
|
stmt := &graph.Node{
|
|
ID: stmtNodeID,
|
|
Kind: graph.KindMethod,
|
|
Name: stmtID,
|
|
QualName: namespace + "." + stmtID,
|
|
FilePath: filePath,
|
|
StartLine: startLine,
|
|
EndLine: startLine,
|
|
Language: "mybatis",
|
|
Meta: map[string]any{
|
|
"mybatis_namespace": namespace,
|
|
"mybatis_statement": stmtID,
|
|
"mybatis_sql_kind": local,
|
|
"mybatis_sql": sql,
|
|
},
|
|
}
|
|
rt := myBatisAttr(se, "resultType")
|
|
rm := myBatisAttr(se, "resultMap")
|
|
pt := myBatisAttr(se, "parameterType")
|
|
if rt != "" {
|
|
stmt.Meta["mybatis_result_type"] = rt
|
|
}
|
|
if rm != "" {
|
|
stmt.Meta["mybatis_result_map"] = rm
|
|
}
|
|
if pt != "" {
|
|
stmt.Meta["mybatis_parameter_type"] = pt
|
|
}
|
|
// Human-readable signature surfaced as meta.signature by get_symbol /
|
|
// search_symbols previews: `KIND [param=…] [result=…]`. Additive on top
|
|
// of the structured mybatis_* keys.
|
|
sig := strings.ToUpper(local)
|
|
if pt != "" {
|
|
sig += " param=" + pt
|
|
}
|
|
if rt != "" {
|
|
sig += " result=" + rt
|
|
} else if rm != "" {
|
|
sig += " result=" + rm
|
|
}
|
|
stmt.Meta["signature"] = sig
|
|
result.Nodes = append(result.Nodes, stmt)
|
|
currentStmt = stmtNodeID
|
|
|
|
// EdgeDefines from the file so the statement node is contained by
|
|
// its mapper file (matches every other extractor's file→symbol
|
|
// wiring).
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: filePath, To: stmtNodeID, Kind: graph.EdgeDefines,
|
|
FilePath: filePath, Line: startLine,
|
|
})
|
|
|
|
// Placeholder call edge from the SQL statement back to the Java
|
|
// DAO method that executes it. The synthesizer rewrites the
|
|
// `To` onto the resolved Java method node; until then the edge
|
|
// terminates on a deterministic unresolved:: placeholder keyed by
|
|
// namespace::id.
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: stmtNodeID,
|
|
To: myBatisUnresolvedPrefix + stmtNodeID,
|
|
Kind: graph.EdgeCalls,
|
|
Meta: map[string]any{
|
|
"via": "mybatis.mapper",
|
|
"mybatis_namespace": namespace,
|
|
"mybatis_statement": stmtID,
|
|
},
|
|
FilePath: filePath,
|
|
Line: startLine,
|
|
})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// myBatisAttr returns the value of the attribute with the given local
|
|
// name (namespace-agnostic).
|
|
func myBatisAttr(se xml.StartElement, local string) string {
|
|
for _, a := range se.Attr {
|
|
if a.Name.Local == local {
|
|
return strings.TrimSpace(a.Value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// myBatisStatementSQL returns the raw text body of the statement element whose
|
|
// start tag opens at byte offset off. It scans from off for the element's `>`
|
|
// then captures up to the matching close tag, stripping nested dynamic-SQL tags
|
|
// so the result reads as plain SQL. Taking the offset directly (captured from
|
|
// the decoder) avoids re-scanning the source from the start to re-derive it.
|
|
func myBatisStatementSQL(src []byte, off int) string {
|
|
if off < 0 || off > len(src) {
|
|
return ""
|
|
}
|
|
// Find the end of the opening start tag ('>') from here.
|
|
gt := bytes.IndexByte(src[off:], '>')
|
|
if gt < 0 {
|
|
return ""
|
|
}
|
|
bodyStart := off + gt + 1
|
|
// Find the next matching statement close tag.
|
|
rest := src[bodyStart:]
|
|
endRel := -1
|
|
for _, ct := range [][]byte{
|
|
[]byte("</select>"), []byte("</insert>"), []byte("</update>"), []byte("</delete>"),
|
|
[]byte("</SELECT>"), []byte("</INSERT>"), []byte("</UPDATE>"), []byte("</DELETE>"),
|
|
} {
|
|
if i := bytes.Index(rest, ct); i >= 0 && (endRel < 0 || i < endRel) {
|
|
endRel = i
|
|
}
|
|
}
|
|
if endRel < 0 {
|
|
endRel = len(rest)
|
|
}
|
|
body := rest[:endRel]
|
|
return normaliseMyBatisSQL(body)
|
|
}
|
|
|
|
// normaliseMyBatisSQL strips nested XML tags from a statement body and
|
|
// collapses whitespace so the stored SQL is a single readable string.
|
|
func normaliseMyBatisSQL(body []byte) string {
|
|
var out []byte
|
|
depth := 0 // inside a nested <…> tag
|
|
for i := 0; i < len(body); i++ {
|
|
c := body[i]
|
|
switch {
|
|
case c == '<':
|
|
depth++
|
|
case c == '>' && depth > 0:
|
|
depth--
|
|
case depth == 0:
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return strings.Join(strings.Fields(string(out)), " ")
|
|
}
|