fix(migration): backfill users.group_users before schema copy (#2934)

Upgrading a v3 database panicked in Schema.Create: v3 bound user groups
through a nullable group_id column (or left groupless users NULL), while
v4 requires NOT NULL group_users, so the copy to the rebuilt table died
on the constraint. A pre-migration repair now adds the column if absent
and backfills it - preserving legacy group_id bindings when present and
falling back to the first non-admin group - so the copy succeeds. The
statements are dialect-portable and no-op on fresh installs where the
users table does not yet exist.

Authored By: TDvorak <info@tdvorak.dev>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3582/head
Tomas Dvorak 2 weeks ago
parent 63066db78a
commit 8d226d2d07

@ -41,6 +41,7 @@ func needMigration(client *ent.Client, ctx context.Context, requiredDbVersion st
func migrate(l logging.Logger, client *ent.Client, ctx context.Context, kv cache.Driver, requiredDbVersion string) error { func migrate(l logging.Logger, client *ent.Client, ctx context.Context, kv cache.Driver, requiredDbVersion string) error {
l.Info("Start initializing database schema...") l.Info("Start initializing database schema...")
l.Info("Creating basic table schema...") l.Info("Creating basic table schema...")
repairLegacyUserGroupColumn(l, client, ctx)
if err := client.Schema.Create(ctx); err != nil { if err := client.Schema.Create(ctx); err != nil {
return fmt.Errorf("Failed creating schema resources: %w", err) return fmt.Errorf("Failed creating schema resources: %w", err)
} }
@ -67,6 +68,32 @@ func migrate(l logging.Logger, client *ent.Client, ctx context.Context, kv cache
return nil return nil
} }
// repairLegacyUserGroupColumn backfills users.group_users before ent's
// auto-migration. v3 bound user groups via a nullable group_id column (or no
// column at all for groupless users); the v4 schema requires NOT NULL
// group_users, so the schema copy fails mid-upgrade on old databases
// (upstream #2934). All statements are error-tolerant: on a fresh install
// the users/groups tables simply do not exist yet and every statement no-ops.
func repairLegacyUserGroupColumn(l logging.Logger, client *ent.Client, ctx context.Context) {
if _, err := client.ExecContext(ctx, `ALTER TABLE users ADD COLUMN group_users INTEGER`); err != nil {
l.Debug("Skip adding group_users column: %s", err)
}
// Prefer the legacy group_id binding when present; otherwise fall back to
// the first non-admin group (id 1 is the seeded admin group), then any group.
queries := []string{
`UPDATE users SET group_users = COALESCE(group_id, (SELECT MIN(id) FROM groups WHERE id <> 1), (SELECT MIN(id) FROM groups)) WHERE group_users IS NULL`,
`UPDATE users SET group_users = COALESCE((SELECT MIN(id) FROM groups WHERE id <> 1), (SELECT MIN(id) FROM groups)) WHERE group_users IS NULL`,
}
for _, q := range queries {
_, err := client.ExecContext(ctx, q)
if err == nil {
return
}
l.Debug("Skip legacy group_users backfill: %s", err)
}
}
func migrateDefaultSettings(l logging.Logger, client *ent.Client, ctx context.Context, kv cache.Driver) { func migrateDefaultSettings(l logging.Logger, client *ent.Client, ctx context.Context, kv cache.Driver) {
// clean kv cache // clean kv cache
if err := kv.DeleteAll(); err != nil { if err := kv.DeleteAll(); err != nil {

@ -0,0 +1,97 @@
package inventory
import (
"context"
rawsql "database/sql"
"path/filepath"
"testing"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/stretchr/testify/require"
)
// legacyV3DB builds a minimal v3-shaped sqlite database: users bind their
// group via a nullable group_id column, and groupless users carry NULL.
func legacyV3DB(t *testing.T, dbPath string) {
t.Helper()
raw, err := rawsql.Open("sqlite3", "file:"+dbPath)
require.NoError(t, err)
defer raw.Close()
stmts := []string{
`CREATE TABLE groups (id INTEGER PRIMARY KEY, created_at DATETIME, updated_at DATETIME, name TEXT, permissions BLOB)`,
`INSERT INTO groups (id, created_at, updated_at, name, permissions) VALUES
(1, '2024-01-01 00:00:00', '2024-01-01 00:00:00', 'Admin', X'00'),
(2, '2024-01-01 00:00:00', '2024-01-01 00:00:00', 'User', X'00')`,
`CREATE TABLE users (
id INTEGER PRIMARY KEY,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
email VARCHAR(100),
nick VARCHAR(100),
password VARCHAR(255),
status VARCHAR(20),
storage BIGINT,
group_id INTEGER
)`,
`INSERT INTO users (created_at, updated_at, email, nick, status, storage, group_id) VALUES
('2024-01-01 00:00:00', '2024-01-01 00:00:00', 'admin@x', 'admin', 'active', 0, 1),
('2024-01-01 00:00:00', '2024-01-01 00:00:00', 'user@x', 'user', 'active', 0, 2),
('2024-01-01 00:00:00', '2024-01-01 00:00:00', 'groupless@x', 'groupless', 'active', 0, NULL)`,
}
for _, q := range stmts {
_, err := raw.Exec(q)
require.NoError(t, err, q)
}
}
// TestRepairLegacyUserGroupColumn reproduces upstream #2934: upgrading a v3
// database fails inside ent's copy-migration because group_users is NOT NULL
// in v4 while v3 rows can be groupless. The repair backfills the column so
// Schema.Create succeeds.
func TestRepairLegacyUserGroupColumn(t *testing.T) {
ctx := context.Background()
l := logging.NewConsoleLogger(logging.LevelError)
openClient := func(t *testing.T) *ent.Client {
dbPath := filepath.Join(t.TempDir(), "legacy.db")
legacyV3DB(t, dbPath)
client, err := ent.Open("sqlite3", "file:"+dbPath)
require.NoError(t, err)
return client
}
t.Run("schema create fails without repair", func(t *testing.T) {
client := openClient(t)
defer client.Close()
err := client.Schema.Create(ctx)
require.Error(t, err)
require.Contains(t, err.Error(), "group_users")
})
t.Run("repair backfills and migration succeeds", func(t *testing.T) {
client := openClient(t)
defer client.Close()
repairLegacyUserGroupColumn(l, client, ctx)
require.NoError(t, client.Schema.Create(ctx))
rows, err := client.QueryContext(ctx, `SELECT id, group_users FROM users ORDER BY id`)
require.NoError(t, err)
defer rows.Close()
got := map[int]int{}
for rows.Next() {
var id, gid int
require.NoError(t, rows.Scan(&id, &gid))
got[id] = gid
}
require.NoError(t, rows.Err())
// group_id bindings preserved; groupless user lands on the
// non-admin group (id 2), not the admin group.
require.Equal(t, map[int]int{1: 1, 2: 2, 3: 2}, got)
})
}
Loading…
Cancel
Save