- 新增 dialect/sqlite3/vtab 包,提供干净的虚拟表接口(Module/Table/Cursor) - 适配层自动将 BestIndex 约束与 Filter 值绑定(ConstraintInfo.Value),无需手动编解码 IdxStr - 支持 OpLIMIT/OpOFFSET 约束下推 - 新增 SupportsDistinct 方言选项,控制 SELECT 级和表达式级 DISTINCT 生成 - sqlite3 方言注册 IF() 函数支持
537 lines
16 KiB
Go
537 lines
16 KiB
Go
//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 // HIDDEN:API 鉴权 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 鉴权 token(HIDDEN 列传入)
|
||
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 下推给 API:Alice(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))
|
||
}
|