Files
db/dialect/sqlite3/vtab/api_test.go
T
what ac81f1ff0b feat: 新增 SQLite3 虚拟表框架及方言扩展
- 新增 dialect/sqlite3/vtab 包,提供干净的虚拟表接口(Module/Table/Cursor)
- 适配层自动将 BestIndex 约束与 Filter 值绑定(ConstraintInfo.Value),无需手动编解码 IdxStr
- 支持 OpLIMIT/OpOFFSET 约束下推
- 新增 SupportsDistinct 方言选项,控制 SELECT 级和表达式级 DISTINCT 生成
- sqlite3 方言注册 IF() 函数支持
2026-04-18 13:41:41 +08:00

537 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build sqlite_vtable || vtable
package vtab_test
// ─── 演示:过滤和分页全部走 API 接口 ──────────────────────────────
//
// 模拟场景:后端有一个分页 API,支持 name=? 和 age 比较条件。
// 通过 BestIndex + Filter 将 WHERE 约束下推给 API
// 避免全量拉取数据再由 SQLite 过滤。
//
// HIDDEN 列:token / page_size 不出现在 SELECT * 结果里,
// 但可以在 WHERE 里传递 API 参数,例如:
// SELECT id, name FROM api_users WHERE age > 28 AND token = 'Bearer xxx' AND page_size = 2
//
// 验证点:
// - 全量查询 6 条 / pageSize=3 → 发起 2 次 API 调用
// - WHERE name='Alice' 下推 → 1 次 API 调用
// - WHERE age > 28 下推 → 1 次 API 调用(结果 3 条,恰好 1 页)
// - WHERE page_size=2 通过 HIDDEN 列动态指定分页大小
import (
"database/sql"
"testing"
"git.fsdpf.net/go/db/dialect/sqlite3/vtab"
"github.com/stretchr/testify/suite"
)
// ── 列索引常量 ────────────────────────────────────────────────────
const (
apiColID = 0
apiColName = 1
apiColAge = 2
apiColToken = 3 // HIDDENAPI 鉴权 token
apiColPageSize = 4 // HIDDEN:自定义每页大小
// OpLIMIT / OpOFFSET 的 Column 字段固定为 -1,不对应任何列。
apiColLimit = -1
apiColOffset = -1
)
// ── Mock API 数据层 ───────────────────────────────────────────────
type apiUser struct {
ID int64
Name string
Age int64
}
type apiListResult struct {
Items []apiUser
HasMore bool
}
// apiQueryFilter 对应 API 接口支持的查询参数。
type apiQueryFilter struct {
Name string // name = ?(精确匹配)
AgeOp vtab.Op // age 的比较运算符
AgeVal int64 // age 的比较值
hasAge bool // 是否有 age 过滤
Token string // API 鉴权 tokenHIDDEN 列传入)
PageSize int // 每页大小(HIDDEN 列传入,0 表示使用默认值)
Limit int64 // SQL LIMIT 下推值(0 表示无限制)
Offset int64 // SQL OFFSET 下推值
}
// mockUserAPI 模拟支持过滤和分页的 HTTP API。
type mockUserAPI struct {
data []apiUser
pageSize int
Calls int // 记录 API 被调用次数,供测试断言
}
func newMockUserAPI() *mockUserAPI {
return &mockUserAPI{
pageSize: 3, // 每页 3 条,便于测试翻页
data: []apiUser{
{1, "Alice", 30},
{2, "Bob", 25},
{3, "Charlie", 35},
{4, "Dave", 28},
{5, "Eve", 22},
{6, "Frank", 40},
},
}
}
// List 模拟 GET /users?page=N&name=X&age_op=GT&age_val=28
func (a *mockUserAPI) List(page int, f apiQueryFilter) apiListResult {
a.Calls++
// 服务端过滤(模拟 API 的 WHERE 逻辑)
var filtered []apiUser
for _, u := range a.data {
if f.Name != "" && u.Name != f.Name {
continue
}
if f.hasAge {
switch f.AgeOp {
case vtab.OpEQ:
if u.Age != f.AgeVal {
continue
}
case vtab.OpGT:
if !(u.Age > f.AgeVal) {
continue
}
case vtab.OpGE:
if !(u.Age >= f.AgeVal) {
continue
}
case vtab.OpLT:
if !(u.Age < f.AgeVal) {
continue
}
case vtab.OpLE:
if !(u.Age <= f.AgeVal) {
continue
}
}
}
filtered = append(filtered, u)
}
// 服务端分页
start := (page - 1) * a.pageSize
if start >= len(filtered) {
return apiListResult{}
}
end := start + a.pageSize
hasMore := end < len(filtered)
if end > len(filtered) {
end = len(filtered)
}
return apiListResult{Items: filtered[start:end], HasMore: hasMore}
}
// ── vtab Module ───────────────────────────────────────────────────
type apiUsersModule struct {
api *mockUserAPI
}
func (m *apiUsersModule) Create(args []string, declare func(string) error) (vtab.Table, error) {
// HIDDEN 列不出现在 SELECT * 结果里,但可以在 WHERE 里传递 API 参数。
if err := declare(`CREATE TABLE api_users(
id INTEGER,
name TEXT,
age INTEGER,
token TEXT HIDDEN,
page_size INTEGER HIDDEN
)`); err != nil {
return nil, err
}
return &apiUsersTable{api: m.api}, nil
}
func (m *apiUsersModule) Connect(args []string, declare func(string) error) (vtab.Table, error) {
return m.Create(args, declare)
}
// ── vtab Table ────────────────────────────────────────────────────
type apiUsersTable struct {
api *mockUserAPI
}
// BestIndex 告知 SQLite 哪些约束由本表(API)处理:
// - name = ? → 下推
// - age =/>/>=/</<=? → 下推
// - HIDDEN 列 token / page_size → 下推
// - LIMIT / OFFSET → 下推
//
// 适配层会自动将 Used=true 的约束与值绑定后传给 Filter,无需编解码 IdxStr。
func (t *apiUsersTable) BestIndex(constraints []vtab.ConstraintInfo, _ []vtab.OrderByInfo) (*vtab.IndexOutput, error) {
used := make([]bool, len(constraints))
for i, c := range constraints {
if !c.Usable {
continue
}
switch {
case c.Column == apiColName && c.Op == vtab.OpEQ:
used[i] = true
case c.Column == apiColAge:
switch c.Op {
case vtab.OpEQ, vtab.OpGT, vtab.OpGE, vtab.OpLT, vtab.OpLE:
used[i] = true
}
case c.Column == apiColToken && c.Op == vtab.OpEQ:
used[i] = true
case c.Column == apiColPageSize && c.Op == vtab.OpEQ:
used[i] = true
case c.Op == vtab.OpLIMIT:
used[i] = true
case c.Op == vtab.OpOFFSET:
used[i] = true
}
}
return &vtab.IndexOutput{Used: used}, nil
}
func (t *apiUsersTable) Open() (vtab.Cursor, error) {
return &apiUsersCursor{api: t.api}, nil
}
func (t *apiUsersTable) Disconnect() error { return nil }
func (t *apiUsersTable) Destroy() error { return nil }
// ── vtab Cursor ───────────────────────────────────────────────────
type apiUsersCursor struct {
api *mockUserAPI
filter apiQueryFilter
// 当前页状态
items []apiUser
pos int // 当前页内下标
// 分页状态
page int
hasMore bool
// LIMIT 下推:记录已向 SQLite 发出的行数,到达 Limit 时停止
emitted int64
}
// Filter 从适配层收到已绑定值的约束,直接构建 API 查询参数,拉取第一页。
func (c *apiUsersCursor) Filter(_ int, constraints []vtab.ConstraintInfo) error {
c.filter = apiQueryFilter{}
for _, fc := range constraints {
switch {
case fc.Op == vtab.OpLIMIT:
c.filter.Limit = toInt64(fc.Value)
case fc.Op == vtab.OpOFFSET:
c.filter.Offset = toInt64(fc.Value)
case fc.Column == apiColName && fc.Op == vtab.OpEQ:
c.filter.Name, _ = fc.Value.(string)
case fc.Column == apiColAge:
c.filter.AgeOp = fc.Op
c.filter.AgeVal = toInt64(fc.Value)
c.filter.hasAge = true
case fc.Column == apiColToken && fc.Op == vtab.OpEQ:
c.filter.Token, _ = fc.Value.(string)
case fc.Column == apiColPageSize && fc.Op == vtab.OpEQ:
c.filter.PageSize = int(toInt64(fc.Value))
}
}
c.page = 1
c.pos = 0
c.emitted = 0
c.items = nil
return c.fetchPage()
}
// fetchPage 调用 API 拉取当前页数据。
// 若 WHERE 里传入了 page_size,临时覆盖默认值。
// 若 SQL 有 LIMIT/OFFSET 下推,计算实际需要拉取的数量。
func (c *apiUsersCursor) fetchPage() error {
if c.filter.PageSize > 0 {
c.api.pageSize = c.filter.PageSize
}
result := c.api.List(c.page, c.filter)
c.items = result.Items
c.hasMore = result.HasMore
// OFFSET 下推:第一页跳过前 Offset 条(假设 Offset < pageSize
if c.page == 1 && c.filter.Offset > 0 {
skip := int(c.filter.Offset)
if skip >= len(c.items) {
c.items = nil
} else {
c.items = c.items[skip:]
}
}
c.pos = 0
return nil
}
// Next 移动到下一行;当前页耗尽且还有下一页时自动翻页。
// LIMIT 下推时,到达限制行数后不再翻页。
func (c *apiUsersCursor) Next() error {
c.pos++
c.emitted++
// 已达 LIMIT,不再翻页
if c.filter.Limit > 0 && c.emitted >= c.filter.Limit {
return nil
}
if c.pos >= len(c.items) && c.hasMore {
c.page++
return c.fetchPage()
}
return nil
}
func (c *apiUsersCursor) EOF() bool {
if c.filter.Limit > 0 && c.emitted >= c.filter.Limit {
return true
}
return c.pos >= len(c.items) && !c.hasMore
}
func (c *apiUsersCursor) Rowid() (int64, error) {
return c.items[c.pos].ID, nil
}
func (c *apiUsersCursor) Column(col int) (any, error) {
u := c.items[c.pos]
switch col {
case apiColID:
return u.ID, nil
case apiColName:
return u.Name, nil
case apiColAge:
return u.Age, nil
}
return nil, nil
}
func (c *apiUsersCursor) Close() error { return nil }
// ── 注册模块 & 测试套件 ───────────────────────────────────────────
var _apiMock = newMockUserAPI()
func init() {
vtab.Register("api_users_mod", &apiUsersModule{api: _apiMock})
}
type APIVtabSuite struct {
suite.Suite
db *sql.DB
}
func (s *APIVtabSuite) SetupSuite() {
db, err := sql.Open(vtab.DriverName, ":memory:")
s.Require().NoError(err)
_, err = db.Exec(`CREATE VIRTUAL TABLE api_users USING api_users_mod()`)
s.Require().NoError(err)
s.db = db
}
func (s *APIVtabSuite) TearDownSuite() {
s.db.Close()
}
// SetupTest 每个用例前重置 API 状态。
func (s *APIVtabSuite) SetupTest() {
_apiMock.Calls = 0
_apiMock.pageSize = 3
}
// ── SELECT 全量 ───────────────────────────────────────────────────
// 6 条数据 / pageSize=3 → 需要翻 2 页 → 2 次 API 调用。
func (s *APIVtabSuite) TestSelect_All_TwoAPIPages() {
rows, err := s.db.Query(`SELECT id, name, age FROM api_users ORDER BY id`)
s.Require().NoError(err)
defer rows.Close()
var result []apiUser
for rows.Next() {
var u apiUser
s.Require().NoError(rows.Scan(&u.ID, &u.Name, &u.Age))
result = append(result, u)
}
s.Require().NoError(rows.Err())
s.Equal(6, len(result), "应返回全部 6 条数据")
s.Equal(2, _apiMock.Calls, "pageSize=3,全量扫描应发起 2 次 API 调用")
}
// ── WHERE 下推:name ──────────────────────────────────────────────
// name='Alice' 下推给 API,结果 1 条,1 页即止。
func (s *APIVtabSuite) TestSelect_FilterName_PushedToAPI() {
rows, err := s.db.Query(`SELECT id, name, age FROM api_users WHERE name = 'Alice'`)
s.Require().NoError(err)
defer rows.Close()
var result []apiUser
for rows.Next() {
var u apiUser
s.Require().NoError(rows.Scan(&u.ID, &u.Name, &u.Age))
result = append(result, u)
}
s.Require().NoError(rows.Err())
s.Equal([]apiUser{{1, "Alice", 30}}, result)
s.Equal(1, _apiMock.Calls, "API 过滤后 1 页,只调用 1 次")
}
// ── WHERE 下推:age ───────────────────────────────────────────────
// age > 28 下推给 APIAlice(30)、Charlie(35)、Frank(40) → 3 条,恰好 1 页。
func (s *APIVtabSuite) TestSelect_FilterAge_PushedToAPI() {
rows, err := s.db.Query(`SELECT name FROM api_users WHERE age > 28 ORDER BY id`)
s.Require().NoError(err)
defer rows.Close()
var names []string
for rows.Next() {
var name string
s.Require().NoError(rows.Scan(&name))
names = append(names, name)
}
s.Require().NoError(rows.Err())
s.Equal([]string{"Alice", "Charlie", "Frank"}, names)
s.Equal(1, _apiMock.Calls, "API 过滤后 3 条恰好 1 页,只调用 1 次")
}
// age <= 25 下推:Bob(25)、Eve(22) → 2 条,1 页。
func (s *APIVtabSuite) TestSelect_FilterAgeLE_PushedToAPI() {
rows, err := s.db.Query(`SELECT name FROM api_users WHERE age <= 25 ORDER BY id`)
s.Require().NoError(err)
defer rows.Close()
var names []string
for rows.Next() {
var name string
s.Require().NoError(rows.Scan(&name))
names = append(names, name)
}
s.Require().NoError(rows.Err())
s.Equal([]string{"Bob", "Eve"}, names)
s.Equal(1, _apiMock.Calls, "过滤后 2 条,1 次 API 调用")
}
// ── WHERE 下推:name + age 组合 ───────────────────────────────────
// name='Alice' AND age >= 30 → 同时下推两个约束。
func (s *APIVtabSuite) TestSelect_FilterNameAndAge_BothPushed() {
rows, err := s.db.Query(`SELECT name, age FROM api_users WHERE name = 'Alice' AND age >= 30`)
s.Require().NoError(err)
defer rows.Close()
var result []apiUser
for rows.Next() {
var u apiUser
s.Require().NoError(rows.Scan(&u.Name, &u.Age))
result = append(result, u)
}
s.Require().NoError(rows.Err())
s.Equal([]apiUser{{Name: "Alice", Age: 30}}, result)
s.Equal(1, _apiMock.Calls, "组合过滤后 1 条,1 次 API 调用")
}
// ── HIDDEN 列:通过 WHERE 传递 API 参数 ───────────────────────────
// page_size=2 通过 HIDDEN 列传入,6 条数据需要 3 次 API 调用。
func (s *APIVtabSuite) TestHidden_PageSize() {
// 先重置为默认 pageSize=3
_apiMock.pageSize = 3
rows, err := s.db.Query(`SELECT id, name FROM api_users WHERE page_size = 2`)
s.Require().NoError(err)
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
var name string
s.Require().NoError(rows.Scan(&id, &name))
ids = append(ids, id)
}
s.Require().NoError(rows.Err())
s.Equal(6, len(ids), "page_size=2 依然能拿到全部 6 条")
s.Equal(3, _apiMock.Calls, "page_size=2 时 6 条数据需要 3 次 API 调用")
}
// HIDDEN 列不出现在 SELECT * 中。
func (s *APIVtabSuite) TestHidden_NotInSelectStar() {
_apiMock.pageSize = 3
rows, err := s.db.Query(`SELECT * FROM api_users WHERE id = 1`)
s.Require().NoError(err)
defer rows.Close()
// SELECT * 应该只有 3 列(id/name/age),不含 token/page_size
cols, err := rows.Columns()
s.Require().NoError(err)
s.Equal([]string{"id", "name", "age"}, cols)
}
// ── LIMIT / OFFSET 下推 ───────────────────────────────────────────
// LIMIT 3 下推:vtab 直接截断,只发 1 次 API 调用(不翻页)。
func (s *APIVtabSuite) TestLimitOffset_LimitPushdown() {
rows, err := s.db.Query(`SELECT id, name FROM api_users ORDER BY id LIMIT 3`)
s.Require().NoError(err)
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
var name string
s.Require().NoError(rows.Scan(&id, &name))
ids = append(ids, id)
}
s.Require().NoError(rows.Err())
s.Equal([]int64{1, 2, 3}, ids)
s.Equal(1, _apiMock.Calls, "LIMIT 3 下推后只需 1 次 API 调用,不翻页")
}
// LIMIT 2 OFFSET 2:跳过前 2 条,取第 3、4 条。
func (s *APIVtabSuite) TestLimitOffset_LimitAndOffset() {
rows, err := s.db.Query(`SELECT id FROM api_users ORDER BY id LIMIT 2 OFFSET 2`)
s.Require().NoError(err)
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
s.Require().NoError(rows.Scan(&id))
ids = append(ids, id)
}
s.Require().NoError(rows.Err())
s.Equal([]int64{3, 4}, ids)
// OFFSET=2 跳过 page1 前 2 条只剩 1 条,不足 LIMIT=2,必须取 page2 补齐 → 2 次 API 调用
s.Equal(2, _apiMock.Calls, "OFFSET 跨页时需要 2 次 API 调用")
}
func TestAPIVtabSuite(t *testing.T) {
suite.Run(t, new(APIVtabSuite))
}