package languages
import (
"bytes"
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/graph"
)
const sampleMyBatisMapper = `
SELECT id, name FROM users WHERE id = #{id}
INSERT INTO users (name) VALUES (#{name})
UPDATE users SET name = #{name}
id = #{id}
DELETE FROM users WHERE id = #{id}
id, name
`
func TestMyBatisExtractor_StatementNodes(t *testing.T) {
res, err := NewMyBatisExtractor().Extract("UserMapper.xml", []byte(sampleMyBatisMapper))
require.NoError(t, err)
var file *graph.Node
stmts := map[string]*graph.Node{}
for _, n := range res.Nodes {
switch n.Kind {
case graph.KindFile:
file = n
case graph.KindMethod:
stmts[n.ID] = n
}
}
require.NotNil(t, file)
require.Equal(t, "com.app.UserMapper", file.Meta["mybatis_namespace"])
// One node per /// — and
// are excluded.
require.Len(t, stmts, 4)
cases := []struct {
id string
kind string
sqlPart string
}{
{"com.app.UserMapper::findUser", "select", "SELECT id, name FROM users"},
{"com.app.UserMapper::insertUser", "insert", "INSERT INTO users"},
{"com.app.UserMapper::updateUser", "update", "UPDATE users SET name"},
{"com.app.UserMapper::deleteUser", "delete", "DELETE FROM users"},
}
for _, c := range cases {
n := stmts[c.id]
require.NotNil(t, n, "missing statement node %s", c.id)
require.Equal(t, c.kind, n.Meta["mybatis_sql_kind"])
require.Equal(t, "com.app.UserMapper", n.Meta["mybatis_namespace"])
sql, _ := n.Meta["mybatis_sql"].(string)
require.Contains(t, sql, c.sqlPart, "statement %s SQL", c.id)
}
// The dynamic-SQL / body is flattened into the stored SQL.
require.Contains(t, stmts["com.app.UserMapper::updateUser"].Meta["mybatis_sql"], "id = #{id}")
// Each statement emits a placeholder call edge keyed by namespace::id.
placeholders := map[string]bool{}
for _, e := range res.Edges {
if e.Kind == graph.EdgeCalls {
if via, _ := e.Meta["via"].(string); via == "mybatis.mapper" {
placeholders[e.To] = true
}
}
}
require.True(t, placeholders["unresolved::mybatis::com.app.UserMapper::findUser"])
require.Len(t, placeholders, 4)
}
func TestMyBatisExtractor_NonMapperXMLYieldsOnlyFileNode(t *testing.T) {
plain := []byte(`1 `)
res, err := NewMyBatisExtractor().Extract("config.xml", plain)
require.NoError(t, err)
require.Len(t, res.Nodes, 1) // file node only
require.Equal(t, graph.KindFile, res.Nodes[0].Kind)
require.Empty(t, res.Edges)
}
func TestIsMyBatisMapper(t *testing.T) {
require.True(t, IsMyBatisMapper([]byte(``)))
require.True(t, IsMyBatisMapper([]byte(``)))
require.False(t, IsMyBatisMapper([]byte(` `)))
require.False(t, IsMyBatisMapper([]byte(`no namespace here `)))
}
func TestMyBatisExtractor_Malformed(t *testing.T) {
res, err := NewMyBatisExtractor().Extract("bad.xml", []byte(`SELECT 1`))
require.NoError(t, err) // never a hard failure
require.NotEmpty(t, res.Nodes) // at least the file node survives
}
func TestMyBatisExtractor_Extensions(t *testing.T) {
require.Equal(t, []string{".xml"}, NewMyBatisExtractor().Extensions())
}
func TestMyBatisExtractor_SignatureString(t *testing.T) {
src := []byte(`
id, name
SELECT * FROM users
SELECT *
`)
res, err := NewMyBatisExtractor().Extract("UserMapper.xml", src)
require.NoError(t, err)
sigs := map[string]string{}
for _, n := range res.Nodes {
if n.Meta != nil {
if s, ok := n.Meta["signature"].(string); ok {
sigs[n.Name] = s
}
}
}
require.Equal(t, "SELECT param=Long result=User", sigs["findUser"])
require.Equal(t, "SELECT result=userMap", sigs["byMap"]) // resultMap fallback
require.Equal(t, "", sigs["cols"])
// The structured mybatis_* keys remain intact alongside the signature.
for _, n := range res.Nodes {
if n.Name == "findUser" {
require.Equal(t, "select", n.Meta["mybatis_sql_kind"])
require.Equal(t, "Long", n.Meta["mybatis_parameter_type"])
require.Equal(t, "User", n.Meta["mybatis_result_type"])
}
}
}
func TestMyBatisExtractor_LineNumbersMultiStatement(t *testing.T) {
// Line numbers come from the precomputed line-start table + binary search;
// assert they match the source positions exactly across several elements.
src := []byte("\n" + // line 1
"\n" + // line 2
"\n" + // line 3
" id, name \n" + // line 4
" SELECT 1 \n" + // line 5
" INSERT \n" + // line 6
" \n")
res, err := NewMyBatisExtractor().Extract("M.xml", src)
require.NoError(t, err)
lines := map[string]int{}
for _, n := range res.Nodes {
if n.Kind == graph.KindMethod || n.Kind == graph.KindFunction {
lines[n.Name] = n.StartLine
}
}
require.Equal(t, 4, lines["cols"])
require.Equal(t, 5, lines["a"])
require.Equal(t, 6, lines["b"])
}
// BenchmarkMyBatisLineResolution contrasts resolving every element offset to a
// line via the precomputed line-start table + binary search (one O(n) scan,
// then O(log n) per offset) against the previous per-element full-prefix
// newline count (O(offset) per element → O(n·m) overall). The "precomputed"
// sub-benchmark is asymptotically faster and allocation-free per offset.
func BenchmarkMyBatisLineResolution(b *testing.B) {
var sb strings.Builder
for i := 0; i < 20000; i++ {
fmt.Fprintf(&sb, " SELECT 1 \n", i)
}
src := []byte(sb.String())
var offs []int
for i, c := range src {
if c == '\n' {
offs = append(offs, i)
}
}
b.Run("precomputed_binary_search", func(b *testing.B) {
b.ReportAllocs()
for n := 0; n < b.N; n++ {
starts := lineStartOffsets(src)
sum := 0
for _, off := range offs {
sum += lineForOffset(starts, off)
}
_ = sum
}
})
b.Run("per_element_prefix_count", func(b *testing.B) {
b.ReportAllocs()
for n := 0; n < b.N; n++ {
sum := 0
for _, off := range offs {
sum += 1 + bytes.Count(src[:off], []byte{'\n'})
}
_ = sum
}
})
}