feat✨: add generic CRUD actions

Index, View, ViewAs, Create, Update and Delete take the model and the
request as type parameters. They serve the same routes as IndexAction
and its siblings and answer them the same way; what they drop is what
the caller had to get right.

The older actions serve every request from instances passed at
registration, so each model and request type needs a Generate that
returns a copy, and a list route takes a func() interface{} whose
element type nothing checks. Here every request declares its own
values, and a model paired with the wrong request does not compile.
ViewAs answers a detail route with a type other than the model, as
ViewAction's f did.

Beyond that they differ in three places, all where the older ones were
wrong: a request with no database is answered with 500 instead of an
empty 200; the key is matched against the primary-key column as a
value, not handed to Where, which reads a string as SQL; and there is
no GenerateM whose error could be dropped.

A test runs the old and new actions on the same types through one
request script and requires every response to match.
This commit is contained in:
zhangwenjian
2026-09-27 20:46:01 +08:00
parent ea2d807071
commit 024dd047ae
2 changed files with 551 additions and 0 deletions
+294
View File
@@ -0,0 +1,294 @@
package actions
import (
"errors"
"net/http"
"reflect"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
"github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
"go-admin/common/dto"
)
// The generic CRUD actions. They serve the same five kinds of route as
// IndexAction and its siblings, and answer them the same way; what changes is
// what the caller has to get right.
//
// The older actions take instances at registration and serve every request
// from them, so each model and DTO has to implement a Generate that returns a
// copy - return the receiver and concurrent requests share one struct - and a
// list route takes a func() interface{} whose element type nothing checks.
// Here each request declares its own values of the types it was given, so
// neither convention exists, and a model paired with the wrong DTO does not
// compile.
//
// Three things differ from the older actions, all on paths they got wrong:
//
// - A request whose database connection cannot be found is answered with
// 500. The older actions logged it and wrote nothing, which reaches the
// client as an empty 200.
// - The key is matched against the model's primary-key column as a value.
// The older actions passed GetId() to Where, which GORM reads as a SQL
// condition when it is a string.
// - There is no GenerateM, so no error of its to drop.
//
// The type constraints are unexported: callers never name them, and an
// exported constraint would be a promise that could not be taken back.
// record is a model the actions can create, read, change and delete.
type record[T any] interface {
*T
schema.Tabler
SetCreateBy(int)
SetUpdateBy(int)
GetId() any
}
// searchReq is a list request: it binds itself, reports its page, and hands
// back the struct whose search tags MakeCondition turns into WHERE clauses.
type searchReq[S any] interface {
*S
Bind(*gin.Context) error
GetPageIndex() int
GetPageSize() int
GetNeedSearch() any
}
// idReq is a request naming rows by key: one from the URI, or several from a
// DELETE body, as dto.ObjectById binds them.
type idReq[I any] interface {
*I
Bind(*gin.Context) error
GetId() any
}
// controlReq is a create or update request: it binds itself and builds the
// model row it describes.
type controlReq[T, C any] interface {
*C
Bind(*gin.Context) error
ToModel() (*T, error)
}
// Index lists T, filtered by S and by the caller's data permission.
func Index[T, S any, PT record[T], PS searchReq[S]]() gin.HandlerFunc {
return func(c *gin.Context) {
log := api.GetRequestLogger(c)
db, ok := genericOrm(c)
if !ok {
return
}
msgID := pkg.GenerateMsgIDFromContext(c)
var req S
if err := PS(&req).Bind(c); err != nil {
response.Error(c, http.StatusUnprocessableEntity, err, "参数验证失败")
return
}
var object T
list := make([]T, 0)
var count int64
p := GetPermissionFromContext(c)
err := db.WithContext(c.Request.Context()).Model(&object).
Scopes(
dto.MakeCondition(PS(&req).GetNeedSearch()),
dto.Paginate(PS(&req).GetPageSize(), PS(&req).GetPageIndex()),
Permission(PT(&object).TableName(), p),
).
Find(&list).Limit(-1).Offset(-1).
Count(&count).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
log.Errorf("MsgID[%s] Index error: %s", msgID, err)
response.Error(c, 500, err, "查询失败")
return
}
response.PageOK(c, &list, int(count), PS(&req).GetPageIndex(), PS(&req).GetPageSize(), "查询成功")
c.Next()
}
}
// View answers one T, named by I's key, if the caller's data permission
// reaches it.
func View[T, I any, PT record[T], PI idReq[I]]() gin.HandlerFunc {
return ViewAs[T, I, T, PT, PI]()
}
// ViewAs is View answering with R instead of T: the row is looked up as T and
// scanned into R, for a detail route that shows a different shape than the
// model stores.
func ViewAs[T, I, R any, PT record[T], PI idReq[I]]() gin.HandlerFunc {
return func(c *gin.Context) {
log := api.GetRequestLogger(c)
db, ok := genericOrm(c)
if !ok {
return
}
msgID := pkg.GenerateMsgIDFromContext(c)
var req I
if err := PI(&req).Bind(c); err != nil {
response.Error(c, http.StatusUnprocessableEntity, err, "参数验证失败")
return
}
var object T
var rsp R
p := GetPermissionFromContext(c)
err := db.Model(&object).WithContext(c.Request.Context()).Scopes(
Permission(PT(&object).TableName(), p),
).Where(keyIs(PI(&req).GetId())).First(&rsp).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusNotFound, nil, "查看对象不存在或无权查看")
return
}
if err != nil {
log.Errorf("MsgID[%s] View error: %s", msgID, err)
response.Error(c, 500, err, "查看失败")
return
}
response.OK(c, &rsp, "查询成功")
c.Next()
}
}
// Create inserts the T that C builds, recording the caller as its creator.
func Create[T, C any, PT record[T], PC controlReq[T, C]]() gin.HandlerFunc {
return func(c *gin.Context) {
log := api.GetRequestLogger(c)
db, ok := genericOrm(c)
if !ok {
return
}
var req C
if err := PC(&req).Bind(c); err != nil {
response.Error(c, http.StatusUnprocessableEntity, err, err.Error())
return
}
object, err := PC(&req).ToModel()
if err != nil {
response.Error(c, 500, err, "模型生成失败")
return
}
PT(object).SetCreateBy(user.GetUserId(c))
if err = db.WithContext(c.Request.Context()).Create(object).Error; err != nil {
log.Errorf("Create error: %s", err)
response.Error(c, 500, err, "创建失败")
return
}
response.OK(c, PT(object).GetId(), "创建成功")
c.Next()
}
}
// Update writes the T that C builds over the row with its key, if the
// caller's data permission reaches that row.
func Update[T, C any, PT record[T], PC controlReq[T, C]]() gin.HandlerFunc {
return func(c *gin.Context) {
log := api.GetRequestLogger(c)
db, ok := genericOrm(c)
if !ok {
return
}
msgID := pkg.GenerateMsgIDFromContext(c)
var req C
if err := PC(&req).Bind(c); err != nil {
response.Error(c, http.StatusUnprocessableEntity, err, "参数验证失败")
return
}
object, err := PC(&req).ToModel()
if err != nil {
response.Error(c, 500, err, "模型生成失败")
return
}
PT(object).SetUpdateBy(user.GetUserId(c))
p := GetPermissionFromContext(c)
result := db.WithContext(c.Request.Context()).Scopes(
Permission(PT(object).TableName(), p),
).Where(keyIs(PT(object).GetId())).Updates(object)
if err = result.Error; err != nil {
log.Errorf("MsgID[%s] Update error: %s", msgID, err)
response.Error(c, 500, err, "更新失败")
return
}
if result.RowsAffected == 0 {
response.Error(c, http.StatusForbidden, nil, "无权更新该数据")
return
}
response.OK(c, PT(object).GetId(), "更新成功")
c.Next()
}
}
// Delete removes the rows I names, where the caller's data permission
// reaches them.
func Delete[T, I any, PT record[T], PI idReq[I]]() gin.HandlerFunc {
return func(c *gin.Context) {
log := api.GetRequestLogger(c)
db, ok := genericOrm(c)
if !ok {
return
}
msgID := pkg.GenerateMsgIDFromContext(c)
var req I
if err := PI(&req).Bind(c); err != nil {
log.Errorf("MsgID[%s] Bind error: %s", msgID, err)
response.Error(c, http.StatusUnprocessableEntity, err, "参数验证失败")
return
}
var object T
PT(&object).SetUpdateBy(user.GetUserId(c))
p := GetPermissionFromContext(c)
result := db.WithContext(c.Request.Context()).Scopes(
Permission(PT(&object).TableName(), p),
).Where(keyIs(PI(&req).GetId())).Delete(&object)
if err := result.Error; err != nil {
log.Errorf("MsgID[%s] Delete error: %s", msgID, err)
response.Error(c, 500, err, "删除失败")
return
}
if result.RowsAffected == 0 {
response.Error(c, http.StatusForbidden, nil, "无权删除该数据")
return
}
// The key of the empty model, as DeleteAction answers: the rows are
// named by the request, not by a model.
response.OK(c, PT(&object).GetId(), "删除成功")
c.Next()
}
}
// genericOrm reads the request's database, answering 500 when there is none.
func genericOrm(c *gin.Context) (*gorm.DB, bool) {
db, err := pkg.GetOrm(c)
if err != nil {
api.GetRequestLogger(c).Error(err)
response.Error(c, 500, err, "数据库连接获取失败")
return nil, false
}
return db, true
}
// keyIs matches the primary-key column against id as a value, or against
// each element when id is a slice or array.
func keyIs(id any) clause.Expression {
v := reflect.ValueOf(id)
if v.Kind() == reflect.Slice || v.Kind() == reflect.Array {
values := make([]any, v.Len())
for i := range values {
values[i] = v.Index(i).Interface()
}
return clause.IN{Column: clause.PrimaryColumn, Values: values}
}
return clause.Eq{Column: clause.PrimaryColumn, Value: id}
}
+257
View File
@@ -0,0 +1,257 @@
package actions_test
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"sync"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/go-admin-team/go-admin-core/v2/jwtauth"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
"go-admin/common/actions"
"go-admin/common/dto"
"go-admin/common/models"
)
// probeSearch is probeIndexReq with a search field, so a list request can
// be told apart from another in its response.
type probeSearch struct {
dto.Pagination `search:"-"`
Name string `form:"name" search:"type:exact;column:name;table:action_probe_row"`
}
func (p *probeSearch) Generate() dto.Index { o := *p; return &o }
func (p *probeSearch) Bind(c *gin.Context) error { return c.ShouldBind(p) }
func (p *probeSearch) GetNeedSearch() interface{} { return *p }
// probeById names rows the way every ById DTO here does.
type probeById struct {
dto.ObjectById
}
func (s *probeById) Generate() dto.Control { o := *s; return &o }
func (s *probeById) GenerateM() (models.ActiveRecord, error) { return &probeRow{}, nil }
// probeControl carries both the old actions' methods and ToModel, so one
// request type serves both generations of action.
type probeControl struct {
Id int `json:"id"`
Name string `json:"name"`
}
func (s *probeControl) Bind(c *gin.Context) error { return c.ShouldBindJSON(s) }
func (s *probeControl) Generate() dto.Control { o := *s; return &o }
func (s *probeControl) GetId() interface{} { return s.Id }
func (s *probeControl) GenerateM() (models.ActiveRecord, error) {
return &probeRow{Model: models.Model{Id: s.Id}, Name: s.Name}, nil
}
func (s *probeControl) ToModel() (*probeRow, error) {
return &probeRow{Model: models.Model{Id: s.Id}, Name: s.Name}, nil
}
// probeKey names a row by a string key, which the old actions handed to GORM
// as a SQL condition.
type probeKey struct {
Key string `uri:"id"`
}
func (s *probeKey) Bind(c *gin.Context) error { return c.ShouldBindUri(s) }
func (s *probeKey) GetId() interface{} { return s.Key }
func probeDB(t *testing.T, name string, l gormlogger.Interface) *gorm.DB {
t.Helper()
if l == nil {
l = gormlogger.Default.LogMode(gormlogger.Silent)
}
db, err := gorm.Open(sqlite.Open("file:"+name+"?mode=memory&cache=shared"), &gorm.Config{Logger: l})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&probeRow{}); err != nil {
t.Fatal(err)
}
return db
}
// probeEngine serves the five routes from db, as caller 7.
func probeEngine(db *gorm.DB, register func(r gin.IRoutes)) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
if db != nil {
c.Set("db", db)
}
c.Set(jwtauth.JwtPayloadKey, jwtauth.MapClaims{"identity": float64(7)})
c.Next()
})
register(r)
return r
}
func oldRoutes(r gin.IRoutes) {
r.GET("/x", actions.IndexAction(&probeRow{}, &probeSearch{}, func() interface{} { l := make([]probeRow, 0); return &l }))
r.GET("/x/:id", actions.ViewAction(&probeById{}, func() interface{} { return &probeRow{} }))
r.POST("/x", actions.CreateAction(&probeControl{}))
r.PUT("/x", actions.UpdateAction(&probeControl{}))
r.DELETE("/x", actions.DeleteAction(&probeById{}))
}
func newRoutes(r gin.IRoutes) {
r.GET("/x", actions.Index[probeRow, probeSearch]())
r.GET("/x/:id", actions.View[probeRow, probeById]())
r.POST("/x", actions.Create[probeRow, probeControl]())
r.PUT("/x", actions.Update[probeRow, probeControl]())
r.DELETE("/x", actions.Delete[probeRow, probeById]())
}
var requestIDField = regexp.MustCompile(`"requestId":"[^"]*"`)
func serve(r *gin.Engine, method, path, body string) (int, string) {
w := httptest.NewRecorder()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
return w.Code, requestIDField.ReplaceAllString(w.Body.String(), `"requestId":""`)
}
// The generic actions answer every request exactly as the actions they
// replace, on the same types and the same data, until the old ones go.
func TestGenericActionsAnswerAsTheOldOnesDo(t *testing.T) {
script := []struct{ method, path, body string }{
{"POST", "/x", `{"name":"a"}`},
{"POST", "/x", `{"name":"b"}`},
{"POST", "/x", `not json`},
{"GET", "/x?pageIndex=1&pageSize=10", ""},
{"GET", "/x?pageIndex=1&pageSize=1&name=b", ""},
{"GET", "/x?pageIndex=2&pageSize=1", ""},
{"GET", "/x/1", ""},
{"GET", "/x/99", ""},
{"GET", "/x/abc", ""},
{"PUT", "/x", `{"id":1,"name":"a2"}`},
{"PUT", "/x", `{"id":99,"name":"ghost"}`},
{"PUT", "/x", `{"name":"no-id"}`},
{"GET", "/x/1", ""},
{"DELETE", "/x", `{"ids":[2]}`},
{"DELETE", "/x", `{"ids":[99]}`},
{"GET", "/x?pageIndex=1&pageSize=10", ""},
}
old := probeEngine(probeDB(t, t.Name()+"-old", nil), oldRoutes)
gen := probeEngine(probeDB(t, t.Name()+"-new", nil), newRoutes)
for i, s := range script {
oc, ob := serve(old, s.method, s.path, s.body)
nc, nb := serve(gen, s.method, s.path, s.body)
if oc != nc || ob != nb {
t.Errorf("step %d, %s %s %s:\nold %d %s\nnew %d %s", i+1, s.method, s.path, s.body, oc, ob, nc, nb)
}
}
}
// Every action that reads or changes existing rows applies the caller's
// data permission: Index, View, Update and Delete.
func TestGenericActionsApplyDataPermission(t *testing.T) {
previous := config.ApplicationConfig.EnableDP
config.ApplicationConfig.EnableDP = true
t.Cleanup(func() { config.ApplicationConfig.EnableDP = previous })
for _, req := range []struct{ method, path, body string }{
{"GET", "/x?pageIndex=1&pageSize=10", ""},
{"GET", "/x/1", ""},
{"PUT", "/x", `{"id":1,"name":"a"}`},
{"DELETE", "/x", `{"ids":[1]}`},
} {
cl := &capturingLogger{Interface: gormlogger.Default.LogMode(gormlogger.Silent)}
db := probeDB(t, strings.NewReplacer("/", "_", "?", "_").Replace(t.Name()+req.method+req.path), cl)
r := probeEngine(db, func(r gin.IRoutes) {
self := func(c *gin.Context) {
c.Set(actions.PermissionKey, &actions.DataPermission{DataScope: actions.DataScopeSelf, UserId: 7})
}
r.GET("/x", self, actions.Index[probeRow, probeSearch]())
r.GET("/x/:id", self, actions.View[probeRow, probeById]())
r.PUT("/x", self, actions.Update[probeRow, probeControl]())
r.DELETE("/x", self, actions.Delete[probeRow, probeById]())
})
serve(r, req.method, req.path, req.body)
if !strings.Contains(cl.all(), "action_probe_row.create_by = ") {
t.Errorf("%s %s ran without the data-permission scope:\n%s", req.method, req.path, cl.all())
}
}
}
// A string key is compared as a value. Handed to Where on its own, "1=1"
// is read as a SQL condition and matches every row.
func TestGenericViewMatchesAStringKeyAsAValue(t *testing.T) {
db := probeDB(t, t.Name(), nil)
if err := db.Create(&probeRow{Name: "a"}).Error; err != nil {
t.Fatal(err)
}
r := probeEngine(db, func(r gin.IRoutes) { r.GET("/x/:id", actions.View[probeRow, probeKey]()) })
_, body := serve(r, "GET", "/x/1=1", "")
var res struct{ Code int }
_ = json.Unmarshal([]byte(body), &res)
if res.Code != http.StatusNotFound {
t.Errorf("GET /x/1=1 answered %s; want the row not found", body)
}
}
// Without a database in the request the old actions wrote nothing, which a
// client reads as an empty 200.
func TestGenericActionsAnswerAMissingDatabase(t *testing.T) {
r := probeEngine(nil, newRoutes)
for _, req := range []struct{ method, path, body string }{
{"GET", "/x?pageIndex=1&pageSize=10", ""},
{"GET", "/x/1", ""},
{"POST", "/x", `{"name":"a"}`},
{"PUT", "/x", `{"id":1}`},
{"DELETE", "/x", `{"ids":[1]}`},
} {
_, body := serve(r, req.method, req.path, req.body)
if !strings.Contains(body, `"code":500`) || !strings.Contains(body, "数据库连接获取失败") {
t.Errorf("%s %s answered %q; want a 500 naming the connection", req.method, req.path, body)
}
}
}
// Concurrent requests to one route each see only what they asked for. The
// generic actions build every value per request, so there is nothing to
// share; this holds that shape.
func TestGenericIndexKeepsConcurrentRequestsApart(t *testing.T) {
db := probeDB(t, t.Name(), nil)
for i := 0; i < 20; i++ {
if err := db.Create(&probeRow{Name: fmt.Sprintf("n%d", i)}).Error; err != nil {
t.Fatal(err)
}
}
r := probeEngine(db, newRoutes)
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
name := fmt.Sprintf("n%d", i)
wg.Go(func() {
for j := 0; j < 10; j++ {
_, body := serve(r, "GET", "/x?pageIndex=1&pageSize=10&name="+name, "")
var res struct {
Data struct{ List []probeRow }
}
if err := json.Unmarshal([]byte(body), &res); err != nil {
t.Errorf("decoding %q: %v", body, err)
return
}
if len(res.Data.List) != 1 || res.Data.List[0].Name != name {
t.Errorf("asked for %s, got %+v", name, res.Data.List)
return
}
}
})
}
wg.Wait()
}