filtering

package
v11.2.0 Latest Latest
Warning

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

Go to latest
Published: Aug 18, 2026 License: AGPL-3.0 Imports: 11 Imported by: 0

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.

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.

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 `default` and `maximum` tags on QueryFilter describe to a caller that will never call it.

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

Examples

Constants

View Source
const (
	// MaxQueryFilterLimit is the maximum value for list queries. Anything larger
	// clamps to it.
	//
	// The field is uint16 rather than uint8 deliberately: 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.
	MaxQueryFilterLimit = 250
	// 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"
)

Variables

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

Functions

func QueryFilterSchema

func QueryFilterSchema() map[string]any

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, so 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's tags do not reflect. That can only be a malformed tag in this package, which is a compile-time property of a type this package owns and which TestQueryFilterSchema catches before it ships; 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/platform-go/v11/filtering"
	"github.com/primandproper/platform-go/v11/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   uint64 `json:"filteredCount"`
	TotalCount      uint64 `json:"totalCount"`
	MaxResponseSize uint16 `json:"maxResponseSize"`
	// contains filtered or unexported fields
}

Pagination represents a pagination request.

type QueryFilter

type QueryFilter struct {
	SortBy *string `` /* 190-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 `` /* 286-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 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 clamp from drifting apart.

`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.

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

func (qf *QueryFilter) SetCursor(cursor *string)

SetCursor sets the current page with certain constraints.

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.

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 NewQueryFilteredResult

func NewQueryFilteredResult[T any](
	data []*T,
	filteredCount,
	totalCount uint64,
	idExtractor func(*T) string,
	filter *QueryFilter,
) *QueryFilteredResult[T]

NewQueryFilteredResult creates a new QueryFilteredResult.

Jump to

Keyboard shortcuts

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