feat: add state store migrations and repositories
This commit is contained in:
22
go.mod
22
go.mod
@@ -1,3 +1,25 @@
|
||||
module sub2api-cn-relay-manager
|
||||
|
||||
go 1.22.2
|
||||
|
||||
require modernc.org/sqlite v1.18.1
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/mattn/go-isatty v0.0.12 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
|
||||
golang.org/x/mod v0.3.0 // indirect
|
||||
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac // indirect
|
||||
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
lukechampine.com/uint128 v1.1.1 // indirect
|
||||
modernc.org/cc/v3 v3.36.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.8 // indirect
|
||||
modernc.org/libc v1.16.19 // indirect
|
||||
modernc.org/mathutil v1.4.1 // indirect
|
||||
modernc.org/memory v1.1.1 // indirect
|
||||
modernc.org/opt v0.1.1 // indirect
|
||||
modernc.org/strutil v1.1.1 // indirect
|
||||
modernc.org/token v1.0.0 // indirect
|
||||
)
|
||||
|
||||
80
go.sum
Normal file
80
go.sum
Normal file
@@ -0,0 +1,80 @@
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/google/go-cmp v0.5.3 h1:x95R7cp+rSeeqAMI2knLtQ0DKlaBhv2NrtrOvafPHRo=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-sqlite3 v1.14.14 h1:qZgc/Rwetq+MtyE18WhzjokPD93dNqLGNT3QJuLvBGw=
|
||||
github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac h1:oN6lz7iLW/YC7un8pq+9bOLyXrprv2+DKfkJY+2LJJw=
|
||||
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 h1:M8tBwCtWD/cZV9DZpFYRUgaymAYAr+aIUTWzDaM3uPs=
|
||||
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
lukechampine.com/uint128 v1.1.1 h1:pnxCASz787iMf+02ssImqk6OLt+Z5QHMoZyUXR4z6JU=
|
||||
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.36.0 h1:0kmRkTmqNidmu3c7BNDSdVHCxXCkWLmWmCIVX4LUboo=
|
||||
modernc.org/cc/v3 v3.36.0/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
|
||||
modernc.org/ccgo/v3 v3.0.0-20220428102840-41399a37e894/go.mod h1:eI31LL8EwEBKPpNpA4bU1/i+sKOwOrQy8D87zWUcRZc=
|
||||
modernc.org/ccgo/v3 v3.0.0-20220430103911-bc99d88307be/go.mod h1:bwdAnOoaIt8Ax9YdWGjxWsdkPcZyRPHqrOvJxaKAKGw=
|
||||
modernc.org/ccgo/v3 v3.16.6/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ=
|
||||
modernc.org/ccgo/v3 v3.16.8 h1:G0QNlTqI5uVgczBWfGKs7B++EPwCfXPWGD2MdeKloDs=
|
||||
modernc.org/ccgo/v3 v3.16.8/go.mod h1:zNjwkizS+fIFDrDjIAgBSCLkWbJuHF+ar3QRn+Z9aws=
|
||||
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
|
||||
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
|
||||
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
|
||||
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
|
||||
modernc.org/libc v0.0.0-20220428101251-2d5f3daf273b/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA=
|
||||
modernc.org/libc v1.16.0/go.mod h1:N4LD6DBE9cf+Dzf9buBlzVJndKr/iJHG97vGLHYnb5A=
|
||||
modernc.org/libc v1.16.1/go.mod h1:JjJE0eu4yeK7tab2n4S1w8tlWd9MxXLRzheaRnAKymU=
|
||||
modernc.org/libc v1.16.17/go.mod h1:hYIV5VZczAmGZAnG15Vdngn5HSF5cSkbvfz2B7GRuVU=
|
||||
modernc.org/libc v1.16.19 h1:S8flPn5ZeXx6iw/8yNa986hwTQDrY8RXU7tObZuAozo=
|
||||
modernc.org/libc v1.16.19/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA=
|
||||
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/mathutil v1.4.1 h1:ij3fYGe8zBF4Vu+g0oT7mB06r8sqGWKuJu1yXeR4by8=
|
||||
modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.1.1 h1:bDOL0DIDLQv7bWhP3gMvIrnoFw+Eo6F7a2QK9HPDiFU=
|
||||
modernc.org/memory v1.1.1/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
|
||||
modernc.org/opt v0.1.1 h1:/0RX92k9vwVeDXj+Xn23DKp2VJubL7k8qNffND6qn3A=
|
||||
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sqlite v1.18.1 h1:ko32eKt3jf7eqIkCgPAeHMBXw3riNSLhl2f3loEF7o8=
|
||||
modernc.org/sqlite v1.18.1/go.mod h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4=
|
||||
modernc.org/strutil v1.1.1 h1:xv+J1BXY3Opl2ALrBwyfEikFAj8pmqcpnfmuwUwcozs=
|
||||
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
|
||||
modernc.org/tcl v1.13.1 h1:npxzTwFTZYM8ghWicVIX1cRWzj7Nd8i6AqqX2p+IYao=
|
||||
modernc.org/tcl v1.13.1/go.mod h1:XOLfOwzhkljL4itZkK6T72ckMgvj0BDsnKNdZVUOecw=
|
||||
modernc.org/token v1.0.0 h1:a0jaWiNMDhDUtqOj09wvjWWAqd3q7WpBulmL9H2egsk=
|
||||
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/z v1.5.1 h1:RTNHdsrOpeoSeOF4FbzTo8gBYByaJ5xT7NgZ9ZqRiJM=
|
||||
modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8=
|
||||
32
internal/store/migrations/0001_init.sql
Normal file
32
internal/store/migrations/0001_init.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
CREATE TABLE hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id TEXT NOT NULL UNIQUE,
|
||||
base_url TEXT NOT NULL,
|
||||
host_version TEXT NOT NULL,
|
||||
capability_probe_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE packs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pack_id TEXT NOT NULL UNIQUE,
|
||||
version TEXT NOT NULL,
|
||||
checksum TEXT NOT NULL,
|
||||
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE providers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pack_id INTEGER NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_providers_pack
|
||||
FOREIGN KEY (pack_id)
|
||||
REFERENCES packs(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT uq_providers_pack_provider
|
||||
UNIQUE (pack_id, provider_id)
|
||||
);
|
||||
8
internal/store/migrations/embed.go
Normal file
8
internal/store/migrations/embed.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package migrations
|
||||
|
||||
import "embed"
|
||||
|
||||
// Files embeds SQL migration assets for release builds.
|
||||
//
|
||||
//go:embed *.sql
|
||||
var Files embed.FS
|
||||
340
internal/store/sqlite/db.go
Normal file
340
internal/store/sqlite/db.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
"sub2api-cn-relay-manager/internal/store/migrations"
|
||||
)
|
||||
|
||||
type execQuerier interface {
|
||||
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
Hosts *HostsRepo
|
||||
Packs *PacksRepo
|
||||
Providers *ProvidersRepo
|
||||
}
|
||||
|
||||
type DB struct {
|
||||
sqlDB *sql.DB
|
||||
queries *Queries
|
||||
}
|
||||
|
||||
func Open(ctx context.Context, dsn string) (*DB, error) {
|
||||
sqlDB, err := sql.Open("sqlite", withForeignKeysEnabled(dsn))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite database: %w", err)
|
||||
}
|
||||
|
||||
if err := sqlDB.PingContext(ctx); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, fmt.Errorf("ping sqlite database: %w", err)
|
||||
}
|
||||
|
||||
if err := ensureForeignKeys(ctx, sqlDB); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := migrate(ctx, sqlDB); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &DB{
|
||||
sqlDB: sqlDB,
|
||||
queries: newQueries(sqlDB),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (db *DB) Close() error {
|
||||
return db.sqlDB.Close()
|
||||
}
|
||||
|
||||
func (db *DB) SQLDB() *sql.DB {
|
||||
return db.sqlDB
|
||||
}
|
||||
|
||||
func (db *DB) Hosts() *HostsRepo {
|
||||
return db.queries.Hosts
|
||||
}
|
||||
|
||||
func (db *DB) Packs() *PacksRepo {
|
||||
return db.queries.Packs
|
||||
}
|
||||
|
||||
func (db *DB) Providers() *ProvidersRepo {
|
||||
return db.queries.Providers
|
||||
}
|
||||
|
||||
func (db *DB) WithTx(ctx context.Context, fn func(*Queries) error) error {
|
||||
tx, err := db.sqlDB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin sqlite transaction: %w", err)
|
||||
}
|
||||
|
||||
queries := newQueries(tx)
|
||||
if err := fn(queries); err != nil {
|
||||
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||
return errors.Join(err, fmt.Errorf("rollback sqlite transaction: %w", rollbackErr))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("commit sqlite transaction: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newQueries(db execQuerier) *Queries {
|
||||
return &Queries{
|
||||
Hosts: newHostsRepo(db),
|
||||
Packs: newPacksRepo(db),
|
||||
Providers: newProvidersRepo(db),
|
||||
}
|
||||
}
|
||||
|
||||
func migrate(ctx context.Context, db *sql.DB) error {
|
||||
migrationNames, err := migrationFileNames()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin sqlite migration transaction: %w", err)
|
||||
}
|
||||
|
||||
if err := ensureMigrationLedger(ctx, tx); err != nil {
|
||||
return rollbackMigration(tx, err)
|
||||
}
|
||||
|
||||
appliedMigrations, err := loadAppliedMigrations(ctx, tx)
|
||||
if err != nil {
|
||||
return rollbackMigration(tx, err)
|
||||
}
|
||||
|
||||
if err := backfillLegacySchemaIfNeeded(ctx, tx, migrationNames, appliedMigrations); err != nil {
|
||||
return rollbackMigration(tx, err)
|
||||
}
|
||||
|
||||
for _, name := range migrationNames {
|
||||
if appliedMigrations[name] {
|
||||
continue
|
||||
}
|
||||
|
||||
migrationSQL, err := readMigration(name)
|
||||
if err != nil {
|
||||
return rollbackMigration(tx, err)
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, migrationSQL); err != nil {
|
||||
return rollbackMigration(tx, fmt.Errorf("apply sqlite migration %s: %w", name, err))
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(
|
||||
ctx,
|
||||
"INSERT INTO schema_migrations (version) VALUES (?)",
|
||||
name,
|
||||
); err != nil {
|
||||
return rollbackMigration(tx, fmt.Errorf("record sqlite migration %s: %w", name, err))
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("commit sqlite migration transaction: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func withForeignKeysEnabled(dsn string) string {
|
||||
const pragma = "_pragma=foreign_keys(1)"
|
||||
|
||||
if strings.Contains(dsn, "?") {
|
||||
return dsn + "&" + pragma
|
||||
}
|
||||
|
||||
return dsn + "?" + pragma
|
||||
}
|
||||
|
||||
func ensureForeignKeys(ctx context.Context, db *sql.DB) error {
|
||||
if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
|
||||
return fmt.Errorf("enable sqlite foreign keys: %w", err)
|
||||
}
|
||||
|
||||
var enabled int
|
||||
if err := db.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&enabled); err != nil {
|
||||
return fmt.Errorf("verify sqlite foreign keys: %w", err)
|
||||
}
|
||||
if enabled != 1 {
|
||||
return errors.New("sqlite foreign keys are disabled")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureMigrationLedger(ctx context.Context, tx *sql.Tx) error {
|
||||
const createLedgerSQL = `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`
|
||||
|
||||
if _, err := tx.ExecContext(ctx, createLedgerSQL); err != nil {
|
||||
return fmt.Errorf("create schema_migrations table: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadAppliedMigrations(ctx context.Context, tx *sql.Tx) (map[string]bool, error) {
|
||||
rows, err := tx.QueryContext(ctx, "SELECT version FROM schema_migrations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query applied sqlite migrations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
applied := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var version string
|
||||
if err := rows.Scan(&version); err != nil {
|
||||
return nil, fmt.Errorf("scan applied sqlite migration: %w", err)
|
||||
}
|
||||
|
||||
applied[version] = true
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate applied sqlite migrations: %w", err)
|
||||
}
|
||||
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
func migrationFileNames() ([]string, error) {
|
||||
entries, err := fs.ReadDir(migrations.Files, ".")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read embedded sqlite migrations: %w", err)
|
||||
}
|
||||
|
||||
var names []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
|
||||
sort.Strings(names)
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func readMigration(name string) (string, error) {
|
||||
data, err := fs.ReadFile(migrations.Files, name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read embedded migration %s: %w", name, err)
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func rollbackMigration(tx *sql.Tx, err error) error {
|
||||
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||
return errors.Join(err, fmt.Errorf("rollback sqlite migration transaction: %w", rollbackErr))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func backfillLegacySchemaIfNeeded(ctx context.Context, tx *sql.Tx, migrationNames []string, appliedMigrations map[string]bool) error {
|
||||
if len(migrationNames) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(appliedMigrations) != 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
firstMigration := migrationNames[0]
|
||||
if firstMigration != "0001_init.sql" {
|
||||
return nil
|
||||
}
|
||||
|
||||
complete, partial, err := detectLegacy0001Schema(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if partial {
|
||||
return errors.New("legacy sqlite schema is partially applied without schema_migrations")
|
||||
}
|
||||
if !complete {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(
|
||||
ctx,
|
||||
"INSERT INTO schema_migrations (version) VALUES (?)",
|
||||
firstMigration,
|
||||
); err != nil {
|
||||
return fmt.Errorf("backfill sqlite migration %s: %w", firstMigration, err)
|
||||
}
|
||||
|
||||
appliedMigrations[firstMigration] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func detectLegacy0001Schema(ctx context.Context, tx *sql.Tx) (complete bool, partial bool, err error) {
|
||||
legacyTables := []string{"hosts", "packs", "providers"}
|
||||
|
||||
existing := 0
|
||||
for _, table := range legacyTables {
|
||||
found, err := tableExists(ctx, tx, table)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
if found {
|
||||
existing++
|
||||
}
|
||||
}
|
||||
|
||||
switch existing {
|
||||
case 0:
|
||||
return false, false, nil
|
||||
case len(legacyTables):
|
||||
return true, false, nil
|
||||
default:
|
||||
return false, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
func tableExists(ctx context.Context, db execQuerier, table string) (bool, error) {
|
||||
var name string
|
||||
err := db.QueryRowContext(
|
||||
ctx,
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
table,
|
||||
).Scan(&name)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check sqlite table %s: %w", table, err)
|
||||
}
|
||||
|
||||
return name == table, nil
|
||||
}
|
||||
60
internal/store/sqlite/hosts_repo.go
Normal file
60
internal/store/sqlite/hosts_repo.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Host struct {
|
||||
HostID string
|
||||
BaseURL string
|
||||
HostVersion string
|
||||
CapabilityProbeJSON string
|
||||
}
|
||||
|
||||
type HostsRepo struct {
|
||||
db execQuerier
|
||||
}
|
||||
|
||||
func newHostsRepo(db execQuerier) *HostsRepo {
|
||||
return &HostsRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *HostsRepo) Create(ctx context.Context, host Host) (int64, error) {
|
||||
hostID := strings.TrimSpace(host.HostID)
|
||||
baseURL := strings.TrimSpace(host.BaseURL)
|
||||
hostVersion := strings.TrimSpace(host.HostVersion)
|
||||
capabilityProbeJSON := strings.TrimSpace(host.CapabilityProbeJSON)
|
||||
|
||||
switch {
|
||||
case hostID == "":
|
||||
return 0, fmt.Errorf("host_id is required")
|
||||
case baseURL == "":
|
||||
return 0, fmt.Errorf("base_url is required")
|
||||
case hostVersion == "":
|
||||
return 0, fmt.Errorf("host_version is required")
|
||||
case capabilityProbeJSON == "":
|
||||
capabilityProbeJSON = "{}"
|
||||
}
|
||||
|
||||
result, err := r.db.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO hosts (host_id, base_url, host_version, capability_probe_json)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
hostID,
|
||||
baseURL,
|
||||
hostVersion,
|
||||
capabilityProbeJSON,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert host %q: %w", hostID, err)
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read inserted host id for %q: %w", hostID, err)
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
55
internal/store/sqlite/packs_repo.go
Normal file
55
internal/store/sqlite/packs_repo.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Pack struct {
|
||||
PackID string
|
||||
Version string
|
||||
Checksum string
|
||||
}
|
||||
|
||||
type PacksRepo struct {
|
||||
db execQuerier
|
||||
}
|
||||
|
||||
func newPacksRepo(db execQuerier) *PacksRepo {
|
||||
return &PacksRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *PacksRepo) Create(ctx context.Context, pack Pack) (int64, error) {
|
||||
packID := strings.TrimSpace(pack.PackID)
|
||||
version := strings.TrimSpace(pack.Version)
|
||||
checksum := strings.TrimSpace(pack.Checksum)
|
||||
|
||||
switch {
|
||||
case packID == "":
|
||||
return 0, fmt.Errorf("pack_id is required")
|
||||
case version == "":
|
||||
return 0, fmt.Errorf("version is required")
|
||||
case checksum == "":
|
||||
return 0, fmt.Errorf("checksum is required")
|
||||
}
|
||||
|
||||
result, err := r.db.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO packs (pack_id, version, checksum)
|
||||
VALUES (?, ?, ?)`,
|
||||
packID,
|
||||
version,
|
||||
checksum,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert pack %q: %w", packID, err)
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read inserted pack id for %q: %w", packID, err)
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
64
internal/store/sqlite/providers_repo.go
Normal file
64
internal/store/sqlite/providers_repo.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
PackID int64
|
||||
ProviderID string
|
||||
DisplayName string
|
||||
BaseURL string
|
||||
Platform string
|
||||
}
|
||||
|
||||
type ProvidersRepo struct {
|
||||
db execQuerier
|
||||
}
|
||||
|
||||
func newProvidersRepo(db execQuerier) *ProvidersRepo {
|
||||
return &ProvidersRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *ProvidersRepo) Create(ctx context.Context, provider Provider) (int64, error) {
|
||||
providerID := strings.TrimSpace(provider.ProviderID)
|
||||
displayName := strings.TrimSpace(provider.DisplayName)
|
||||
baseURL := strings.TrimSpace(provider.BaseURL)
|
||||
platform := strings.TrimSpace(provider.Platform)
|
||||
|
||||
switch {
|
||||
case provider.PackID <= 0:
|
||||
return 0, fmt.Errorf("pack_id is required")
|
||||
case providerID == "":
|
||||
return 0, fmt.Errorf("provider_id is required")
|
||||
case displayName == "":
|
||||
return 0, fmt.Errorf("display_name is required")
|
||||
case baseURL == "":
|
||||
return 0, fmt.Errorf("base_url is required")
|
||||
case platform == "":
|
||||
return 0, fmt.Errorf("platform is required")
|
||||
}
|
||||
|
||||
result, err := r.db.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO providers (pack_id, provider_id, display_name, base_url, platform)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
provider.PackID,
|
||||
providerID,
|
||||
displayName,
|
||||
baseURL,
|
||||
platform,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert provider %q: %w", providerID, err)
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read inserted provider id for %q: %w", providerID, err)
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
291
tests/integration/store_init_test.go
Normal file
291
tests/integration/store_init_test.go
Normal file
@@ -0,0 +1,291 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
"sub2api-cn-relay-manager/internal/store/sqlite"
|
||||
)
|
||||
|
||||
func TestStoreInitCreatesRequiredTables(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
defer closeTestStore(t, store)
|
||||
|
||||
for _, table := range []string{"hosts", "packs", "providers"} {
|
||||
if !tableExists(t, store.SQLDB(), table) {
|
||||
t.Fatalf("table %q does not exist after store initialization", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreInitEnforcesUniqueConstraints(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := openTestStore(t)
|
||||
defer closeTestStore(t, store)
|
||||
|
||||
packID, err := store.Packs().Create(ctx, sqlite.Pack{
|
||||
PackID: "openai-cn-pack",
|
||||
Version: "1.0.0",
|
||||
Checksum: "checksum-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Packs().Create() error = %v", err)
|
||||
}
|
||||
|
||||
provider := sqlite.Provider{
|
||||
PackID: packID,
|
||||
ProviderID: "deepseek",
|
||||
DisplayName: "DeepSeek",
|
||||
BaseURL: "https://api.deepseek.com",
|
||||
Platform: "openai",
|
||||
}
|
||||
|
||||
if _, err := store.Providers().Create(ctx, provider); err != nil {
|
||||
t.Fatalf("Providers().Create() first call error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.Providers().Create(ctx, provider); err == nil {
|
||||
t.Fatal("Providers().Create() second call error = nil, want unique constraint failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreInitEnforcesProviderForeignKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := openTestStore(t)
|
||||
defer closeTestStore(t, store)
|
||||
|
||||
_, err := store.Providers().Create(ctx, sqlite.Provider{
|
||||
PackID: 9999,
|
||||
ProviderID: "ghost",
|
||||
DisplayName: "Ghost",
|
||||
BaseURL: "https://ghost.example.com",
|
||||
Platform: "openai",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Providers().Create() error = nil, want foreign key failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreInitRollsBackTransaction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := openTestStore(t)
|
||||
defer closeTestStore(t, store)
|
||||
|
||||
wantErr := errors.New("force rollback")
|
||||
|
||||
err := store.WithTx(ctx, func(queries *sqlite.Queries) error {
|
||||
_, err := queries.Hosts.Create(ctx, sqlite.Host{
|
||||
HostID: "host-1",
|
||||
BaseURL: "https://host.example.com",
|
||||
HostVersion: "0.1.126",
|
||||
CapabilityProbeJSON: `{"supports_batch_accounts":true}`,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return wantErr
|
||||
})
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("WithTx() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
|
||||
if got := countRows(t, store.SQLDB(), "hosts"); got != 0 {
|
||||
t.Fatalf("hosts row count after rollback = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreInitRecordsMigrationLedgerOnce(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "state.db")
|
||||
dsn := fmt.Sprintf("file:%s?_busy_timeout=5000", filepath.ToSlash(dbPath))
|
||||
|
||||
store1, err := sqlite.Open(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("first sqlite.Open() error = %v", err)
|
||||
}
|
||||
if got := countRows(t, store1.SQLDB(), "schema_migrations"); got != 1 {
|
||||
t.Fatalf("schema_migrations row count after first open = %d, want 1", got)
|
||||
}
|
||||
if err := store1.Close(); err != nil {
|
||||
t.Fatalf("first store.Close() error = %v", err)
|
||||
}
|
||||
|
||||
store2, err := sqlite.Open(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("second sqlite.Open() error = %v", err)
|
||||
}
|
||||
defer closeTestStore(t, store2)
|
||||
|
||||
if got := countRows(t, store2.SQLDB(), "schema_migrations"); got != 1 {
|
||||
t.Fatalf("schema_migrations row count after second open = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreInitBackfillsLedgerForCompletePreLedgerSchema(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "state.db")
|
||||
dsn := fmt.Sprintf("file:%s?_busy_timeout=5000", filepath.ToSlash(dbPath))
|
||||
|
||||
rawDB := openRawSQLiteDB(t, dsn)
|
||||
createLegacy0001Schema(t, rawDB)
|
||||
closeRawSQLiteDB(t, rawDB)
|
||||
|
||||
store, err := sqlite.Open(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("sqlite.Open() on complete pre-ledger schema error = %v", err)
|
||||
}
|
||||
defer closeTestStore(t, store)
|
||||
|
||||
if got := countRows(t, store.SQLDB(), "schema_migrations"); got != 1 {
|
||||
t.Fatalf("schema_migrations row count after backfill = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreInitFailsWhenPreLedgerSchemaIsPartial(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "state.db")
|
||||
dsn := fmt.Sprintf("file:%s?_busy_timeout=5000", filepath.ToSlash(dbPath))
|
||||
|
||||
rawDB := openRawSQLiteDB(t, dsn)
|
||||
mustExec(t, rawDB, `
|
||||
CREATE TABLE hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id TEXT NOT NULL UNIQUE,
|
||||
base_url TEXT NOT NULL,
|
||||
host_version TEXT NOT NULL,
|
||||
capability_probe_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
closeRawSQLiteDB(t, rawDB)
|
||||
|
||||
store, err := sqlite.Open(context.Background(), dsn)
|
||||
if err == nil {
|
||||
closeTestStore(t, store)
|
||||
t.Fatal("sqlite.Open() error = nil, want partial pre-ledger schema failure")
|
||||
}
|
||||
}
|
||||
|
||||
func openTestStore(t *testing.T) *sqlite.DB {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "state.db")
|
||||
dsn := fmt.Sprintf("file:%s?_busy_timeout=5000&_pragma=foreign_keys(0)", filepath.ToSlash(dbPath))
|
||||
|
||||
store, err := sqlite.Open(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("sqlite.Open() error = %v", err)
|
||||
}
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func openRawSQLiteDB(t *testing.T, dsn string) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open() error = %v", err)
|
||||
}
|
||||
|
||||
if err := db.PingContext(context.Background()); err != nil {
|
||||
t.Fatalf("raw db PingContext() error = %v", err)
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func closeRawSQLiteDB(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatalf("raw db Close() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createLegacy0001Schema(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
|
||||
mustExec(t, db, `
|
||||
CREATE TABLE hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id TEXT NOT NULL UNIQUE,
|
||||
base_url TEXT NOT NULL,
|
||||
host_version TEXT NOT NULL,
|
||||
capability_probe_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
mustExec(t, db, `
|
||||
CREATE TABLE packs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pack_id TEXT NOT NULL UNIQUE,
|
||||
version TEXT NOT NULL,
|
||||
checksum TEXT NOT NULL,
|
||||
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
mustExec(t, db, `
|
||||
CREATE TABLE providers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pack_id INTEGER NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_providers_pack
|
||||
FOREIGN KEY (pack_id)
|
||||
REFERENCES packs(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT uq_providers_pack_provider
|
||||
UNIQUE (pack_id, provider_id)
|
||||
)`)
|
||||
}
|
||||
|
||||
func mustExec(t *testing.T, db *sql.DB, statement string) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := db.ExecContext(context.Background(), statement); err != nil {
|
||||
t.Fatalf("ExecContext() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func closeTestStore(t *testing.T, store *sqlite.DB) {
|
||||
t.Helper()
|
||||
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatalf("store.Close() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func tableExists(t *testing.T, db *sql.DB, table string) bool {
|
||||
t.Helper()
|
||||
|
||||
var name string
|
||||
err := db.QueryRowContext(
|
||||
context.Background(),
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
table,
|
||||
).Scan(&name)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("tableExists(%q) query error = %v", table, err)
|
||||
}
|
||||
|
||||
return name == table
|
||||
}
|
||||
|
||||
func countRows(t *testing.T, db *sql.DB, table string) int {
|
||||
t.Helper()
|
||||
|
||||
var count int
|
||||
query := fmt.Sprintf("SELECT COUNT(*) FROM %s", table)
|
||||
if err := db.QueryRowContext(context.Background(), query).Scan(&count); err != nil {
|
||||
t.Fatalf("countRows(%q) query error = %v", table, err)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
Reference in New Issue
Block a user