Skip to content

Commit a9c6cc0

Browse files
committed
feat(ci): 迁移矩阵补 SQL Server 方言——sp_getapplock 会话锁与 mssql 服务容器
- migrate.go: dialectOf 映射 sqlserver/mssql;versionTableExists 用 sys.objects 探测;acquireSessionLock 增加 sp_getapplock 会话锁 (Exclusive/Session/LockTimeout=0,返回码>=0 视为获锁),release 对应 sp_releaseapplock——并发首启 AutoMigrate 串行化与 MySQL/PG 同语义 - 集成测试: SQLServer case(TEST_SQLSERVER_DSN),建库/删库(SINGLE_USER 回滚连接)/DSN database 参数/sys.objects 探测表 - ci.yml: migrate-matrix 挂 mssql 2022 容器(health 用 mssql-tools18 sqlcmd -C -No),job 更名 MySQL/Postgres/SQLServer - configexplorer: 补 restoreDSNPassword/isTextFormat/parseID 单测
1 parent a123df1 commit a9c6cc0

4 files changed

Lines changed: 117 additions & 13 deletions

File tree

.github/workflows/ci.yml

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -290,13 +290,13 @@ jobs:
290290
fi
291291
292292
migrate-matrix:
293-
name: Migration Matrix (MySQL/Postgres)
293+
name: Migration Matrix (MySQL/Postgres/SQLServer)
294294
# Phase 4 of docs/architecture/database-migration-strategy.md: the
295-
# versioned migration executor must work on all three dialects. SQLite is
295+
# versioned migration executor must work on all four dialects. SQLite is
296296
# covered by the unit tests in internal/svc/migrations_test.go; this job
297-
# runs the integration-tagged matrix against real MySQL and Postgres
298-
# servers (fresh DB → baseline + catch-up, concurrent first boot proving
299-
# the session lock, idempotent second boot).
297+
# runs the integration-tagged matrix against real MySQL, Postgres and
298+
# SQL Server servers (fresh DB → baseline + catch-up, concurrent first
299+
# boot proving the session lock, idempotent second boot).
300300
runs-on: ubuntu-latest
301301
timeout-minutes: 20
302302
needs: [test]
@@ -324,9 +324,22 @@ jobs:
324324
--health-interval 5s
325325
--health-timeout 5s
326326
--health-retries 20
327+
mssql:
328+
image: mcr.microsoft.com/mssql/server:2022-latest
329+
env:
330+
ACCEPT_EULA: "Y"
331+
MSSQL_SA_PASSWORD: "YourStr0ngPassw0rd"
332+
ports:
333+
- 1433:1433
334+
options: >-
335+
--health-cmd "/opt/mssql-tools18/bin/sqlcmd -C -No -S 127.0.0.1 -U sa -P 'YourStr0ngPassw0rd' -Q 'SELECT 1'"
336+
--health-interval 10s
337+
--health-timeout 5s
338+
--health-retries 30
327339
env:
328340
TEST_MYSQL_DSN: "root:root@tcp(127.0.0.1:3306)/"
329341
TEST_POSTGRES_DSN: "postgres://postgres:postgres@127.0.0.1:5432/postgres?sslmode=disable"
342+
TEST_SQLSERVER_DSN: "sqlserver://sa:YourStr0ngPassw0rd@127.0.0.1:1433"
330343
steps:
331344
- timeout-minutes: 3
332345
uses: actions/checkout@v7

internal/api/configexplorer/service_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,48 @@ func TestFormatOf(t *testing.T) {
5252
}
5353
}
5454
}
55+
56+
func TestRestoreDSNPassword(t *testing.T) {
57+
cases := []struct {
58+
name, oldDSN, newDSN, want string
59+
}{
60+
{"keeps old host tail", "user:secret@tcp(db:3306)/x", "user:******@tcp(newdb:3307)/y", "user:secret@tcp(newdb:3307)/y"},
61+
{"no at in old", "plain-dsn", "user:******@h/d", "plain-dsn"},
62+
{"no at in new", "user:secret@h/d", "plain", "user:secret@h/d"},
63+
{"old cred without colon", "user@h/d", "user:******@h2/d", "user@h/d"},
64+
{"pg url", "postgres://u:p@h:5432/db", "postgres://u:******@h2:5432/db2", "postgres://u:p@h2:5432/db2"},
65+
}
66+
for _, tc := range cases {
67+
t.Run(tc.name, func(t *testing.T) {
68+
if got := restoreDSNPassword(tc.oldDSN, tc.newDSN); got != tc.want {
69+
t.Fatalf("got %q, want %q", got, tc.want)
70+
}
71+
})
72+
}
73+
}
74+
75+
func TestIsTextFormat(t *testing.T) {
76+
text := []string{"json", "yaml", "csv", "ini", "xml", "lua", "python", "txt", "md", "toml", "properties", "plaintext"}
77+
for _, f := range text {
78+
if !isTextFormat(f) {
79+
t.Errorf("isTextFormat(%q) = false, want true", f)
80+
}
81+
}
82+
for _, f := range []string{"png", "xlsx", "bin", ""} {
83+
if isTextFormat(f) {
84+
t.Errorf("isTextFormat(%q) = true, want false", f)
85+
}
86+
}
87+
}
88+
89+
func TestParseID(t *testing.T) {
90+
if id, err := parseID("42"); err != nil || id != 42 {
91+
t.Fatalf("parseID(42) = (%d, %v)", id, err)
92+
}
93+
if _, err := parseID("abc"); err == nil {
94+
t.Fatal("parseID(abc) should fail")
95+
}
96+
if _, err := parseID("-1"); err == nil {
97+
t.Fatal("parseID(-1) should fail")
98+
}
99+
}

internal/db/migrate/migrate.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ func dialectOf(gormDialect string) string {
5959
return "mysql"
6060
case "postgres", "postgresql":
6161
return "postgres"
62+
case "sqlserver", "mssql":
63+
return "sqlserver"
6264
case "sqlite", "sqlite3":
6365
return "sqlite3"
6466
default:
@@ -72,6 +74,9 @@ const sessionLockDeadline = 60 * time.Second
7274

7375
const mysqlMigrationLockName = "croupier_schema_migration"
7476

77+
// Application-lock resource name for SQL Server (sp_getapplock).
78+
const sqlServerMigrationLockName = "croupier_schema_migration"
79+
7580
// Advisory lock keys for Postgres (two int32 components). Chosen so they spell
7681
// "crou"+"pier" in ASCII; any stable constant pair works as long as every
7782
// process uses the same one.
@@ -85,7 +90,7 @@ const (
8590
// done. SQLite returns a no-op release because it is single-writer.
8691
func acquireSessionLock(ctx context.Context, sqlDB *sql.DB, gooseDialect string) (func(), error) {
8792
switch gooseDialect {
88-
case "mysql", "postgres":
93+
case "mysql", "postgres", "sqlserver":
8994
default:
9095
return func() {}, nil
9196
}
@@ -110,6 +115,17 @@ func acquireSessionLock(ctx context.Context, sqlDB *sql.DB, gooseDialect string)
110115
return false, err
111116
}
112117
return ok, nil
118+
case "sqlserver":
119+
// sp_getapplock: session-scoped exclusive application lock on
120+
// the bound connection. Return codes: 0 = granted, 1 = granted
121+
// after wait; <0 = timeout/cancel/error. LockTimeout 0 →
122+
// immediate, -1 when held elsewhere.
123+
var code sql.NullInt32
124+
query := "DECLARE @r int; EXEC @r = sp_getapplock @Resource = ?, @LockMode = 'Exclusive', @LockOwner = 'Session', @LockTimeout = 0; SELECT @r"
125+
if err := conn.QueryRowContext(ctx, query, sqlServerMigrationLockName).Scan(&code); err != nil {
126+
return false, err
127+
}
128+
return code.Valid && code.Int32 >= 0, nil
113129
}
114130
return false, nil
115131
}
@@ -143,6 +159,8 @@ func acquireSessionLock(ctx context.Context, sqlDB *sql.DB, gooseDialect string)
143159
case "postgres":
144160
query := fmt.Sprintf("SELECT pg_advisory_unlock(%d, %d)", pgAdvisoryLockKey1, pgAdvisoryLockKey2)
145161
_, _ = conn.ExecContext(context.Background(), query)
162+
case "sqlserver":
163+
_, _ = conn.ExecContext(context.Background(), "EXEC sp_releaseapplock @Resource = ?, @LockOwner = 'Session'", sqlServerMigrationLockName)
146164
}
147165
conn.Close()
148166
}
@@ -253,6 +271,8 @@ func versionTableExists(ctx context.Context, sqlDB *sql.DB, gooseDialect string)
253271
query = "SELECT COUNT(1) FROM sqlite_master WHERE type = 'table' AND name = '" + VersionTableName + "'"
254272
case "postgres":
255273
query = "SELECT COUNT(1) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = '" + VersionTableName + "'"
274+
case "sqlserver":
275+
query = "SELECT COUNT(1) FROM sys.objects WHERE type = 'U' AND name = '" + VersionTableName + "'"
256276
default:
257277
query = "SELECT COUNT(1) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = '" + VersionTableName + "'"
258278
}

internal/svc/migrations_integration_test.go

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
//
1010
// TEST_MYSQL_DSN="root:root@tcp(127.0.0.1:3306)/"
1111
// TEST_POSTGRES_DSN="postgres://postgres:postgres@127.0.0.1:5432/postgres?sslmode=disable"
12+
// TEST_SQLSERVER_DSN="sqlserver://sa:YourStr0ngPass@127.0.0.1:1433"
1213
//
1314
// Each run creates (and finally drops) a uniquely named throwaway database.
1415
package svc
@@ -26,6 +27,7 @@ import (
2627
"github.com/cuihairu/croupier/internal/db/migrate"
2728
gmysql "gorm.io/driver/mysql"
2829
gpostgres "gorm.io/driver/postgres"
30+
gsqlserver "gorm.io/driver/sqlserver"
2931
"gorm.io/gorm"
3032
"gorm.io/gorm/logger"
3133
)
@@ -48,6 +50,13 @@ func TestMigrationMatrix(t *testing.T) {
4850
}
4951
runMigrationMatrixCase(t, "postgres", dsn)
5052
})
53+
t.Run("SQLServer", func(t *testing.T) {
54+
dsn := envOr("TEST_SQLSERVER_DSN", "")
55+
if dsn == "" {
56+
t.Skip("TEST_SQLSERVER_DSN not set")
57+
}
58+
runMigrationMatrixCase(t, "sqlserver", dsn)
59+
})
5160
}
5261

5362
func runMigrationMatrixCase(t *testing.T, dialect, adminDSN string) {
@@ -64,23 +73,29 @@ func runMigrationMatrixCase(t *testing.T, dialect, adminDSN string) {
6473
open = func(dsn string) (*gorm.DB, error) {
6574
return gorm.Open(gpostgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
6675
}
76+
case "sqlserver":
77+
open = func(dsn string) (*gorm.DB, error) {
78+
return gorm.Open(gsqlserver.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
79+
}
6780
}
6881

6982
admin, err := open(adminDSN)
7083
if err != nil {
7184
t.Fatalf("open admin connection: %v", err)
7285
}
7386
create := map[string]string{
74-
"mysql": fmt.Sprintf("CREATE DATABASE %s", dbName),
75-
"postgres": fmt.Sprintf("CREATE DATABASE %s", dbName),
87+
"mysql": fmt.Sprintf("CREATE DATABASE %s", dbName),
88+
"postgres": fmt.Sprintf("CREATE DATABASE %s", dbName),
89+
"sqlserver": fmt.Sprintf("CREATE DATABASE [%s]", dbName),
7690
}[dialect]
7791
if err := admin.Exec(create).Error; err != nil {
7892
t.Fatalf("create throwaway database: %v", err)
7993
}
8094
t.Cleanup(func() {
8195
drop := map[string]string{
82-
"mysql": fmt.Sprintf("DROP DATABASE IF EXISTS %s", dbName),
83-
"postgres": fmt.Sprintf("DROP DATABASE IF EXISTS %s", dbName),
96+
"mysql": fmt.Sprintf("DROP DATABASE IF EXISTS %s", dbName),
97+
"postgres": fmt.Sprintf("DROP DATABASE IF EXISTS %s", dbName),
98+
"sqlserver": fmt.Sprintf("IF DB_ID('%s') IS NOT NULL ALTER DATABASE [%s] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE IF EXISTS [%s]", dbName, dbName, dbName),
8499
}[dialect]
85100
if err := admin.Exec(drop).Error; err != nil {
86101
t.Logf("cleanup drop database: %v", err)
@@ -102,6 +117,16 @@ func runMigrationMatrixCase(t *testing.T, dialect, adminDSN string) {
102117
u.Path = "/" + name
103118
return u.String()
104119
},
120+
"sqlserver": func(dsn, name string) string {
121+
u, err := url.Parse(dsn)
122+
if err != nil {
123+
t.Fatalf("parse sqlserver DSN: %v", err)
124+
}
125+
q := u.Query()
126+
q.Set("database", name)
127+
u.RawQuery = q.Encode()
128+
return u.String()
129+
},
105130
}[dialect](adminDSN, dbName)
106131

107132
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
@@ -146,7 +171,7 @@ func runMigrationMatrixCase(t *testing.T, dialect, adminDSN string) {
146171
if err != nil {
147172
t.Fatalf("sql.DB: %v", err)
148173
}
149-
gormDialect := map[string]string{"mysql": "mysql", "postgres": "postgres"}[dialect]
174+
gormDialect := map[string]string{"mysql": "mysql", "postgres": "postgres", "sqlserver": "sqlserver"}[dialect]
150175
version, ok, err := migrate.CurrentVersion(ctx, sqlDB, gormDialect)
151176
if err != nil {
152177
t.Fatalf("CurrentVersion: %v", err)
@@ -158,8 +183,9 @@ func runMigrationMatrixCase(t *testing.T, dialect, adminDSN string) {
158183
// Spot-check a baseline table exists (admins is a meta model).
159184
var count int
160185
adminTable := map[string]string{
161-
"mysql": "SELECT COUNT(1) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'admins'",
162-
"postgres": "SELECT COUNT(1) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'admins'",
186+
"mysql": "SELECT COUNT(1) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'admins'",
187+
"postgres": "SELECT COUNT(1) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'admins'",
188+
"sqlserver": "SELECT COUNT(1) FROM sys.objects WHERE type = 'U' AND name = 'admins'",
163189
}[dialect]
164190
if err := db.Raw(adminTable).Scan(&count).Error; err != nil {
165191
t.Fatalf("probe admins table: %v", err)

0 commit comments

Comments
 (0)