Files
go-admin/cmd/migrate/uninstall.go
T
zhangwenjian 006756ea40 test: cover the uninstall's chunk boundary and its empty id lists
Copilot could not review this branch - the account is over its review quota -
so these are what a second pass over the uninstaller turned up. No defect: the
three cases were uncovered rather than wrong.

findOrphanPolicies batches its OR chain because a driver runs out of
placeholders long before an application runs out of endpoints, and nothing
exercised the boundary. 205 paths across three batches, the last one short,
plus one policy no key names as a control. Taking one fewer per batch,
advancing one too far, and stopping after the first batch each turn it red.

An application with apis and no menus, and one with menus and no apis, are
both normal - endpoints another service calls, or a section with no endpoints
of its own - and each leaves one of the two id lists the uninstall reads
empty.

That last pair also corrected a comment. The guard in front of the join-table
delete was described as being there because an empty IN list is a syntax
error. It is in raw SQL, but GORM renders IN with an empty slice as a
condition that matches nothing, and removing the guard leaves the new test
green. It stays as a statement of intent, and now says so.
2026-09-09 12:56:33 +08:00

297 lines
11 KiB
Go

package migrate
import (
"errors"
"fmt"
"io"
"strings"
"gorm.io/gorm"
adminmodels "go-admin/app/admin/models"
"go-admin/cmd/migrate/migration"
commonmodels "go-admin/common/models"
)
// policyKey is one casbin_rule row identified the way casbin_rule is unique:
// by its tuple, not by its id. Ids do not survive SysRole.Update, which
// removes a role's policies and adds them back.
type policyKey struct {
Ptype string `gorm:"column:ptype"`
V0 string `gorm:"column:v0"`
V1 string `gorm:"column:v1"`
V2 string `gorm:"column:v2"`
V3 string `gorm:"column:v3"`
V4 string `gorm:"column:v4"`
V5 string `gorm:"column:v5"`
}
func (p policyKey) String() string {
return fmt.Sprintf("%s %s %s %s", p.Ptype, p.V0, p.V1, p.V2)
}
// uninstallReport is what an uninstall removed, and what it deliberately did
// not.
type uninstallReport struct {
Code string
// Found says whether sys_app had a row. An application whose migrations
// were applied by plain `migrate` rather than by `install` has its menus
// and its permissions without ever having had one.
Found bool
Version string
Menus int64
Apis int64
Bindings int64
RoleMenus int64
Policies int64
Migrations int64
// Skipped are ledger entries whose casbin_rule row was not there any
// more: something this install created and something else removed.
Skipped []policyKey
// Orphans are policies naming this application's paths that no ledger
// entry claims - somebody granted this app's API to another role by
// hand. Reported, never deleted.
Orphans []policyKey
}
// uninstall removes one application's menus, APIs and permission grants.
//
// It does not touch the application's own tables. Removing an order module
// is not the same decision as destroying the orders, and nothing here can
// tell the operator apart from someone who will reinstall tomorrow.
//
// One transaction, and this one really is one: every statement below is DML
// or a SELECT, so unlike an install there is no DDL to commit it out from
// under itself. Child rows go first, while the ids that identify them can
// still be read from the parents.
//
// A sys_app row is not required. `migrate` with no subcommand applies every
// registered migration, an application's included, so an application can
// have all of its data without ever having gone through the installer.
func uninstall(db *gorm.DB, code string) (uninstallReport, error) {
code = migration.NormalizeAppCode(code)
rep := uninstallReport{Code: code}
if code == "" {
return rep, errors.New("no app code given")
}
if code == migration.FrameworkAppCode {
return rep, fmt.Errorf("%q is the framework's own migrations; there is no uninstall for those", code)
}
err := db.Transaction(func(tx *gorm.DB) error {
row, found, err := loadApp(tx, code)
if err != nil {
return err
}
rep.Found = found
if found {
rep.Version = row.Version
}
// 1 and 2. Read before deleting: sys_api's rows are about to go, and
// step 5b needs their paths.
//
// Unscoped throughout. A row this application wrote that somebody
// soft-deleted from the UI is still this application's row, and
// leaving it behind would leave its join rows pointing at it.
var menuIDs []int
if err := tx.Unscoped().Model(&adminmodels.SysMenu{}).
Where("app_code = ?", code).Pluck("menu_id", &menuIDs).Error; err != nil {
return fmt.Errorf("reading this app's menus: %w", err)
}
var apiIDs []int
if err := tx.Unscoped().Model(&adminmodels.SysApi{}).
Where("app_code = ?", code).Pluck("id", &apiIDs).Error; err != nil {
return fmt.Errorf("reading this app's apis: %w", err)
}
var apiKeys []policyKey
if err := tx.Unscoped().Model(&adminmodels.SysApi{}).
Where("app_code = ?", code).
Select("path as v1, action as v2").Scan(&apiKeys).Error; err != nil {
return fmt.Errorf("reading this app's api paths: %w", err)
}
// 3. The many2many rows behind SysMenu.SysApi. Either side is enough
// to make a row this application's.
//
// The guard is intent, not necessity: GORM renders IN with an empty
// slice as a condition matching nothing rather than the empty IN
// list raw SQL would reject, so removing it changes no behaviour
// today. It says out loud that an application with no menus, or no
// apis, is a normal thing to uninstall.
if len(menuIDs) > 0 || len(apiIDs) > 0 {
q := tx.Table("sys_menu_api_rule")
switch {
case len(menuIDs) > 0 && len(apiIDs) > 0:
q = q.Where("sys_menu_menu_id IN ? OR sys_api_id IN ?", menuIDs, apiIDs)
case len(menuIDs) > 0:
q = q.Where("sys_menu_menu_id IN ?", menuIDs)
default:
q = q.Where("sys_api_id IN ?", apiIDs)
}
res := q.Delete(nil)
if res.Error != nil {
return fmt.Errorf("removing menu/api bindings: %w", res.Error)
}
rep.Bindings = res.RowsAffected
}
// 4. Role assignments. menu_id is a surrogate key, so a row here can
// only have come from a menu this application wrote - there is no
// "looks like it but is not". That is why this needs no ledger, and
// why a column on sys_role_menu would have been wrong: SysRole.Update
// deletes a role's rows and writes them back through GORM's
// many2many, which does not carry extra columns, so any such column
// would be silently blanked the first time somebody edits a role.
if len(menuIDs) > 0 {
res := tx.Table("sys_role_menu").Where("menu_id IN ?", menuIDs).Delete(nil)
if res.Error != nil {
return fmt.Errorf("removing role assignments: %w", res.Error)
}
rep.RoleMenus = res.RowsAffected
}
// 5. Policies, by ledger, one at a time and by exact tuple.
var grants []adminmodels.SysAppCasbinGrant
if err := tx.Where("app_code = ?", code).Find(&grants).Error; err != nil {
return fmt.Errorf("reading the grant ledger: %w", err)
}
for _, g := range grants {
k := policyKey{Ptype: g.Ptype, V0: g.V0, V1: g.V1, V2: g.V2, V3: g.V3, V4: g.V4, V5: g.V5}
res := tx.Table("casbin_rule").
Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ? AND v3 = ? AND v4 = ? AND v5 = ?",
k.Ptype, k.V0, k.V1, k.V2, k.V3, k.V4, k.V5).
Delete(nil)
if res.Error != nil {
return fmt.Errorf("removing policy %s: %w", k, res.Error)
}
if res.RowsAffected == 0 {
// Something this install created is not there any more. Not
// an error: the uninstall's job was to remove it and it is
// gone. Reported because a policy this app created and did
// not remove means something else rewrote casbin_rule.
rep.Skipped = append(rep.Skipped, k)
continue
}
rep.Policies += res.RowsAffected
}
// The ledger's job ends here whether or not each row matched. Left
// behind it would only grow, and a reinstall writes its own entries.
if err := tx.Where("app_code = ?", code).
Delete(&adminmodels.SysAppCasbinGrant{}).Error; err != nil {
return fmt.Errorf("clearing the grant ledger: %w", err)
}
// 5b. Read-only. By now every policy the ledger could speak for has
// been dealt with, so a policy still matching one of this app's paths
// is one the ledger never claimed - somebody granted this app's API
// to another role by hand. Business rule 3 says do not delete what
// is not ours; without this step nobody would ever learn it is
// there, pointing at an API that is about to stop existing.
orphans, err := findOrphanPolicies(tx, apiKeys)
if err != nil {
return err
}
rep.Orphans = orphans
// 6 and 7.
res := tx.Unscoped().Where("app_code = ?", code).Delete(&adminmodels.SysApi{})
if res.Error != nil {
return fmt.Errorf("removing this app's apis: %w", res.Error)
}
rep.Apis = res.RowsAffected
res = tx.Unscoped().Where("app_code = ?", code).Delete(&adminmodels.SysMenu{})
if res.Error != nil {
return fmt.Errorf("removing this app's menus: %w", res.Error)
}
rep.Menus = res.RowsAffected
// 8. Without this a reinstall finds every version already applied,
// runs no migration, seeds nothing, and reports success. It is the
// easiest step to leave out, because a migration record does not
// look like the application's data.
res = tx.Where("app_code = ?", code).Delete(&commonmodels.Migration{})
if res.Error != nil {
return fmt.Errorf("removing this app's migration records: %w", res.Error)
}
rep.Migrations = res.RowsAffected
// 9.
if found {
if err := tx.Where("app_code = ?", code).
Delete(&adminmodels.SysApp{}).Error; err != nil {
return fmt.Errorf("removing the sys_app row: %w", err)
}
}
return nil
})
if err != nil {
return uninstallReport{Code: code}, err
}
return rep, nil
}
// findOrphanPolicies looks for policies naming any of this application's
// paths.
//
// Written as an OR chain rather than a row-value IN, which MySQL and modern
// SQLite accept and SQL Server does not; this repository supports all of
// them. Chunked because a driver's placeholder limit is reached long before
// an application runs out of endpoints.
func findOrphanPolicies(tx *gorm.DB, keys []policyKey) ([]policyKey, error) {
const chunk = 100
var out []policyKey
for start := 0; start < len(keys); start += chunk {
end := start + chunk
if end > len(keys) {
end = len(keys)
}
clauses := make([]string, 0, end-start)
args := make([]any, 0, (end-start)*2)
for _, k := range keys[start:end] {
clauses = append(clauses, "(v1 = ? AND v2 = ?)")
args = append(args, k.V1, k.V2)
}
var found []policyKey
if err := tx.Table("casbin_rule").
Where("ptype = ? AND ("+strings.Join(clauses, " OR ")+")", append([]any{"p"}, args...)...).
Scan(&found).Error; err != nil {
return nil, fmt.Errorf("looking for policies nothing claims: %w", err)
}
out = append(out, found...)
}
return out, nil
}
// reportUninstall prints what went and what stayed.
//
// The two lists are separate because they mean different things: one is
// something this application created that had already gone, the other is
// somebody else's grant that is now pointing at nothing. Merged into one
// "could not remove" list, neither would be actionable.
func reportUninstall(w io.Writer, rep uninstallReport) {
if !rep.Found {
fmt.Fprintf(w, "%s had no sys_app row; removed what its migrations had written\n", rep.Code)
} else {
fmt.Fprintf(w, "uninstalled %s %s\n", rep.Code, rep.Version)
}
fmt.Fprintf(w, "removed: %d menu(s), %d api(s), %d binding(s), %d role assignment(s), %d policy(ies), %d migration record(s)\n",
rep.Menus, rep.Apis, rep.Bindings, rep.RoleMenus, rep.Policies, rep.Migrations)
fmt.Fprintln(w, "the application's own tables were not touched.")
if len(rep.Skipped) > 0 {
fmt.Fprintf(w, "\n%d policy(ies) this install had created were already gone:\n", len(rep.Skipped))
for _, k := range rep.Skipped {
fmt.Fprintf(w, " %s\n", k)
}
}
if len(rep.Orphans) > 0 {
fmt.Fprintf(w, "\n%d policy(ies) name this application's paths and were granted by somebody else, so they were left alone:\n", len(rep.Orphans))
for _, k := range rep.Orphans {
fmt.Fprintf(w, " %s\n", k)
}
fmt.Fprintln(w, "they now point at APIs that no longer exist. Harmless to the running server, and yours to clear up.")
}
}