feat: 新增 SQLite3 虚拟表框架及方言扩展
- 新增 dialect/sqlite3/vtab 包,提供干净的虚拟表接口(Module/Table/Cursor) - 适配层自动将 BestIndex 约束与 Filter 值绑定(ConstraintInfo.Value),无需手动编解码 IdxStr - 支持 OpLIMIT/OpOFFSET 约束下推 - 新增 SupportsDistinct 方言选项,控制 SELECT 级和表达式级 DISTINCT 生成 - sqlite3 方言注册 IF() 函数支持
This commit is contained in:
@@ -0,0 +1,536 @@
|
||||
//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))
|
||||
}
|
||||
Reference in New Issue
Block a user