pgxaip rewrites a parsed AIP-160 filter,
an AIP-132 order_by, and an
optional keyset cursor into Postgres SQL fragments you splice into a
query by hand.
query := pgxaip.Query{
Filter: filter, // from filtering.ParseFilter
OrderBy: orderBy, // from ordering.ParseOrderBy
PageToken: pageToken, // from pagination.ParsePageToken (incl. Cursor)
Columns: columns, // AIP path -> DB column
}
where, order, args, err := query.Rewrite()where— the WHERE predicate (filter, cursor, or both ANDed). Empty when neither is present.order— thecol ASC, col DESClist, noORDER BYprefix. Empty whenOrderByhas no fields.args— positional bind values, numbered$1..$N. Filter literals first, then cursor values. Append your ownLIMIT/OFFSETat$N+1.
PageToken.Offset is not consulted by Rewrite; feed it into your
OFFSET clause yourself.
go get github.com/pgx-contrib/pgxaipfilter, _ := filtering.ParseFilter(req, BookFilterDeclarations)
orderBy, _ := ordering.ParseOrderBy(req)
pageToken, _ := pagination.ParsePageToken(req)
// Unified column map: union of the generated *FilterColumns and
// *OrderByColumns. Same path -> same column by construction, so the
// union is safe.
columns := map[string]string{}
for k, v := range BookFilterColumns {
columns[k] = v
}
for k, v := range BookOrderByColumns {
columns[k] = v
}
q := pgxaip.Query{
Filter: filter,
OrderBy: orderBy,
PageToken: pageToken,
Columns: columns,
}
where, order, args, err := q.Rewrite()Columns is the AIP-path → DB-column allow-list. Lookup is
fail-closed: any filter / order / cursor path that is not in
Columns causes Rewrite to return an error, so an unmapped field
can never leak into generated SQL. Pair with the *FilterColumns /
*OrderByColumns maps produced by
protoc-gen-go-aip-query
or hand-build a map[string]string for ad-hoc use.
| AIP filter | Postgres fragment |
|---|---|
=, !=, <, <=, >, >= |
col op $N (or col op col) |
AND, OR |
(lhs AND rhs) / (lhs OR rhs) |
NOT |
(NOT expr) |
name:"ali" |
"name" ILIKE '%' || $N || '%' |
timestamp("2025-01-02T03:04:05Z") |
$N bound as time.Time |
duration("1h30m") |
$N bound as time.Duration |
unary -<literal> |
bound as signed numeric literal |
When PageToken.Cursor is populated, Rewrite emits the standard
compound keyset predicate from OrderBy.Fields:
("name" < $1)
OR ("name" = $1 AND "id" > $2)Direction per field follows the OrderBy field's Desc flag
(ASC → >, DESC → <). len(PageToken.Cursor) must equal
len(OrderBy.Fields); mismatch is a validation error.
For a stable ordering, append a tiebreaker (the PK) to
OrderBy.Fields before calling Rewrite, and make sure
PageToken.Cursor carries a matching trailing value.
On the first page (PageToken.Cursor is empty) the cursor predicate
is omitted.
nix develop
go tool ginkgo run -r