Skip to content

过滤器

goyave.dev/filter 允许使用查询参数进行强大的过滤。

使用方法

sh
go get goyave.dev/filter

首先,为您的索引路由添加必要的查询验证:

go
router.GlobalMiddleware(&parse.Middleware{}) // 不要忘记解析中间件!
router.Get("/users", user.Index).ValidateQuery(filter.Validation)

然后,在您的仓库和服务中创建一个新方法:

go
// database/repository/user.go
import "goyave.dev/filter"

//...

func (r *User) Paginate(ctx context.Context, request *filter.Request) (*database.Paginator[*model.User], error) {
	users := []*model.User{}
	paginator, err := filter.Scope(session.DB(ctx, r.DB), request, &users)
	return paginator, errors.New(err)
}
go
// service/user/user.go
func (s *Service) Paginate(ctx context.Context, request *filter.Request) (*database.PaginatorDTO[*dto.User], error) {
	paginator, err := s.repository.Paginate(ctx, request)
	return typeutil.MustConvert[*database.PaginatorDTO[*dto.User]](paginator), errors.New(err)
}

在您的控制器中,使用 filter.NewRequest() 和请求的查询来生成过滤器请求 DTO:

go
// http/controller/user/user.go
func (ctrl *Controller) Index(response *goyave.Response, request *goyave.Request) {
	paginator, err := ctrl.userService.Paginate(request.Context(), filter.NewRequest(request.Query))
	if response.WriteDBError(err) {
		return
	}
	response.JSON(http.StatusOK, paginator)
}

就这样!现在您的前端可以添加查询参数来进行过滤。

您也可以使用 ScopeUnpaginated() 查找记录而不进行分页:

go
var users []*model.User
tx := filter.ScopeUnpaginated(session.DB(ctx, r.DB), request, &users)

INFO

ScopeUnpaginated() 返回一个 *gorm.DB,而不是直接返回错误。要检查错误,请使用 tx.Error

设置

您可以使用 filter.Settings 禁用某些功能,或将某些字段加入黑名单:

go
settings := &filter.Settings[*model.User]{
	DisableFields: true, // 禁用 "fields" 功能
	DisableFilter: true, // 禁用 "filter" 功能
	DisableSort:   true, // 禁用 "sort" 功能
	DisableJoin:   true, // 禁用 "join" 功能

	// 如果不为 nil 且不为空,并且请求未提供任何
	// 排序,请求将根据此切片中定义的 `*Sort` 进行排序。
	// 如果启用了 `DisableSort`,则此设置无效。
	DefaultSort: []*Sort{{Field: "name", Order: SortDescending}},

	// 如果为 true,排序将在值为字符串时将其包装在 `LOWER()` 中,结果为 `ORDER BY LOWER(column)`。
	CaseInsensitiveSort: true,

	FieldsSearch:   []string{"a", "b"},      // 可选,用于搜索功能的字段
	SearchOperator: filter.Operators["$eq"], // 可选,用于搜索功能的操作符,默认为 "$cont"

	Blacklist: filter.Blacklist{
		// 阻止选择、排序和过滤这些字段
		FieldsBlacklist: []string{"a", "b"},

		// 阻止连接这些关系
		RelationsBlacklist: []string{"Relation"},

		Relations: map[string]*filter.Blacklist{
			// 应用于此关系的黑名单设置
			"Relation": &filter.Blacklist{
				FieldsBlacklist:    []string{"c", "d"},
				RelationsBlacklist: []string{"Parent"},
				Relations:          map[string]*filter.Blacklist{ /*...*/ },
				IsFinal:            true, // 如果为 true,则阻止连接任何子关系
			},
		},
	},
}
results := []*model.User{}
paginator, err := settings.Scope(session.DB(ctx, r.DB), request, &results)

过滤

?filter=字段||$操作符||

示例:

?filter=name||$cont||Jack (WHERE name LIKE "%Jack%")

您可以添加多个过滤器。在这种情况下,它被解释为 AND 条件。

您可以使用 ?or 来使用 OR 条件,或者组合使用:

?filter=name||$cont||Jack&or=name||$cont||John (WHERE (name LIKE %Jack% OR name LIKE "%John%"))
?filter=age||$eq||50&filter=name||$cont||Jack&or=name||$cont||John (WHERE ((age = 50 AND name LIKE "%Jack%") OR name LIKE "%John%"))

您可以使用一对一关系("has one" 或 "belongs to")中的列进行过滤:

?filter=Relation.name||$cont||Jack

如果只有一个 "or",它被视为常规过滤器:

?or=name||$cont||John (WHERE name LIKE "%John%")

如果同时存在 "filter" 和 "or",则它们被解释为两个 AND 组的组合,使用 OR 进行比较:

?filter=age||$eq||50&filter=name||$cont||Jack&or=name||$cont||John&or=name||$cont||Doe
WHERE ((age = 50 AND name LIKE "%Jack%") OR (name LIKE "%John%" AND name LIKE "%Doe%"))

注意: 添加到 SQL 查询中的所有过滤条件都是分组的(用括号包围)。

操作符

$eq=, 等于
$ne<>, 不等于
$gt>, 大于
$lt<, 小于
$gte>=, 大于等于
$lte<=, 小于等于
$startsLIKE val%, 以...开始
$endsLIKE %val, 以...结束
$contLIKE %val%, 包含
$exclNOT LIKE %val%, 不包含
$inIN (val1, val2,...), 在...中 (接受多个值)
$notinNOT IN (val1, val2,...), 不在...中 (接受多个值)
$isnullIS NULL, 为 NULL (不接受值)
$notnullIS NOT NULL, 不为 NULL (不接受值)
$betweenBETWEEN val1 AND val2, 在...之间 (接受两个值)

搜索

搜索类似于多个 or=column||$cont||value,但列和操作符由服务器指定而不是客户端。

使用 Settings 指定列:

go
settings := &filter.Settings{
	FieldsSearch: []string{"a", "b"},
	SearchOperator: filter.Operators["$eq"], // 可选,默认为 "$cont"
	//...
}

?search=John (WHERE (a LIKE "%John%" OR b LIKE "%John%"))

如果您不指定 FieldsSearch,查询将在所有可选择的字段中搜索。

字段 / 选择

?fields=field1,field2

要选择的字段的逗号分隔列表。如果未提供此字段,则使用 SELECT *

排序

?sort=column,ASC|DESC

示例:

?sort=name,ASC
?sort=age,DESC

您也可以按多个字段排序。

?sort=age,DESC&sort=name,ASC

连接

?join=relation

预加载一个关系。您也可以只选择需要的列:

?join=relation||field1,field2

您可以连接多个关系:

?join=profile||firstName,email&join=notifications||content&join=tasks

分页

在内部,goyave.dev/filter 使用 Goyave 的 Paginator

?page=1&per_page=10

  • 如果未给出 page,将返回第一页。
  • 如果未给出 per_page,将使用默认页面大小。可以通过更改 filter.DefaultPageSize 来覆盖此默认值。
  • 无论哪种方式,结果总是分页的,即使缺少这两个参数。

计算列

有时您需要使用不在数据库中存储但使用 SQL 表达式计算的"虚拟"列。例如,取决于日期的动态状态。为了正确支持此库的功能,您必须使用 computed 结构标签将表达式添加到模型中:

go
type MyModel struct {
	ID int64
	// ...
	StartDate time.Time
	Status    string `gorm:"->" computed:"CASE WHEN ~~~ct~~~.start_date < NOW() THEN 'pending' ELSE 'started' END"`
}

注意:~~~ct~~~当前表的指示符。它将自动替换为正确的表或关系名称。这也允许在关系中使用的计算字段,其中需要连接。

提示: 您也可以使用组合来避免将虚拟列包含在模型中:

go
type MyModel struct{
	ID int64
	// ...
	StartDate time.Time
}

type MyModelWithStatus struct{
	MyModel
	Status string `gorm:"->" computed:"CASE WHEN ~~~ct~~~.start_date < NOW() THEN 'pending' ELSE 'started' END"`
}

当使用 JSON 列时,您可以使用计算列支持对该 JSON 列内嵌套字段的过滤器:

go
// 此示例与 PostgreSQL 兼容。
// 如果您使用其他数据库引擎,JSON 处理可能会有所不同。
type MyModel struct {
	ID            int64
	JSONColumn    datatypes.JSON
	SomeJSONField null.Int `gorm:"->" computed:"(~~~ct~~~.json_column->>'fieldName')::int"`
}

确保您的 JSON 表达式返回的值具有与结构字段匹配的类型以避免数据库错误非常重要。数据库引擎通常只从 JSON 返回文本类型。如果您的字段是数字,您必须对其进行转换,否则在过滤此字段时会出现数据库错误。

安全性

  • 输入被转义以防止 SQL 注入。
  • 字段经过预处理,客户端不能请求不存在的字段。这防止了数据库错误。如果需要不存在的字段,它将被忽略。排序和连接也是如此。不可能请求不存在的关系。
  • 类型安全:在相同的字段预处理中,字段的广义类型根据数据库类型(基于模型定义)进行检查。如果输入无法转换为列的类型,这可以防止数据库错误。
  • 外键总是在连接中被选择,以确保关联可以分配给父模型。
  • 小心双向关系(例如,一篇文章由一个用户撰写,一个用户可以有多篇文章)。如果您启用了两个模型来预加载这些关系,客户端可以无限深度地请求它们(Articles.User.Articles.User...)。为防止这种情况,建议在 deepest requestable models 上使用关系黑名单IsFinal。有关详细信息,请参阅设置部分。

提示

模型和 DTO 建议

  • 为了仅将选定的字段和关系返回给用户响应中,所有模型和 DTO 字段应使用 typeutil.Undefinedjson:",omitzero" 结构标签。
  • 不要在 DTO 中包含外键。
  • 使用 gopkg.in/guregu/null.v4 库中的 null.Time 而不是 sql.NullTime
  • 始终指定 gorm:"foreignKey",否则回退到 "ID"。
  • 不要使用 gorm.Model 并手动添加必要的字段。这样您可以更好地控制 json 结构标签。

类型安全

对于未实现 driver.Valuer 接口的非本地类型,您应始终使用 filterType 结构标签。此结构标签强制字段的 recognized broad type 用于类型安全转换。在处理数组时,也建议始终添加此标签。此标签对过滤器和搜索功能有效。

可用的广义类型有:

  • text / text[]
  • enum / enum[]: 用于自定义枚举类型,以防止"无效输入值"或"无效操作符"错误
  • bool / bool[]
  • int8 / int8[], int16 / int16[], int32 / int32[], int64 / int64[]
  • uint / uint[], uint16 / uint16[], uint32 / uint32[], uint64 / uint64[]
  • float32 / float32[], float64 / float64[]
  • time / time[]
  • -: 不支持的数据类型。标记为 - 的字段将在过滤器和搜索中被忽略:不会向 WHERE 子句添加条件。

如果未提供,类型将从 Gorm 的数据类型确定。如果 Gorm 的数据类型是此库不直接支持的自定义类型,类型将回退到 -(不支持)并且该字段将在过滤器中忽略。

如果类型受支持但用户输入不能与请求的列一起使用,内置操作符将生成 FALSE 条件。

示例

go
type MyModel struct{
	ID int64
	// ...
	StartDate null.Time `filterType:"time"`
}

静态条件

如果要添加静态条件(不是由库自动定义的),建议按如下方式对它们进行分组:

go
users := []*model.User{}
db = session.DB(ctx, r.DB).Where(r.DB.Where("username LIKE ?", "%Miss%").Or("username LIKE ?", "%Ms.%"))
paginator, err := filter.Scope(db, request, &users)

自定义操作符

您可以通过修改 filter.Operators 映射来添加自定义操作符(或覆盖现有操作符):

go
import (
	"gorm.io/gorm"
	"gorm.io/gorm/schema"
	"goyave.dev/filter"
	"goyave.dev/goyave/v5/util/sqlutil"
)

// ...

filter.Operators["$cont"] = &filter.Operator{
	Function: func(tx *gorm.DB, f *filter.Filter, column string, dataType filter.DataType) *gorm.DB {
		if dataType != filter.DataTypeString {
			return f.Where("FALSE")
		}
		query := column + " LIKE ?"
		value := "%" + sqlutil.EscapeLike(f.Args[0]) + "%"
		return f.Where(tx, query, value)
	},
	RequiredArguments: 1,
}

filter.Operators["$eq"] = &filter.Operator{
	Function: func(tx *gorm.DB, f *filter.Filter, column string, dataType filter.DataType) *gorm.DB {
		if dataType.IsArray() {
			return f.Where("FALSE")
		}
		arg, ok := filter.ConvertToSafeType(f.Args[0], dataType)
		if !ok {
			return f.Where("FALSE")
		}
		query := fmt.Sprintf("%s = ?", column, op)
		return f.Where(tx, query, arg)
	},
	RequiredArguments: 1,
}

数组操作符

某些数据库引擎(如 PostgreSQL)提供用于数组操作的操作符(@>, &&, ...)。由于 Gorm 将切片转换为记录(("a", "b") 而不是 {"a", "b"}),您可能会在项目中实现这些操作符时遇到问题。

要解决此问题,您必须实现自己的 ConvertArgsToSafeType 变体,以便它返回具有具体类型的切片的指针而不是 []any。通过向 Gorm 发送指针,它不会尝试自己渲染切片,而是直接将其传递给底层驱动程序,后者通常知道如何处理本机类型的切片。

示例(使用泛型):

go
type argType interface {
	string | int64 | uint64 | float64 | bool
}

func init() {
	filter.Operators["$arrayin"] = &filter.Operator{
		Function: func (tx *gorm.DB, f *filter.Filter, column string, dataType filter.DataType) *gorm.DB {
			if !dataType.IsArray() {
				return f.Where("FALSE")
			}

			if dataType == filter.DataTypeEnumArray {
				column = fmt.Sprintf("CAST(%s as TEXT[])", column)
			}

			query := fmt.Sprintf("%s @> ?", column)
			switch dataType {
			case filter.DataTypeTextArray, filter.DataTypeEnumArray, filter.DataTypeTimeArray:
				return bindArrayArg[string](tx, query, f, dataType)
			case filter.DataTypeFloat32Array, filter.DataTypeFloat64Array:
				return bindArrayArg[float64](tx, query, f, dataType)
			case filter.DataTypeUint8Array, filter.DataTypeUint16Array, filter.DataTypeUint32Array, filter.DataTypeUint64Array:
				return bindArrayArg[uint64](tx, query, f, dataType)
			case filter.DataTypeInt8Array, filter.DataTypeInt16Array, filter.DataTypeInt32Array, filter.DataTypeInt64Array:
				return bindArrayArg[int64](tx, query, f, dataType)
			}

			// 如果您需要处理 DataTypeBoolArray,请使用 pgtype.BoolArray
			return f.Where("FALSE")
		},
		RequiredArguments: 1,
	}
}

func bindArrayArg[T argType](tx *gorm.DB, query string, f *filter.Filter, dataType filter.DataType) *gorm.DB {
	args, ok := convertArgsToSafeTypeArray[T](f.Args, dataType)
	if !ok {
		return f.Where("FALSE")
	}
	return f.Where(tx, query, args)
}

func convertArgsToSafeTypeArray[T argType](args []string, dataType filter.DataType) (*[]T, bool) {
	result := make([]T, 0, len(args))
	for _, arg := range args {
		a, ok := filter.ConvertToSafeType(arg, dataType)
		if !ok {
			return nil, false
		}
		result = append(result, a.(T))
	}

	return &result, true
}

手动连接

支持手动连接,并且不会与库自动生成的连接冲突。这意味着如果需要,您可以编写如下所述的代码。

go
// database/repository/user.go
func (r *User) Paginate(ctx context.Context, request *filter.Request) (*database.Paginator[*model.User], error) {
	var users []*model.User
	db := session.DB(ctx, r.DB).Joins("Relation")
	paginator, err := filter.Scope(db, request, &users)
	return paginator, errors.New(err)
}