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:
2026-04-18 13:41:41 +08:00
parent 2221ca0ddb
commit ac81f1ff0b
13 changed files with 1443 additions and 9 deletions
+536
View File
@@ -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 // 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))
}