Documentation
¶
Overview ¶
Package flop implements declarative AIP-132 ordering, AIP-160 filtering, and cursor or page-number pagination.
A Schema declares the fields a collection exposes and which of them may be filtered, sorted or paged by. Everything else is built on it, and each part is optional: a schema may filter without sorting or sort without paging, and page-number pagination needs no schema.
var users = flop.NewSchema(
flop.NewField("id").Ref("u.id").Int().Unique(),
flop.NewField("display_name").Ref("u.name").String().Filterable().Sortable().Implicit(),
flop.NewField("created_at").Ref("u.created_at").Time().Filterable().Sortable(),
).MustBuild()
A field carries a public path and a private backend ref. The path is what a client writes in a filter or order_by clause. The ref is an opaque value a backend uses to address the field and must be a trusted constant.
Schema.ParseFilter and Schema.ParseOrder validate a request and return the AIP types. flop holds no request state of its own: an order, a filter and a decoded cursor position are passed to a backend as they are.
filter, err := users.ParseFilter(r.FormValue("filter"))
order, err := users.ParseOrder(r.FormValue("order_by"))
after, err := users.DecodeCursor(r.FormValue("page_token"), order, filter)
A query selects one row more than the page holds. Schema.CursorPage splits that surplus row off and mints the token the next page continues from. Page-number pagination uses Offset for the query and NewOffsetPage for the page it returns.
Query generation lives outside this package. Schema.Compile turns a validated filter into a tree of And, Or, Not and Cmp nodes whose values are already coerced to Go types, which a backend walks to emit whatever its database speaks. Two backends ship with flop: github.com/ysomad/flop/rawsql for SQL text and named arguments, and github.com/ysomad/flop/flopsq for the squirrel query builder.
Errors returned for bad input wrap ErrInvalidFilter, ErrInvalidOrder, ErrInvalidCursor, ErrCursorMismatch, ErrInvalidPage, ErrInvalidPageSize or ErrInvalidSkip, so a handler can map them to a 400. ErrDeclaration marks a mistake in the schema or in how the library was called, which no request can provoke.
Index ¶
- Variables
- func MergeOrder(def, order []aip132.OrderBy) []aip132.OrderBy
- func Offset(page, pageSize int32) (int64, error)
- func TotalPages(totalItems int64, pageSize int32) int64
- type And
- type Cmp
- type CursorPage
- type CursorPosition
- type CursorValue
- type Expr
- type Field
- type FieldBuilder
- func (b *FieldBuilder) Bool() *FieldBuilder
- func (b *FieldBuilder) Duration() *FieldBuilder
- func (b *FieldBuilder) Filterable() *FieldBuilder
- func (b *FieldBuilder) Float() *FieldBuilder
- func (b *FieldBuilder) Implicit() *FieldBuilder
- func (b *FieldBuilder) Int() *FieldBuilder
- func (b *FieldBuilder) Ref(ref string) *FieldBuilder
- func (b *FieldBuilder) Sortable() *FieldBuilder
- func (b *FieldBuilder) String() *FieldBuilder
- func (b *FieldBuilder) Time() *FieldBuilder
- func (b *FieldBuilder) Unique() *FieldBuilder
- func (b *FieldBuilder) Value[T any](fn func(T) any) *FieldBuilder
- type Not
- type OffsetPage
- type Op
- type Or
- type Schema
- func (s *Schema) Compile(filter *aip160.Filter) (Expr, error)
- func (s *Schema) CompileSeek(order []aip132.OrderBy, pos CursorPosition) (Expr, error)
- func (s *Schema) CursorPage[T any](rows []T, pageSize int32, order []aip132.OrderBy, filter *aip160.Filter) (CursorPage[T], error)
- func (s *Schema) DecodeCursor(token string, order []aip132.OrderBy, filter *aip160.Filter) (CursorPosition, error)
- func (s *Schema) EncodeCursor(row any, order []aip132.OrderBy, filter *aip160.Filter) (string, error)
- func (s *Schema) Fields() []*Field
- func (s *Schema) FilterableField(path aip132.FieldPath) (*Field, error)
- func (s *Schema) ParseFilter(text string) (*aip160.Filter, error)
- func (s *Schema) ParseOrder(text string) ([]aip132.OrderBy, error)
- func (s *Schema) SortableField(path aip132.FieldPath) (*Field, error)
- func (s *Schema) SortableFields(order []aip132.OrderBy) ([]*Field, error)
- func (s *Schema) TotalOrder(order []aip132.OrderBy) []aip132.OrderBy
- func (s *Schema) UniqueField() *Field
- func (s *Schema) ValidateFilter(filter *aip160.Filter) error
- func (s *Schema) ValidateOrder(order []aip132.OrderBy) error
- type SchemaBuilder
Constants ¶
This section is empty.
Variables ¶
var ( // ErrInvalidFilter reports a filter flop refuses: one that does not parse, // names an undeclared or non-filterable field, carries a value of the wrong // type, or uses an operator the field does not accept. ErrInvalidFilter = errors.New("flop: invalid filter") // ErrInvalidCursor reports a cursor that is malformed, truncated, or // carries a value that cannot address a row. ErrInvalidCursor = errors.New("flop: invalid cursor") // ErrCursorMismatch reports a well-formed cursor issued for a different // order or filter than the one accompanying it. ErrCursorMismatch = errors.New("flop: cursor does not match") // ErrInvalidOrder reports an order naming an undeclared field, repeating a // field, or using a malformed path. ErrInvalidOrder = errors.New("flop: invalid order") // ErrInvalidPageSize reports a negative page size. ErrInvalidPageSize = errors.New("flop: invalid page size") // ErrInvalidPage reports a negative page number. ErrInvalidPage = errors.New("flop: invalid page number") // ErrInvalidSkip reports a negative number of results to skip. ErrInvalidSkip = errors.New("flop: invalid skip") // ErrDeclaration reports invalid schema or pagination setup. No client input // can provoke it, so it always marks a programmer mistake. ErrDeclaration = errors.New("flop: invalid declaration") )
Functions ¶
func MergeOrder ¶
MergeOrder combines a requested order with a schema's default. Terms in order take precedence, and the terms of def it does not name follow in the order def gives them.
func Offset ¶
Offset returns the row offset of a one-based page number. A zero page number selects the first page.
func TotalPages ¶
TotalPages returns how many pages of pageSize rows totalItems fills.
Types ¶
type Cmp ¶
Cmp compares a field against a value.
Value is a string, int64, float64, bool, time.Time or time.Duration matching the field's type, or nil for a null comparison. It is never user text: the schema has already coerced it.
type CursorPage ¶
CursorPage is a page of rows and the token the page after it continues from. NextCursor is empty on the last page.
type CursorPosition ¶
type CursorPosition []CursorValue
CursorPosition addresses the row a page continues after. It holds one value per ordering field, in order.
type CursorValue ¶
type CursorValue struct {
// FieldPath is the field the value belongs to, matching the order the
// cursor was issued under.
FieldPath aip132.FieldPath
// Value is the field's value. It must be a bool, int64, uint64, float64,
// string, []byte, time.Time or time.Duration.
Value any
}
CursorValue is one ordering field of a row and the value it held there.
type Expr ¶
type Expr interface {
// contains filtered or unexported methods
}
Expr is a node of a compiled filter. The tree a schema compiles holds only And, Or, Not and Cmp, so a backend can switch over it exhaustively.
type Field ¶
type Field struct {
// contains filtered or unexported fields
}
Field is one field of a collection, declared with NewField.
A field has a public path and a private backend reference. The path is what a client writes in a filter or order_by, what errors name the field by, and what a cursor is bound to. The ref is what generated queries use. The two are free to differ:
flop.NewField("user_id").Ref("u.id").Int().Filterable().Sortable().
Value(func(u user) any { return u.ID })
A ref defaults to the path. Declare one when a backend addresses the field by another name or syntax.
Renaming a ref is invisible to clients. Renaming a path is a breaking change: filters clients already send stop resolving, and cursors they already hold stop matching, because the binding is taken over the paths the order names.
type FieldBuilder ¶
type FieldBuilder struct {
// contains filtered or unexported fields
}
FieldBuilder builds a Field.
func NewField ¶
func NewField(segments ...string) *FieldBuilder
NewField starts a field at the given path segments. Segments are joined by the AIP-161 traversal operator, so NewField("metadata", "tags") declares the path metadata.tags.
func (*FieldBuilder) Bool ¶
func (b *FieldBuilder) Bool() *FieldBuilder
Bool types the field as a boolean.
func (*FieldBuilder) Duration ¶
func (b *FieldBuilder) Duration() *FieldBuilder
Duration types the field as a Go duration, such as 250ms or 2h30m.
func (*FieldBuilder) Filterable ¶
func (b *FieldBuilder) Filterable() *FieldBuilder
Filterable allows the field to be named in a filter.
func (*FieldBuilder) Float ¶
func (b *FieldBuilder) Float() *FieldBuilder
Float types the field as a 64-bit float.
func (*FieldBuilder) Implicit ¶
func (b *FieldBuilder) Implicit() *FieldBuilder
Implicit makes a bare filter value search this field, and implies FieldBuilder.Filterable. Only string fields may be implicit.
func (*FieldBuilder) Int ¶
func (b *FieldBuilder) Int() *FieldBuilder
Int types the field as a 64-bit signed integer.
func (*FieldBuilder) Ref ¶
func (b *FieldBuilder) Ref(ref string) *FieldBuilder
Ref sets the backend reference generated queries use. It defaults to the field's path, so only a ref that differs from it has to be declared.
Only assign trusted constants. An adapter may embed the ref directly into its query syntax; user input reaching it can become an injection vulnerability.
func (*FieldBuilder) Sortable ¶
func (b *FieldBuilder) Sortable() *FieldBuilder
Sortable allows the field to be named in an order_by.
func (*FieldBuilder) String ¶
func (b *FieldBuilder) String() *FieldBuilder
String types the field as text.
func (*FieldBuilder) Time ¶
func (b *FieldBuilder) Time() *FieldBuilder
Time types the field as an RFC 3339 timestamp.
func (*FieldBuilder) Unique ¶
func (b *FieldBuilder) Unique() *FieldBuilder
Unique declares that the field orders rows totally, and implies FieldBuilder.Sortable. Cursor pagination needs one such field to page without repeating or dropping rows, and a schema may declare at most one.
func (*FieldBuilder) Value ¶
func (b *FieldBuilder) Value[T any](fn func(T) any) *FieldBuilder
Value declares how to read the field off a row, which is what Schema.EncodeCursor addresses a row by. Only a field a cursor may order by needs one.
type OffsetPage ¶
OffsetPage is a page of rows and where it sits in the collection.
func NewOffsetPage ¶
func NewOffsetPage[T any](rows []T, page, pageSize int32, totalItems int64) (OffsetPage[T], error)
NewOffsetPage assembles the page a page-number query returned. A zero page number is the first page, as it is for Offset.
type Op ¶
type Op int
Op is the comparison a Cmp makes.
const ( OpEq Op = iota + 1 OpNe OpLt OpLe OpGt OpGe // OpLike matches a value against a pattern whose only metacharacters are // % and _, escaped by a backslash. It is what the AIP-160 has operator // compiles to, and what a string argument holding the * wildcard compiles // to whichever comparator it was written with. // // A client writes * and flop renders it as %. A literal * cannot be // searched for: the parser unescapes a quoted argument before the compiler // reads it, so \* and * arrive the same. OpLike )
type Schema ¶
type Schema struct {
// contains filtered or unexported fields
}
Schema is the set of fields one collection exposes.
func (*Schema) Compile ¶
Compile resolves a filter against the schema and coerces every argument to the Go value its field's type calls for.
A nil or empty filter compiles to a nil Expr, meaning match everything.
func (*Schema) CompileSeek ¶
CompileSeek compiles the row comparison that continues a page after pos into the same Expr tree a filter compiles to, so a backend renders seeking with the code it already renders filtering with.
A first page has no position and compiles to a nil Expr. The comparison is written out term by term rather than as a row value, so an order mixing ascending and descending fields still seeks correctly. Every ordering column must be NOT NULL: SQL comparisons against NULL are unknown, which would silently drop rows from the page.
func (*Schema) CursorPage ¶
func (s *Schema) CursorPage[T any]( rows []T, pageSize int32, order []aip132.OrderBy, filter *aip160.Filter, ) (CursorPage[T], error)
CursorPage assembles the page a cursor query returned: it splits the surplus row off and mints the token that continues after the last row of the page.
A query asks for pageSize+1 rows, so that the surplus row is there to report whether another page follows.
func (*Schema) DecodeCursor ¶
func (s *Schema) DecodeCursor( token string, order []aip132.OrderBy, filter *aip160.Filter, ) (CursorPosition, error)
DecodeCursor reads a page token back into the position it addresses.
order and filter are the ones the request arrived with. A token only decodes under the pair it was issued for, so a client cannot carry a position over to a different filter. An empty token is the first page: it yields a nil position and no error.
The order only has to name sortable fields here. That it also has to be total is Schema.CompileSeek's rule, because that is what needs it.
func (*Schema) EncodeCursor ¶
func (s *Schema) EncodeCursor( row any, order []aip132.OrderBy, filter *aip160.Filter, ) (string, error)
EncodeCursor renders the last row of a page as the token the next page continues from, bound to the order and filter the page was drawn under.
Every field the order names has to declare a FieldBuilder.Value, which is what reads the row. A row of another type than the one those accessors were declared for is a declaration mistake, not something a request can cause.
func (*Schema) FilterableField ¶
FilterableField returns the filterable field at path.
func (*Schema) ParseFilter ¶
ParseFilter parses an AIP-160 filter and validates it against the schema.
func (*Schema) ParseOrder ¶
ParseOrder parses an AIP-132 order_by clause and validates it against the schema.
A schema that declares a unique field has it appended when the clause does not already name it, so the order is total. Cursor pagination needs that to page without repeating or dropping rows.
func (*Schema) SortableField ¶
SortableField returns the sortable field at path.
func (*Schema) SortableFields ¶
SortableFields resolves each term of an order to the field it names, keeping the order's own indexing so a term's direction is read from it directly.
func (*Schema) TotalOrder ¶
TotalOrder appends the schema's unique field to an order that does not already name it, so that no two rows compare equal. A schema declaring no unique field returns the order unchanged.
Schema.ParseOrder applies it already. Call it directly when composing an order first, so that the tie-breaker ends up last:
order := schema.TotalOrder(flop.MergeOrder(defaultOrder, requested))
func (*Schema) UniqueField ¶
UniqueField returns the field declared unique, or nil if there is none.
func (*Schema) ValidateFilter ¶
ValidateFilter reports whether every restriction names a filterable field, uses an operator that field accepts, and carries a value of its type.
type SchemaBuilder ¶
type SchemaBuilder struct {
// contains filtered or unexported fields
}
SchemaBuilder builds a Schema.
func NewSchema ¶
func NewSchema(fields ...*FieldBuilder) *SchemaBuilder
NewSchema starts a schema holding the given fields.
func (*SchemaBuilder) Build ¶
func (b *SchemaBuilder) Build() (*Schema, error)
Build validates the declared fields and returns the schema.
func (*SchemaBuilder) MustBuild ¶
func (b *SchemaBuilder) MustBuild() *Schema
MustBuild is SchemaBuilder.Build for a schema declared at init, where a mistake is a programmer error rather than something to report.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package aip132 parses AIP-132 order_by clauses, with the field path support of AIP-161.
|
Package aip132 parses AIP-132 order_by clauses, with the field path support of AIP-161. |
|
Package aip160 parses AIP-160 filter expressions into an abstract syntax tree.
|
Package aip160 parses AIP-160 filter expressions into an abstract syntax tree. |
|
internal
|
|
|
assert
Package assert holds the assertions flop's tests make, so the module needs no test dependencies.
|
Package assert holds the assertions flop's tests make, so the module needs no test dependencies. |
|
Package rawsql renders a flop schema, filter and order as SQL text and named arguments, for callers assembling a query by hand.
|
Package rawsql renders a flop schema, filter and order as SQL text and named arguments, for callers assembling a query by hand. |