Documentation
¶
Overview ¶
Package filtering is the shared vocabulary for list queries: which slice of a collection a caller asked for, and which slice they got.
A QueryFilter carries the request half — a cursor, a page size, a sort direction, created/updated time windows, and whether archived rows count — and round-trips through URL query parameters, so the same value is what a handler parses out of an *http.Request and what a client puts back on the wire. The query-parameter names are exported constants rather than string literals spelled out per handler, which is what keeps a client and a server agreeing on them. Pagination is the response half, and is what an API response embeds alongside its data to say what was applied and where the next page starts.
It builds no SQL and touches no database. This package decides what a caller asked for; translating that into a query belongs to whatever store answers it.
The two ends of that translation are here, because both are restatements of rules this package owns rather than of anything a store knows. ToSQLArgs converts a filter into the driver-typed values a filtered read binds — the nil default, the page-size clamp, and the seven conversions, applied once — and Drain turns the rows that come back into the QueryFilteredResult they are answered with. What stays with the store is the SQL and the row-to-domain conversion, which is the half that is genuinely per-table. A store that writes those two ends by hand writes them once per list query, and a copy that drops the archived flag or reads its counts off the wrong row is a copy that keeps working and answers wrongly.
Page size is clamped rather than rejected: a request for more than MaxQueryFilterLimit gets MaxQueryFilterLimit, and an absent one gets DefaultQueryFilterLimit. A parameter that is present and unreadable is a different matter, and parsing reports it — every parameter is still attempted and everything that parsed is applied, so the filter is always usable, but the error is there because a mistyped filter that is silently dropped answers with a plausible-looking page that excludes nothing. The handler decides which of those it wants; the parse does not decide for it.
A nil *QueryFilter is usable throughout: it renders as the default filter and says so when attached to a logger, so handlers need no nil check before passing one along.
Cursors are directional, and the two halves of a round trip do not carry the same one. A QueryFilter's Cursor is the page being asked for. The Pagination that answers it reports PreviousCursor as the cursor that reached this page and Cursor as the one that reaches the next, so an empty PreviousCursor is the first page. Nothing needs to compare the two against each other to work that out, and a caller that does is inferring a contract stated here.
The next cursor is the last row's identifier whenever the page held rows, so it is empty only for an empty page. It is not a "there is more" signal: a full page and the final page carry an equally non-empty Cursor, and the counts are what distinguish them.
The counts, in turn, are answerable or not, and Pagination says which. A store that counts by carrying the numbers along on the rows — so that the page and the number describing it cannot come from two different moments — has nothing to read them off when the page came back empty, and the zero it would report is not "nothing matched" but "no row to carry the number on". A caller walking a keyset to its end sees those two as the same 0. CountsKnown separates them, and Counts is the read that cannot skip the question.
Defaults and bounds apply to a filter however it arrived. Parsing one out of query parameters is only the transport that has a parser here; Normalize is the same rule for a filter decoded from anywhere else, and is what the schema describes to a caller that will never call it.
The page-size ceiling is a var rather than a constant, so a service that wants a different one is not held to platform's. MaxQueryFilterLimit says what setting it costs and when it may be set; the schema follows it, so raising the clamp raises what the type publishes about itself as well.
The page-size ceiling is the one bound Normalize cannot be left to apply on its own, because a decoder reaches uint16 before it reaches a QueryFilter and a page size that narrows before it is clamped wraps rather than clamps. SetMaxResponseSize takes the wide value a wire format actually carries and applies the ceiling in the order that works; ClampResponseSize is the same rule for a parser that has the number before it has a filter.
One transport has both a schema and a decoder here rather than only the rule. filtering/proto carries the .proto for QueryFilter and Pagination, shipped inside this module for a consumer's protoc to import rather than copy; filtering/filteringpb is the generated Go, and filtering/grpc converts between the two — the clamp before the narrowing and the default for an absent filter, written once instead of per service. They are subpackages so that this one takes on no protobuf runtime, exactly as it builds no SQL.
QueryFilterSchema describes the request half as JSON Schema, for the surfaces that ask for a filter in that dialect rather than in query parameters: a tool-calling model, an MCP tool definition, an OpenAPI document. It is reflected off the struct, so it is the one description of this type there is.
Index ¶
- Constants
- Variables
- func ClampResponseSize(size uint64) uint16
- func QueryFilterSchema() map[string]any
- type Pagination
- type QueryFilter
- func (qf *QueryFilter) AttachToLogger(logger logging.Logger) logging.Logger
- func (qf *QueryFilter) FromParams(params url.Values) error
- func (qf *QueryFilter) Normalize() error
- func (QueryFilter) PrepareJSONSchema(schema *jsonschema.Schema) error
- func (qf *QueryFilter) SetCursor(cursor *string)
- func (qf *QueryFilter) SetMaxResponseSize(size uint64)
- func (qf *QueryFilter) SortsDescending() bool
- func (qf *QueryFilter) ToPagination() Pagination
- func (qf *QueryFilter) ToValues() url.Values
- type QueryFilteredResult
- func Drain[Row, T any](rows []Row, convert func(Row) *T, counts func(Row) (filtered, total int64), ...) *QueryFilteredResult[T]
- func NewQueryFilteredResult[T any](data []*T, filteredCount, totalCount uint64, idExtractor func(*T) string, ...) *QueryFilteredResult[T]
- func NewQueryFilteredResultWithoutCounts[T any](data []*T, idExtractor func(*T) string, filter *QueryFilter) *QueryFilteredResult[T]
- type SQLArgs
Examples ¶
Constants ¶
const ( // DefaultQueryFilterLimit represents how many results we return in a response by default. DefaultQueryFilterLimit = 50 // QueryKeySearchWithDatabase is the query param key to find search queries in requests. QueryKeySearchWithDatabase = "useDB" // QueryKeyLimit is the query param key to specify a limit in a query. QueryKeyLimit = "limit" // QueryKeyCursor is the query param key for specifying which cursor to use in a list query. QueryKeyCursor = "cursor" // QueryKeyCreatedBefore is the query param key for a creation time limit in a list query. QueryKeyCreatedBefore = "createdBefore" // QueryKeyCreatedAfter is the query param key for a creation time limit in a list query. QueryKeyCreatedAfter = "createdAfter" // QueryKeyUpdatedBefore is the query param key for an updated time limit in a list query. QueryKeyUpdatedBefore = "updatedBefore" // QueryKeyUpdatedAfter is the query param key for an updated time limit in a list query. QueryKeyUpdatedAfter = "updatedAfter" // QueryKeyIncludeArchived is the query param key for including archived results in a query. QueryKeyIncludeArchived = "includeArchived" // QueryKeySortBy is the query param key for sort order in a query. QueryKeySortBy = "sortBy" )
const ( // ArgCursor is the keyset position a page resumes after — see // database/querygen's cursor predicate. `page_cursor` rather than `cursor` // because of the reserved word above. ArgCursor = "page_cursor" ArgResultLimit = "result_limit" ArgIncludeArchived = "include_archived" ArgCreatedAfter = "created_after" ArgCreatedBefore = "created_before" ArgUpdatedAfter = "updated_after" ArgUpdatedBefore = "updated_before" )
The SQL-side spelling of QueryFilter: the argument names a filtered read binds its window through.
They live here rather than beside the SQL that consumes them because both halves of a filtered read now read them from one place — database/querygen emits the statements that name these arguments and aliases these constants, and ToSQLArgs produces the values those arguments take. A new window argument is therefore added to this list once, and both the statement and the binding follow from it; two lists could disagree, and a binding keyed on a name no statement mentions binds nothing and filters nothing, which looks exactly like a filter nobody set.
The names are snake_case because that is what sqlc reads out of `sqlc.arg(created_after)` and what it derives a generated Go field from. They are not the URL parameter names — those are the QueryKey constants — and the two are deliberately allowed to differ, since one is a wire format and the other is a statement's vocabulary.
One of them is spelled around a dialect rather than for it. The keyset position would naturally be called `cursor`, and CURSOR is a reserved word in MySQL — so `sqlc.narg(cursor)` is a syntax error there, in MySQL's own parser, which is the parser sqlc reads these statements with. Quoting does not rescue it: the text inside the reference is the argument's name, not an identifier the engine resolves, and backticks would become part of the name. The alternative to renaming it is a generator that emits one name for two of the three dialects and another for the third, which is a difference every consumer of every dialect would then have to know about.
One field of a QueryFilter is deliberately absent from this list, and its absence is the point rather than an omission. SortBy names a direction, and a direction is not a value: it decides which way the ORDER BY runs and which way the cursor comparison points, both of which are statement text. So a store answers it by choosing between two statements rather than by binding a seventh argument — SortsDescending is that choice, and database/querygen emits the pair it chooses between. An argument name here would be one nothing could bind.
Variables ¶
var ( // SortAscending is the pre-determined Ascending string for external use. SortAscending = new(sortAscendingString) // SortDescending is the pre-determined Descending string for external use. SortDescending = new(sortDescendingString) )
var MaxQueryFilterLimit uint16 = 250
MaxQueryFilterLimit is the largest page a list query is answered with. Anything above it clamps to it, and ClampResponseSize is where that happens.
It is a var because the ceiling is a policy question and this module is the wrong place to answer it for everybody: a service whose rows are three narrow columns has no reason to be held to a number picked with somebody else's rows in mind. Set it once during initialization — before the first filter is parsed and before any schema is reflected — and leave it alone after that. Nothing here guards it, so a write racing a list request is a data race like any other.
The published bound follows it, which is the half that is easy to leave behind. QueryFilter carries no `maximum` struct tag, deliberately: a tag is fixed when this package compiles and this number is not, so a tag would go on promising 250 to every generated client and every tool-calling model while the clamp quietly enforced something else. PrepareJSONSchema writes the current value into every reflection of the type instead.
The type is uint16 rather than an untyped constant because that is the type of the field it bounds, which makes a ceiling too large to store in a page size a compile error at the assignment rather than a truncation at the clamp. The field is uint16 rather than uint8 for its own reason: at uint8 the ceiling sat five above the limit, so `maxResponseSize: 300` was an unmarshal error rather than the clamp every other over-limit value gets. ClampResponseSize still takes a uint64, and still has to.
DefaultQueryFilterLimit is deliberately not settable the same way. It is applied at runtime here, but it is also written into SQL at generate time — database/querygen emits `LIMIT COALESCE(sqlc.narg(result_limit), 50)` into files that are checked in — so a value changed in a running process would disagree with statements that shipped before it started.
Functions ¶
func ClampResponseSize ¶
ClampResponseSize is the page-size ceiling, and the only place it is applied.
It takes a uint64 because the ceiling has to be applied before the narrowing to uint16, not after. Every wire format narrows first if it is left to do it alone: protobuf has no uint16, so a page size crosses as a uint32; JSON hands a decoder a number; a query parameter hands it a string. All three reach *uint16 before there is a QueryFilter to hold one, which is before Normalize can see the value — and a narrowing that happens first is silent. A requested 70000 wraps to 4464, Normalize clamps that to MaxQueryFilterLimit, and the client receives a legible-looking answer to a question it did not ask. Normalize cannot catch it by construction, because by the time it runs 4464 is indistinguishable from a page size the client actually sent.
SetMaxResponseSize is this applied to a filter, and is what a decoder holding one should reach for, since a clamp that must be called is a clamp that can be forgotten. This is the bare function, for a parser that has the wide value before it has a filter to put it in.
Still clamped rather than rejected: MaxQueryFilterLimit documents an over-large limit as a clamp, and a client asking for more than the ceiling has asked a legible question with a legible answer. Zero is left alone here rather than filled in — FromParams distinguishes a supplied value from an absent one and Normalize is what supplies the default, so a clamp that also defaulted would take that distinction away from both.
func QueryFilterSchema ¶
QueryFilterSchema returns the JSON Schema for QueryFilter as a decoded document — the shape llm.Tool.Schema takes, which is also the shape an MCP tool definition takes, and the same object the OpenAPI spec describes this type with.
It is reflected off the struct rather than written out beside it, and that is why it lives here. A hand-written mirror of this type is a second copy that can be wrong: one such mirror described SortBy as the field to sort by rather than the direction to sort in, declared MaxResponseSize as an unbounded integer, and keyed on Go field names against camelCase tags. None of that was a mistake when it was written. The struct moved and the mirror did not, and nothing anywhere said so.
Everything the document asserts beyond the field types is a struct tag on QueryFilter — bar the page-size ceiling, which PrepareJSONSchema writes out of MaxQueryFilterLimit because that one is a var and a tag cannot follow one. Either way the constraints are written once for every reflector that reads them. The schema is therefore about the type and not about any one use of it: nothing here is MCP-shaped, or HTTP-shaped, and a caller wanting a filter described to a model and a caller generating a client both get this.
The map is freshly decoded on every call and the caller owns it outright. Merging these properties into a larger tool input, dropping the ones an endpoint does not honor, or tightening a bound is editing a private copy — which is the point, since a shared one would have every tool definition in a process editing the same document.
It panics if QueryFilter does not reflect. That can only be a malformed tag in this package or a PrepareJSONSchema that no longer recognizes the field it bounds, both of them properties of a type this package owns and both caught by TestQueryFilterSchema before they ship; an error return would put an impossible branch at every call site instead, and a tool registry would spend its own error path on it forever.
Example ¶
A tool that lists something takes a page of a collection as its input, which is what a QueryFilter is. Handing the model this rather than a description of it written out beside the tool is what keeps the two from drifting: the enum, the bounds, and the property names are the struct's own tags.
package main
import (
"fmt"
"github.com/primandproper/primitives-go/v2/filtering"
"github.com/primandproper/primitives-go/v2/llm"
)
func main() {
tool := llm.Tool{
Name: "list_recipes",
Description: "List the caller's recipes.",
Schema: filtering.QueryFilterSchema(),
}
properties, _ := tool.Schema["properties"].(map[string]any)
// A tool whose endpoint honors only some of these drops the rest. The map
// is this caller's own copy, so doing that affects nobody else's.
delete(properties, "includeArchived")
sortBy, _ := properties["sortBy"].(map[string]any)
size, _ := properties["maxResponseSize"].(map[string]any)
fmt.Printf("%s: %s\n", tool.Name, tool.Description)
fmt.Println("sortBy:", sortBy["enum"])
fmt.Println("maxResponseSize:", size["minimum"], "to", size["maximum"], "defaulting to", size["default"])
}
Output: list_recipes: List the caller's recipes. sortBy: [asc desc] maxResponseSize: 0 to 250 defaulting to 50
Types ¶
type Pagination ¶
type Pagination struct {
// AppliedQueryFilter is the filter this page was answered with, after
// defaults and bounds were applied — not necessarily the one the client
// sent.
AppliedQueryFilter *QueryFilter `json:"appliedQueryFilter"`
// Cursor reaches the page after this one. It is the last row's
// identifier, so it is empty only when this page held no rows and says
// nothing about whether a further page exists.
Cursor string `json:"cursor"`
// PreviousCursor is the cursor that reached this page, echoed back from
// the filter that was applied. It is empty on the first page, which is
// how the first page is recognized.
PreviousCursor string `json:"previousCursor"`
// FilteredCount is how many rows matched the filter and TotalCount how
// many were in scope regardless of it. Neither describes this page:
// they describe the collection it was cut from, which is why they do
// not shrink as a caller walks it.
//
// Both mean nothing unless CountsKnown is set. Counts is the accessor
// that hands them over with that fact attached.
FilteredCount uint64 `json:"filteredCount"`
TotalCount uint64 `json:"totalCount"`
MaxResponseSize uint16 `json:"maxResponseSize"`
// CountsKnown reports whether the counts above were answered at all.
//
// They are plain integers, so an unanswered pair reads as 0 and 0 —
// which is also what a collection with nothing in it reads as, and
// nothing else here tells those apart. A store whose counts ride along
// on the rows, handed a page that came back empty, has no row to read
// them off; a caller walking a keyset therefore sees FilteredCount go
// 5, 5, 0, and the last of those is not a result. A UI rendering "0
// results" off the final page of a walk is the obvious way to get this
// wrong, and it looks correct in every test that does not page to the
// end.
//
// False is the zero value, so a Pagination assembled as a literal
// vouches for nothing until it says otherwise, and ToPagination — which
// is built from a request, where there are no counts yet — leaves it
// alone. NewQueryFilteredResult sets it, because a caller that passed
// counts in has answered them. A caller with none to pass has
// NewQueryFilteredResultWithoutCounts rather than a zero that means
// something else.
CountsKnown bool `json:"countsKnown"`
// contains filtered or unexported fields
}
Pagination represents a pagination request.
func (*Pagination) Counts ¶
func (p *Pagination) Counts() (filtered, total uint64, known bool)
Counts returns how many rows matched the filter, how many were in scope regardless of it, and whether either number was answered at all.
The third value is the reason this method exists. FilteredCount and TotalCount are plain integers, so a caller reading them off the struct gets 0 and 0 whether the collection is empty or the counts were never answered, and has no prompt to wonder which. Taking them from here makes that a value the caller has to name, and an unanswered pair comes back as zeroes rather than as whatever happens to be sitting in the fields.
A nil Pagination has no counts, like every other nil in this package.
type QueryFilter ¶
type QueryFilter struct {
SortBy *string `` /* 372-byte string literal not displayed */
CreatedAfter *time.Time `description:"Only rows created after this instant." json:"createdAfter,omitempty" nullable:"false"`
CreatedBefore *time.Time `description:"Only rows created before this instant." json:"createdBefore,omitempty" nullable:"false"`
UpdatedAfter *time.Time `description:"Only rows last updated after this instant." json:"updatedAfter,omitempty" nullable:"false"`
UpdatedBefore *time.Time `description:"Only rows last updated before this instant." json:"updatedBefore,omitempty" nullable:"false"`
MaxResponseSize *uint16 `` /* 272-byte string literal not displayed */
IncludeArchived *bool `` /* 126-byte string literal not displayed */
Cursor *string `` /* 182-byte string literal not displayed */
// contains filtered or unexported fields
}
QueryFilter represents all the filters a User could apply to a list query.
The tags beyond `json` are the type's JSON Schema. QueryFilterSchema reflects them, and so does the OpenAPI reflector routing runs, so what a generated client is told about a filter and what a model on the other end of a tool call is told about one come from the same place and cannot disagree. Nothing about the schema is written out anywhere else — that is the point, because a second copy of this struct can be wrong and nothing would say so.
The numbers here are literals because a struct tag cannot name a constant. TestQueryFilterSchema_Bounds ties each one back to the constant it repeats, which is what keeps the tag and the code from drifting apart.
MaxResponseSize's ceiling is the one number that is not here at all. A tag cannot name a constant, but it also cannot hold a value a consumer can change, and MaxQueryFilterLimit is a var — so `maximum` is written by PrepareJSONSchema, out of the var itself, on every reflection of this type. `minimum` stays a tag, because zero is not a policy question.
`nullable:"false"` is on every field because these are optional, not nullable: an absent one filters nothing, and none of them is ever emitted as null — `omitempty` sees to that. Left alone the reflector reads the pointer and offers null as a value, which on SortBy would have contradicted its own enum.
The fields are separated by blank lines because tagalign pads a run of adjacent ones out to the longest tag in it, and one description long enough to be worth writing puts a hundred spaces in front of every other field's `json`.
func DefaultQueryFilter ¶
func DefaultQueryFilter() *QueryFilter
DefaultQueryFilter builds the default query filter.
func ExtractQueryFilterFromRequest ¶
func ExtractQueryFilterFromRequest(req *http.Request) (*QueryFilter, error)
ExtractQueryFilterFromRequest extracts a QueryFilter from a request, reporting any query parameter that was supplied and could not be read.
The filter is always usable — it starts from DefaultQueryFilter and holds whatever parsed — so a handler that wants the old lenient behavior can log the error and list anyway. One that would rather not answer a mistyped filter with a plausible-looking page has an error wrapping errors.ErrUnrecognizedInputValue, which errors/http already renders as a 400.
func (*QueryFilter) AttachToLogger ¶
func (qf *QueryFilter) AttachToLogger(logger logging.Logger) logging.Logger
AttachToLogger attaches a QueryFilter's values to a logging.Logger.
The values, not the pointers holding them. Every field on a filter is optional and therefore a pointer, and a *string or a *uint16 handed to a logging backend as an `any` renders as an address wherever that backend falls back to fmt rather than special-casing the type behind the pointer — so the cursor a page was read with reaches the line as 0xc000123456. A filter's values in a log are the debugging surface this package exists to standardize, which makes the nil check that decides whether a field reaches the line the place to dereference it too.
The set of fields is ToValues's, IncludeArchived included: the two are the same filter written for two readers, and a field that reaches the wire and not the log is one that has to be inferred from the rows that came back.
func (*QueryFilter) FromParams ¶
func (qf *QueryFilter) FromParams(params url.Values) error
FromParams overrides the core QueryFilter values with values retrieved from url.Params, reporting any parameter that was supplied and could not be read.
An absent parameter is not a failure — the filter simply keeps whatever it already held. A parameter that is present and unreadable is, and that is the distinction the method exists to draw. It used to make no distinction at all: `limit=fifty` and `createdAfter=yesterday` parsed to an error that was discarded, and the caller got an unfiltered list that looked exactly like a filtered one with nothing excluded. The person who notices is whoever reconciles the numbers a week later.
Every parameter is attempted, so one bad value does not hide the next; the returned error joins all of them. Whatever did parse is applied, which makes an ignored error behave as the old method did — but a caller reporting the failure to a client should discard the filter rather than list against a half-applied one.
func (*QueryFilter) Normalize ¶
func (qf *QueryFilter) Normalize() error
Normalize applies the defaults and bounds a filter is answered under, so a filter that did not arrive as query parameters is held to the same rule as one that did.
FromParams is the parser for one transport. This is the part that is not about transport at all: an absent or zero page size becomes DefaultQueryFilterLimit, an over-large one clamps to MaxQueryFilterLimit, and an absent sort direction becomes SortAscending. A decoder for protobuf, a JSON body, or a tool call reaches the same filter the HTTP path would have produced without restating any of those numbers.
A sort direction that is present and unrecognized is reported rather than corrected, wrapping errors.ErrUnrecognizedInputValue exactly as FromParams does — the filter is still usable and still normalized, because the caller that logs and lists anyway should get the ascending page rather than none, but the value is not quietly turned into one the caller did not ask for. That is the failure this package is most careful about: the list comes back sorted the other way, in full, and looks entirely successful.
A nil filter normalizes to nothing, since a nil *QueryFilter already renders as the default filter everywhere it is read.
func (QueryFilter) PrepareJSONSchema ¶
func (QueryFilter) PrepareJSONSchema(schema *jsonschema.Schema) error
PrepareJSONSchema writes the current page-size ceiling into every reflection of this type.
MaxQueryFilterLimit is a var, so `maximum` cannot be a struct tag: a tag is fixed when this package compiles and the ceiling is not, so a service that raised it would go on publishing 250 to every client generated off this type while clamping somewhere else. This is the hook a swaggest reflector calls once it has built the object schema, which makes it the one place the bound is written — for QueryFilterSchema here, and equally for the openapi-go reflector routing runs over a consumer's own request and response types, where this package has no call of its own to patch afterwards.
A missing property is an error rather than a silent no-op. It can only mean the `json` tag was renamed out from under this function, and an unbounded MaxResponseSize published to a generated client is exactly the failure the schema is reflected to avoid — so it surfaces as a panic out of QueryFilterSchema and a registration error out of routing, either of which TestQueryFilterSchema_Bounds catches first.
func (*QueryFilter) SetCursor ¶
func (qf *QueryFilter) SetCursor(cursor *string)
SetCursor sets the current page with certain constraints.
func (*QueryFilter) SetMaxResponseSize ¶
func (qf *QueryFilter) SetMaxResponseSize(size uint64)
SetMaxResponseSize sets the page size from the wide type a wire format actually carries, clamping before the narrowing to uint16 rather than after.
This is the setter a decoder wants, and it takes a uint64 so that there is no order left to get wrong: assigning MaxResponseSize directly means narrowing first, which is the silent wrap ClampResponseSize describes.
Zero is stored as zero rather than replaced with the default, exactly as ClampResponseSize leaves it alone — Normalize is what supplies the default, and it reads a zero page size as an absent one.
Example ¶
A decoder for a wire format reaches its page size as something wider than a uint16, because no wire format has one: protobuf carries a uint32, JSON hands a decoder a number, a query parameter hands it a string. Narrowing that to the field's type before the ceiling is applied wraps rather than clamps, and the wrapped value is indistinguishable from one the client actually sent. SetMaxResponseSize takes the wide value, so there is no order left to get wrong.
package main
import (
"fmt"
"github.com/primandproper/primitives-go/v2/filtering"
)
func main() {
// What a generated protobuf message hands a converter.
var maxResponseSize uint32 = 70000
qf := &filtering.QueryFilter{}
qf.SetMaxResponseSize(uint64(maxResponseSize))
// Narrowing first would have produced 4464, which Normalize then clamps to
// 250 — a legible-looking page size nobody asked for, raised nowhere.
fmt.Println("clamped first:", *qf.MaxResponseSize)
fmt.Println("narrowed first:", uint16(maxResponseSize))
}
Output: clamped first: 250 narrowed first: 4464
func (*QueryFilter) SortsDescending ¶
func (qf *QueryFilter) SortsDescending() bool
SortsDescending reports whether this filter asks for the newest-first page.
It is the whole of what SortBy means to a store, and the shape of the answer is not an accident. The direction is not a value a statement binds — no query in this module has an argument for it, and the ORDER BY and the cursor comparison it decides are statement text rather than parameters — so what a store does with a direction is choose between two statements that were written down in both directions. database/querygen emits that pair for every paged list; this is the read that picks one of them.
Anything that is not "desc" is ascending, matching the reading Normalize applies: an unrecognized direction is reported there, where a caller asked for the filter to be checked, and is answered here with the ascending page rather than with a third behavior nobody described. A nil filter and an absent SortBy are ascending for the same reason — that is what DefaultQueryFilter holds.
The comparison folds case because both parsers do, and for the same reason: "DESC" is what a hand-written client sends about as often as "desc".
func (*QueryFilter) ToPagination ¶
func (qf *QueryFilter) ToPagination() Pagination
ToPagination returns a Pagination from a QueryFilter.
The Cursor it carries is the requested one, because a filter on its own does not know where the next page starts. NewQueryFilteredResult is what moves it to PreviousCursor and fills Cursor from the data, so a Pagination built here and returned directly reports the request rather than the result.
It leaves CountsKnown false for the same reason: a request has no counts on it, and the zeroes this returns are the absence of an answer rather than one.
func (*QueryFilter) ToValues ¶
func (qf *QueryFilter) ToValues() url.Values
ToValues returns a url.Values from a QueryFilter.
type QueryFilteredResult ¶
type QueryFilteredResult[T any] struct { Data []*T `json:"data"` Pagination // contains filtered or unexported fields }
func Drain ¶
func Drain[Row, T any]( rows []Row, convert func(Row) *T, counts func(Row) (filtered, total int64), id func(*T) string, filter *QueryFilter, ) *QueryFilteredResult[T]
Drain turns the rows a filtered read returned into the QueryFilteredResult it answers with: the page, the counts riding along on it, and the cursor that reaches the next one.
It exists because the loop that does this is four lines with three separable ways to be quietly wrong, and every list query writes it again. The counts come off the first row rather than being reassigned per row — the windowed count is identical on every row, so a per-row assignment is correct by accident rather than by construction. The page is an empty slice rather than a nil one when nothing matched, so the JSON shape of an empty page does not depend on which store answered. And an empty page reports its counts as unknown rather than as zero, because a store whose counts ride along on the rows has no row to read them off and the zero it would otherwise report is "no row to carry the number on" rather than "nothing matched" — the ambiguity Pagination.CountsKnown exists to remove.
counts may be nil, for a read whose statement carries no counts at all; the result then reports unknown counts however many rows came back. A caller with counts from a separate query has NewQueryFilteredResult and should pass them there, where saying so is the point.
convert is the per-table half and stays the caller's: turning a generated row struct into a domain type is the one part of this that is genuinely about the table. It must not return nil — id is called on the last converted value to derive the next cursor.
Example ¶
Turning those rows into the page an endpoint answers with is the other end of the same query. The conversion from a row to a domain type stays here, because that is the half that is genuinely about this table; the loop, the counts, and the cursor do not.
package main
import (
"fmt"
"github.com/primandproper/primitives-go/v2/filtering"
)
// listRecipesRow stands in for the row a list query returns: the columns, plus
// the two windowed counts the same statement carried along so that the page and
// the numbers describing it come from one moment.
type listRecipesRow struct {
ID string
Name string
FilteredCount int64
TotalCount int64
}
type recipe struct {
ID string
Name string
}
func main() {
rows := []listRecipesRow{
{ID: "recipe_001", Name: "gruel", FilteredCount: 2, TotalCount: 40},
{ID: "recipe_002", Name: "porridge", FilteredCount: 2, TotalCount: 40},
}
page := filtering.Drain(
rows,
func(r listRecipesRow) *recipe { return &recipe{ID: r.ID, Name: r.Name} },
func(r listRecipesRow) (filtered, total int64) { return r.FilteredCount, r.TotalCount },
func(r *recipe) string { return r.ID },
filtering.DefaultQueryFilter(),
)
filtered, total, known := page.Counts()
fmt.Println("rows:", len(page.Data))
fmt.Println("counts:", filtered, total, known)
// The cursor reaching the next page is the last row's identifier. It is not
// a "there is more" signal — the counts are what say that.
fmt.Println("next cursor:", page.Cursor)
}
Output: rows: 2 counts: 2 40 true next cursor: recipe_002
func NewQueryFilteredResult ¶
func NewQueryFilteredResult[T any]( data []*T, filteredCount, totalCount uint64, idExtractor func(*T) string, filter *QueryFilter, ) *QueryFilteredResult[T]
NewQueryFilteredResult creates a new QueryFilteredResult from a page and the counts describing the collection it was cut from.
Passing the counts in is the caller answering them, so the result reports CountsKnown. That is the contract of this constructor rather than an inference from the data: a store that ran its own COUNT knows the collection holds nothing and says 0 to mean it, and the empty page it returns alongside must not read as an unanswered one.
A caller that cannot answer them — a store whose counts ride along on the rows, handed a page with no rows — has NewQueryFilteredResultWithoutCounts, and should reach for it rather than passing 0 to mean "no idea".
func NewQueryFilteredResultWithoutCounts ¶
func NewQueryFilteredResultWithoutCounts[T any]( data []*T, idExtractor func(*T) string, filter *QueryFilter, ) *QueryFilteredResult[T]
NewQueryFilteredResultWithoutCounts creates a QueryFilteredResult for a caller with no counts to report, leaving CountsKnown false.
The page, the cursors and the applied filter are assembled exactly as NewQueryFilteredResult assembles them; only the two numbers are withheld. It is for the store that reads its counts off the rows — the shape database/querygen emits, where both counts are scalar subqueries in the SELECT list so that the page and the numbers describing it come from one statement at one moment. That store has nothing to scan when the page comes back empty, and this is how it says so instead of reporting a zero the caller would read as "there are none".
type SQLArgs ¶
type SQLArgs struct {
// CreatedAfter and CreatedBefore bound the row's creation time; an invalid
// one is an open end of the window.
CreatedAfter sql.NullTime
CreatedBefore sql.NullTime
// UpdatedAfter and UpdatedBefore bound the row's last update. The column
// they compare against is NULL until the row is first edited, which the
// emitted predicate admits explicitly.
UpdatedAfter sql.NullTime
UpdatedBefore sql.NullTime
// Cursor is the keyset position the page resumes after. Invalid is the
// first page.
Cursor sql.NullString
// ResultLimit is the page size, always valid — see ToSQLArgs.
ResultLimit sql.NullInt32
// IncludeArchived admits soft-deleted rows. Invalid reads as false, which
// is what the emitted COALESCE makes of it.
//
// This is the field a hand-written params literal is likeliest to leave
// out, and leaving it out is silent: the query still runs, still returns
// rows, and serves archived ones to a filter that asked for live ones.
// Nothing fails and nothing logs.
IncludeArchived sql.NullBool
}
SQLArgs is a QueryFilter's window in the types a database driver takes: the seven values a filtered read binds, converted once.
It is a plain struct with no method a caller has to find, because what the caller does with it is copy its fields across into whatever params struct their query generator produced. That is the whole design: sqlc names those fields and this package does not get to, so requiring a consumer's generated struct to embed a platform type — or to be any particular shape at all — would strand every consumer whose generator disagrees. Seven assignments from a value that already holds the right things is what is left, and the seven conversions stop being seven decisions.
Every field is nullable because absence is what an unset filter field means, and the emitted predicates coalesce a NULL bound to a horizon that admits everything. The exception is ResultLimit, which ToSQLArgs always fills — see ToSQLArgs's own comment for why an absent page size is answered here rather than left to the statement.
func ToSQLArgs ¶
func ToSQLArgs(filter *QueryFilter) SQLArgs
ToSQLArgs converts a QueryFilter into the driver-typed values a filtered read takes, applying the nil-default and the page-size clamp once.
A nil filter binds the defaults, so a caller that took none still produces a bindable statement rather than a nil dereference three frames further down.
A caller whose driver takes its arguments by name rather than a generated struct by field keys these same values on the Arg constants above, which are the names database/querygen's emitted statements bind them under. Building that map is the caller's, because the one a keyed read hands over carries its own match columns alongside these and there is no version of it this package could hand back whole.
ResultLimit is always valid. An absent page size becomes DefaultQueryFilterLimit here rather than being left as a NULL for the statement to coalesce, because only two of the three dialects can express that coalesce — MySQL takes a placeholder after LIMIT and nothing else, and what a NULL gets there is an empty page. Answering absence here means the value is the same number on every server, read from the same constant the emitted COALESCE reads.
A page size that is present and over the ceiling is clamped to MaxQueryFilterLimit, which is the treatment MaxQueryFilterLimit documents and the treatment a URL parameter already gets. The clamp is applied before the narrowing to the driver's int32 rather than after, which is the ordering that matters: narrowing first turns an over-large limit into a legible-looking wrong answer.
A page size that is present and zero is left alone, and returns no rows. That is the loud reading of an explicit zero, and only absence is defaulted here — the same distinction ClampResponseSize draws, and the reason defaulting a zero belongs to Normalize, where a caller has asked for it.
The times are bound as timestamps, which is what a server with a timestamp type takes. SQLite has neither a timestamp type nor a driver that speaks time.Time, and its comparisons over a text column are lexicographic — so a SQLite store binds its window through the generated querier, which shapes a time for the dialect it was generated for. Binding these values there would produce a window that admits every row for every bound, which is indistinguishable from a caller who set no window at all.
Example ¶
A list query binds its window from a filter, and the seven conversions that takes are the same seven every time. The arguments the query is keyed on are the caller's own — ToSQLArgs does not know about them and does not touch them.
package main
import (
"database/sql"
"fmt"
"github.com/primandproper/primitives-go/v2/filtering"
)
// listRecipesParams stands in for the params struct a query generator emits.
// sqlc names these fields off the arguments in the .sql file, so a consumer's
// looks like this without platform having any say in it — which is why ToSQLArgs
// hands back values to copy across rather than a type the generated struct
// would have to embed.
type listRecipesParams struct {
CreatedAfter sql.NullTime
CreatedBefore sql.NullTime
UpdatedAfter sql.NullTime
UpdatedBefore sql.NullTime
BelongsToUser string
Cursor sql.NullString
ResultLimit sql.NullInt32
IncludeArchived sql.NullBool
}
// listRecipes stands in for the generated query method the params struct is
// handed to. A real one takes a context and a connection and returns rows; this
// one reports the window it was given.
func listRecipes(params *listRecipesParams) string {
return fmt.Sprintf("owner=%s limit=%d includeArchived=%v createdAfter=%v",
params.BelongsToUser,
params.ResultLimit.Int32,
params.IncludeArchived.Valid,
params.CreatedAfter.Valid,
)
}
func main() {
filter := &filtering.QueryFilter{MaxResponseSize: new(uint16(1_000))}
// A page size above the ceiling is answered with the ceiling rather than
// rejected, and the clamp lands before the narrowing to the driver's type.
// An unset field stays a NULL, which the emitted predicates coalesce to a
// bound that admits everything.
args := filtering.ToSQLArgs(filter)
fmt.Println(listRecipes(&listRecipesParams{
CreatedAfter: args.CreatedAfter,
CreatedBefore: args.CreatedBefore,
UpdatedAfter: args.UpdatedAfter,
UpdatedBefore: args.UpdatedBefore,
Cursor: args.Cursor,
ResultLimit: args.ResultLimit,
IncludeArchived: args.IncludeArchived,
BelongsToUser: "user_001",
}))
}
Output: owner=user_001 limit=250 includeArchived=false createdAfter=false
Directories
¶
| Path | Synopsis |
|---|---|
|
Package grpc is the wire conversion for filtering's two types: the QueryFilter a caller sends and the Pagination they are answered with, to and from the generated messages in filtering/filteringpb.
|
Package grpc is the wire conversion for filtering's two types: the QueryFilter a caller sends and the Pagination they are answered with, to and from the generated messages in filtering/filteringpb. |