flop

package module
v0.0.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 24, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

README

flop

ci codecov Go Reference

Declarative AIP-132 ordering, AIP-160 filtering, and cursor or page-number pagination for Go 1.27+

Package Contents
flop schema, filter compiler, ordering, cursors, page numbers
flop/aip160 AIP-160 filter parser and syntax tree
flop/aip132 AIP-132 order_by parser
flop/rawsql SQL text and named arguments
flop/flopsq squirrel query builders

Schema

var payments = flop.NewSchema(
	flop.NewField("id").String().Unique().Value(func(p payment) any { return p.ID }),
	flop.NewField("amount").Int().Filterable().Sortable(),
	flop.NewField("captured_at").Time().Filterable().Sortable(),
	flop.NewField("processing_time").Duration().Filterable().Sortable(),
	flop.NewField("provider").String().Filterable().Implicit(),
	flop.NewField("tenant_id").Ref("t.id").String().Filterable(),
).MustBuild()

A field has a public path clients write, and a backend Ref that defaults to it.

declaration meaning
Filterable() may be named in a filter
Sortable() may be named in an order_by
Unique() tie-breaker for cursor paging; implies Sortable(), at most one per schema
Implicit() a bare filter value searches this field; implies Filterable(), string fields only
Value(fn) how a row supplies this field to a cursor

Filtering

AIP-160, grammar

filter, err := payments.ParseFilter(r.FormValue("filter"))
filter: amount >= 1000 AND provider = "stripe"
sql:    ((amount >= @amount_1) AND (provider = @provider_2))
args:   amount_1=1000 provider_2=stripe
type operators
string = != :
int, float, time, duration = != < <= > >=
bool = !=

time takes quoted RFC 3339, duration takes Go syntax (250ms, 2h30m). A * in a string argument makes it a LIKE pattern, null becomes IS NULL.

Ordering

AIP-132

order, err := payments.ParseOrder(r.FormValue("order_by"))
order = payments.TotalOrder(flop.MergeOrder(defaultOrder, order))
order_by: captured_at desc, amount
sql:      captured_at DESC, amount, id

Ascending unless followed by desc; the unique field is appended to make the order total.

Cursor pagination

AIP-158

after, err := payments.DecodeCursor(req.Cursor, order, filter)

q, err := flopsq.CursorQuery(base, payments, order, filter, after, size, req.Skip)
rows := query(q)

page, err := payments.CursorPage(rows, size, order, filter)
// page.Items, page.NextCursor

Page-number pagination

offset, err := flop.Offset(req.Page, size)
q, err := flopsq.OffsetQuery(base, payments, order, filter, req.Page, size)

page, err := flop.NewOffsetPage(items, req.Page, size, total)
// page.Items, page.Page, page.TotalPages, page.TotalItems

Backends

Schema.Compile and Schema.CompileSeek turn a filter and a cursor position into a tree of And, Or, Not and Cmp a backend walks.

flopsq builds squirrel expressions:

base := psql.Select("id", "amount").From("payments").
	Where(squirrel.Eq{"tenant_id": req.Tenant})

q, err := flopsq.CursorQuery(base, payments, order, filter, after, size, skip)

rawsql renders keyword-less SQL fragments and named arguments; use one Builder per query. An empty filter or a first page renders as "":

b := rawsql.NewBuilder()
where, err := b.Where(payments, filter)
seek, err := b.Seek(payments, order, after)
orderBy, err := rawsql.OrderBy(payments, order)

sql := "SELECT id, amount FROM payments WHERE tenant_id = @tenant"
if where != "" {
	sql += " AND " + where
}
if seek != "" {
	sql += " AND " + seek
}
sql += " ORDER BY " + orderBy

args := b.Args()
args["tenant"] = req.Tenant

rows, err := db.Query(ctx, sql, pgx.NamedArgs(args))

Credits

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

Constants

This section is empty.

Variables

View Source
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

func MergeOrder(def, order []aip132.OrderBy) []aip132.OrderBy

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

func Offset(page, pageSize int32) (int64, error)

Offset returns the row offset of a one-based page number. A zero page number selects the first page.

func TotalPages

func TotalPages(totalItems int64, pageSize int32) int64

TotalPages returns how many pages of pageSize rows totalItems fills.

Types

type And

type And struct{ Exprs []Expr }

And matches when every operand matches.

type Cmp

type Cmp struct {
	Field *Field
	Op    Op
	Value any
}

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

type CursorPage[T any] struct {
	Items      []T
	NextCursor string
}

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.

func (*Field) Path

func (f *Field) Path() aip132.FieldPath

Path returns the field path clients name the field by.

func (*Field) Ref

func (f *Field) Ref() string

Ref returns the backend reference generated queries use for the field.

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 Not

type Not struct{ Expr Expr }

Not inverts its operand.

type OffsetPage

type OffsetPage[T any] struct {
	Items      []T
	Page       int32
	TotalPages int64
	TotalItems int64
}

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
)

func (Op) String

func (o Op) String() string

type Or

type Or struct{ Exprs []Expr }

Or matches when any operand matches.

type Schema

type Schema struct {
	// contains filtered or unexported fields
}

Schema is the set of fields one collection exposes.

func (*Schema) Compile

func (s *Schema) Compile(filter *aip160.Filter) (Expr, error)

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

func (s *Schema) CompileSeek(order []aip132.OrderBy, pos CursorPosition) (Expr, error)

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) Fields

func (s *Schema) Fields() []*Field

Fields returns the declared fields in declaration order.

func (*Schema) FilterableField

func (s *Schema) FilterableField(path aip132.FieldPath) (*Field, error)

FilterableField returns the filterable field at path.

func (*Schema) ParseFilter

func (s *Schema) ParseFilter(text string) (*aip160.Filter, error)

ParseFilter parses an AIP-160 filter and validates it against the schema.

func (*Schema) ParseOrder

func (s *Schema) ParseOrder(text string) ([]aip132.OrderBy, error)

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

func (s *Schema) SortableField(path aip132.FieldPath) (*Field, error)

SortableField returns the sortable field at path.

func (*Schema) SortableFields

func (s *Schema) SortableFields(order []aip132.OrderBy) ([]*Field, error)

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

func (s *Schema) TotalOrder(order []aip132.OrderBy) []aip132.OrderBy

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

func (s *Schema) UniqueField() *Field

UniqueField returns the field declared unique, or nil if there is none.

func (*Schema) ValidateFilter

func (s *Schema) ValidateFilter(filter *aip160.Filter) error

ValidateFilter reports whether every restriction names a filterable field, uses an operator that field accepts, and carries a value of its type.

func (*Schema) ValidateOrder

func (s *Schema) ValidateOrder(order []aip132.OrderBy) error

ValidateOrder reports whether every term names a distinct sortable field.

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL