Documentation
¶
Index ¶
- Constants
- Variables
- func ApplySortToQuery(qb *query.QueryBuilder, sorts []ParsedSort)
- func ApplyToCountQuery(cb *query.CountBuilder, filters []ParsedFilter)
- func ApplyToQuery(qb *query.QueryBuilder, filters []ParsedFilter)
- func BracketKey(name string, op FilterOp) string
- func CheckOpType(field string, op FilterOp, t schema.FieldType) error
- func CoerceValue(field string, t schema.FieldType, raw string) (any, error)
- func EscapeLikePattern(v string) string
- func IsAmbiguousKey(key string, named func(string) bool) bool
- func IsBracketKey(key string) bool
- func LikeArg(value string) string
- func LikeClause(col string, n int, value string) (string, string)
- func OpSuitsType(op FilterOp, t schema.FieldType) bool
- func RepeatedFilterError(key string, count int) error
- func SplitINValues(values []string) []string
- func SplitINValuesBounded(values []string, max int) (parts []string, total int)
- func SuffixKey(name string, op FilterOp) string
- func TypedBind(t schema.FieldType, raw string) any
- type Condition
- type FilterOp
- type FilterOption
- type FilterSuffixOp
- type KeySpeller
- type ParsedFilter
- func (f ParsedFilter) BindValue() any
- func (f ParsedFilter) Coerced(t schema.FieldType) ParsedFilter
- func (f ParsedFilter) InList(key string) ParsedFilter
- func (f ParsedFilter) Key() string
- func (f ParsedFilter) SameINList(g ParsedFilter) bool
- func (f ParsedFilter) Typed(t schema.FieldType) (ParsedFilter, error)
- type ParsedSort
- type Predicate
Constants ¶
const LikeEscapeSuffix = ` ESCAPE '\'`
LikeEscapeSuffix is the SQL that must follow a LIKE placeholder for EscapeLikePattern's escaping to take effect.
const MaxINListEntries = 1000
MaxINListEntries bounds the number of values a single ?field_in=… parameter can expand to, counting repeated occurrences of the parameter as one list. Generous for legitimate use (most DBs cap IN-lists at a few thousand parameters) but small enough that an adversarial 10K-element list can't drive memory or statement-cache growth. Exposed so sibling surfaces (nested ?rel.field_in=) enforce the same cap instead of growing a private one.
const MaxSearchTerms = 8
MaxSearchTerms bounds the number of tokens a single ?q= value expands to. A search term is one whitespace-delimited token; beyond this cap, extra tokens are dropped. Keeps statement size bounded against an adversarial input.
Variables ¶
BracketOps lists the operator words the bracket form ?field[op]= takes, in the order a refusal names them. Each is its FilterOp's own spelling, the word its suffix carries after the underscore (eq has no suffix).
var FilterSuffixes = [...]FilterSuffixOp{ {"_gte", OpGte}, {"_lte", OpLte}, {"_gt", OpGt}, {"_lt", OpLt}, {"_like", OpLike}, {"_ne", OpNe}, {"_in", OpIn}, }
FilterSuffixes is the canonical operator-suffix table for the equality, not-equal, comparison, LIKE, and IN operators. Order matters: longer suffixes MUST be tested before their shorter prefixes (e.g. `_gte` before `_gt`, otherwise `?score_gte=5` matches `_gt` and leaves an `e=` field-name fragment). The table is a pure function of the operator set, so it is hoisted to a package var. ParseFilters/ParseSort no longer rebuild it per call.
Functions ¶
func ApplySortToQuery ¶
func ApplySortToQuery(qb *query.QueryBuilder, sorts []ParsedSort)
applySortToQuery applies parsed sorts to a query builder.
func ApplyToCountQuery ¶
func ApplyToCountQuery(cb *query.CountBuilder, filters []ParsedFilter)
ApplyToCountQuery applies parsed filters to a count builder.
func ApplyToQuery ¶
func ApplyToQuery(qb *query.QueryBuilder, filters []ParsedFilter)
ApplyToQuery applies parsed filters to a query builder.
func BracketKey ¶ added in v0.96.0
BracketKey spells the explicit form of a filter on name with op, name[op]. The text before the brackets is always the whole field (or wire) name, never cut at an operator suffix, so it reaches a field whose name ends in one: check_in[eq] is check_in = …, check[in] is check IN (…).
func CheckOpType ¶ added in v0.87.0
CheckOpType refuses an operator that does not suit the column's type, the one rule every filter surface applies: ?field_<op>=, ?where= and Go-built predicates, nested ?rel.field_<op>= filters and include-scoped filters. The error names the field the caller sent.
func CoerceValue ¶ added in v0.96.0
CoerceValue checks that raw is a value of a column of type t and returns what a query binder binds for it. Every filter surface (?field_<op>=, ?where=, dsl predicates, nested ?rel.field= and ?include=rel(…) filters, ListOptions.Filters, a keyset cursor's values) runs its values through here, so a value that is not of the column's type is a 400 naming the field on both dialects. Before it, Postgres refused the bind with 22P02 (a 500) while SQLite compared the text and answered 200 with no rows.
- Int: base-10 integer (strconv.ParseInt), bound as int64.
- Float: decimal literal, bound as float64. Hex floats, underscores, Inf and NaN are refused.
- Decimal: decimal literal, bound as the text sent so the comparison keeps every digit.
- Bool: exactly what strconv.ParseBool accepts (1, t, T, TRUE, true, True and the false spellings), bound as bool. "yes"/"on" are refused: Postgres would accept them and SQLite would match nothing.
- Timestamp: RFC 3339, a zone-less date and time (T or a space, to the minute or second), or a bare date, bound in schema.TimestampLayout. A value with no zone is UTC, the zone crud stores every Timestamp in; a bare date is its midnight.
- Date: YYYY-MM-DD, bound as the text sent.
Every other type binds raw unchanged: String, Text, Enum, UUID and Relation columns are TEXT on both dialects, so any text is a value that simply may match nothing.
Under every type the value must be text Postgres can store: valid UTF-8 with no NUL byte. Postgres refuses either in a text bind with 22021 (a 500) while SQLite compared the bytes and answered 200, so ?title=a%FFb split the dialects the way a mistyped value did. A Decimal exponent past ±1000 (Postgres numeric_in refuses it) and a Timestamp or Date in year 0 or before (Postgres has no year 0) are refused for the same reason.
func EscapeLikePattern ¶ added in v0.45.0
EscapeLikePattern escapes v's LIKE metacharacters and wraps it in the leading/trailing wildcards that implement "contains". Pair it with an `ESCAPE '\'` clause on the LIKE fragment so the wildcards a caller supplies are matched literally, not interpreted as patterns.
Exported so relation-scoped filters (framework/crud's nested `?rel.f_like=` and `?include=rel(f_like=…)`) build the identical clause. They used to pass the caller's value through raw, which made the same query parameter mean "literal substring" at the top level and "wildcard pattern" one level down.
func IsAmbiguousKey ¶ added in v0.96.0
IsAmbiguousKey reports whether the bare key reads two ways among the names named accepts: it is a name, and it is also another name plus an operator suffix. ReadKey refuses such a key; a client sends a bracket spelling instead.
func IsBracketKey ¶ added in v0.96.0
IsBracketKey reports whether key is in the bracket form name[op] (whatever the op word), the explicit spelling that never reads as a suffix or as a reserved control word.
func LikeArg ¶ added in v0.96.0
LikeArg is the bind value LikeClause pairs with its placeholder: value lowered, wildcards escaped, wrapped in %…%.
func LikeClause ¶ added in v0.96.0
LikeClause returns the case-insensitive "contains" predicate every `_like` / `contains` filter compiles to, and the bind value for its placeholder $n. It is the ?q= search shape (LOWER on the column, the value lowered in Go) so the two agree, with the caller's LIKE wildcards escaped so they match literally. col is spliced as SQL: pass an allow-listed, possibly table-qualified, column name.
Postgres LIKE is case-sensitive and SQLite's folds ASCII case only, so a bare LIKE answered differently per dialect. The case contract is ?q='s: ASCII-case-insensitive everywhere, Unicode folding a Postgres bonus (SQLite's LOWER folds only ASCII, so a stored "É" is not matched there).
func OpSuitsType ¶ added in v0.87.0
OpSuitsType reports whether op is meaningful on a column of type t. It is the one type predicate behind every filter surface: CheckOpType, the URL parsers, and the OpenAPI spec generator's advertised-operator set all read it, so no surface can accept (or advertise) an operator another refuses.
OpLike is a text-shape question and is refused on non-text columns (a `like` on an Int or Bool is always a client bug, and on a JSON column it quietly depends on dialect-specific text coercion). The ordered comparisons are refused on Bool: SQL orders false < true, but no caller means that, and accepting it invites accidental always-true predicates. A JSON column takes no operator at all: equality compared the stored text on SQLite and the parsed value on Postgres (JSONB), so {"x":1.0} matched a stored {"x":1} on one engine and not the other, and neither engine can put the other's answer into SQL. OpEq, OpNe and OpIn suit every other type; their values are bound parameters.
func RepeatedFilterError ¶ added in v0.96.0
RepeatedFilterError is the refusal of a scalar filter key sent count times (filter.repeated_filter). Only an IN key reads every occurrence; a scalar operator has one value, and keeping the first while dropping the rest runs a different query from the one a proxy reading the last occurrence saw. The nested ?rel.field= parser in framework/crud returns the same refusal, so a relation filter follows the same rule.
func SplitINValues ¶ added in v0.66.0
SplitINValues expands every occurrence of a repeated _in parameter into one flat, comma-split value list: ?tag_in=a&tag_in=b,c yields [a b c]. Reading only values[0] silently narrowed the filter to the first key's values, a client asking for a union got a subset with no error. Repeated keys and comma-separated values are treated as the same list, so the cap (MaxINListEntries) can't be bypassed by splitting one huge list across several occurrences either.
func SplitINValuesBounded ¶ added in v0.66.0
SplitINValuesBounded is SplitINValues with an allocation bound for the HTTP filter paths that enforce MaxINListEntries: it never materializes more than max+1 entries. SplitINValues built the entire list before the caller compared it against the cap, so one request carrying hundreds of thousands of commas allocated that many strings just to earn a 400, repeated per request. Here the total is counted first (strings.Count, no allocation) and the list is only built in full when it fits; an over-cap input yields a max+1-entry prefix plus the exact total, which is everything the rejection error needs. parts holds the complete list iff total <= max.
func SuffixKey ¶ added in v0.96.0
SuffixKey spells the suffix form of a filter on name with op: name alone for equality, name plus the op's suffix otherwise.
Types ¶
type Condition ¶ added in v0.18.0
Condition is a single WHERE fragment (SQL + args) produced by SearchConditions. Convert to a hook.WhereClause and append via qb.Where(c.SQL, c.Args…), the query builder wraps each Where in parens and AND-composes them, so multi-field search clauses combine safely with owner/tenant/soft-delete scopes.
func BuildPredicate ¶ added in v0.20.0
BuildPredicate compiles a validated predicate tree into one WHERE Condition: a fully-parenthesized SQL string with sequential $N placeholders in depth-first order and a matching, same-order Args slice. Field names are interpolated (they came from the schema allow-list in ParseWhere); every value is a bound arg. Hand the result to qb.Where(c.SQL, c.Args...) ONCE, the builder wraps it in its own parens and AND-joins it to the framework scopes, so the user's boolean logic can never escape to widen a scope.
The $N numbers are positional only; core/query.renumberPlaceholders rewrites them left-to-right and advances by len(Args), so the sole invariant is that placeholders appear in the same order as Args, which building both in one DFS pass guarantees.
func SearchConditions ¶ added in v0.18.0
SearchConditions builds a slice of AND-composed search conditions from a free-text term over the given DB column names. Each whitespace- delimited token produces one Condition whose SQL is a parenthesized OR-group: (LOWER(f1) LIKE $1 ESCAPE '\' OR LOWER(f2) LIKE $2 ESCAPE '\'). Every Condition must match (AND); within one Condition, any field may match (OR). A blank/whitespace-only term returns nil (no conditions). A term that is not storable text (invalid UTF-8 or a NUL byte) returns an error naming "q": Postgres refuses it in a text bind.
Case contract: LOWER() is ASCII-only on SQLite and locale-aware on Postgres, so matching is ASCII-case-insensitive everywhere. Unicode case folding is a Postgres bonus. The token itself is lowercased before building the LIKE pattern so the comparison is consistent across dialects.
LIKE metacharacters (%, _, \) in each token are escaped via the existing escaper so they match literally, a user searching for "50%" finds rows containing "50%", not every row with any character sequence. The args are ordered to match the $N placeholders left-to-right; the crud query builder renumbers $N on Build.
type FilterOp ¶
type FilterOp string
FilterOp represents a comparison operator for query filtering.
func ReadKey ¶ added in v0.96.0
ReadKey reads one filter key against the names a target answers to. named reports whether a name is one a caller may put in a filter key: the column or wire name of a field that is not Hidden. NoQuery fields count (they are refused by name afterwards); Hidden ones never do, so a Hidden field and an absent one read alike.
The bracket form name[op] names its field with the whole text before the brackets and its operator by word (BracketOps). A word outside that list is a refusal, but only once name is named: an unnamed name reads as an unknown field first, the answer an absent one gets.
A bare key is equality on itself when it is a name, or op on the name before its operator suffix when that is a name. When both hold (fields check and check_in, key check_in) the key reads two ways and is refused, naming both readings and the bracket spellings that pick one: guessing either answered the other reading's rows with a 200.
ok is false when no reading names a field. name and op are then the reading the caller most likely meant, for the unknown-field refusal: the bracket name, else the cut at the key's suffix, else the key.
type FilterOption ¶ added in v0.35.0
type FilterOption func(*filterOpts)
FilterOption tunes ParseFilters behavior.
func Allow ¶ added in v0.35.0
func Allow(keys ...string) FilterOption
Allow declares extra query-param keys that are NOT entity fields but are legitimately consumed elsewhere on the request (a BeforeList hook, custom middleware). Strict parsing skips them instead of rejecting them, so a host keeps typo-protection for real fields without falling back to Lenient (which disables it entirely). Keys are matched exactly.
func Lenient ¶ added in v0.35.0
func Lenient() FilterOption
Lenient restores the pre-strict behavior: an unknown top-level filter key is silently dropped instead of returning an error. It exists as a migration escape hatch for apps that historically relied on unrecognized query params being ignored. Prefer the strict default, a dropped filter returns an UNFILTERED result set, which is a data-exposure and broken-client hazard.
type FilterSuffixOp ¶ added in v0.36.0
FilterSuffixOp pairs a query-string operator suffix (e.g. "_gt") with its FilterOp. Exported so the CRUD layer's nested-filter parser can share the same canonical table (no per-call rebuild, no duplicate literal to drift between packages).
type KeySpeller ¶ added in v0.96.0
type KeySpeller struct {
// contains filtered or unexported fields
}
KeySpeller spells the query key a client sends to filter one name with one operator, given every name a target answers to (column and wire names of its non-Hidden fields, NoQuery ones included). Generators and re-dispatching surfaces (the MCP list tool, the CLI, the OpenAPI document) use it so a field whose bare key is ambiguous is addressed by its bracket spelling, and every other key keeps its suffix spelling.
func NewKeySpeller ¶ added in v0.96.0
func NewKeySpeller(names ...string) KeySpeller
NewKeySpeller builds a KeySpeller over names. Empty names are ignored.
func SpellerFor ¶ added in v0.96.0
func SpellerFor(fields []schema.Field) KeySpeller
SpellerFor is the KeySpeller over the names ParseFiltersValues reads for fields: each non-Hidden field's column name and WireName, NoQuery fields included (a NoQuery name still makes a bare key read two ways).
func (KeySpeller) Ambiguous ¶ added in v0.96.0
func (s KeySpeller) Ambiguous(name string, op FilterOp) bool
Ambiguous reports whether the suffix spelling of name with op reads two ways (IsAmbiguousKey), so a client must send the bracket form.
func (KeySpeller) AmbiguousKeys ¶ added in v0.96.0
func (s KeySpeller) AmbiguousKeys() []string
AmbiguousKeys returns, sorted, every one of the speller's names whose bare key reads two ways: the keys a client must send in bracket form.
func (KeySpeller) Key ¶ added in v0.96.0
func (s KeySpeller) Key(name string, op FilterOp) string
Key returns the key to send: the suffix spelling (SuffixKey) unless it is ambiguous, then the bracket spelling (BracketKey).
func (KeySpeller) Named ¶ added in v0.96.0
func (s KeySpeller) Named(name string) bool
Named reports whether name is one of the speller's names.
type ParsedFilter ¶
type ParsedFilter struct {
Field string
Op FilterOp
Value string
// contains filtered or unexported fields
}
ParsedFilter represents a single parsed filter from query parameters.
func ParseFilters ¶
func ParseFilters(r *http.Request, fields []schema.Field, opts ...FilterOption) ([]ParsedFilter, error)
ParseFilters extracts filters from query parameters based on entity fields. Supported patterns:
?field=value → equals
?field_ne=value → not equals
?field_gt=value → greater than
?field_lt=value → less than
?field_gte=value → greater than or equal
?field_lte=value → less than or equal
?field_like=value → LIKE (contains)
?field_in=v1,v2,v3 → IN
?field[op]=value → the same operators by word (eq, ne, gt, gte,
lt, lte, like, in); the name before the
brackets is always the whole field name
A bare key cuts at its suffix only when the name before the suffix is a field, so a lone opt_in field filters as ?opt_in=. When the key is a field AND another field plus a suffix (check beside check_in, key check_in) it reads two ways and is a 400 naming both readings and the bracket spellings that pick one (?check_in[eq]=, ?check[in]=), Lenient included. An unknown bracket word is a 400 too; a bracket on an unknown or Hidden field is the unknown-filter 400.
`?field_ne=` follows OpEq's NULL semantics rather than SQL three-valued intuition: a row whose column is NULL matches neither `=` nor `!=`, no `OR field IS NULL` arm is added.
Only fields present in the schema are accepted. Hidden fields are excluded from the allow-list (mirroring ParseSort): building a WHERE predicate on a column the caller can't read turns row-count/result changes into a value-disclosure oracle, an attacker could probe a Hidden column (e.g. a password hash) via ?password_hash_like=… and exfiltrate it prefix by prefix. A Hidden field name is treated as an unknown filter param and never produces a ParsedFilter.
NoQuery fields are excluded too, and for the same oracle reason, a value masked on the way out (last-4, redacted by an AfterGet hook) is still recoverable a character at a time if the stored column remains filterable. They are rejected by name rather than as unknown: the field is in the response, so its existence is not a secret worth protecting.
STRICT by default: an unknown top-level filter key (a typo like ?stauts=active, or a suffixed op on a non-field) returns a structured error rather than being silently dropped. Dropping it would return an UNFILTERED 200, a broken client reads the whole table and an attacker's probe looks identical to the real query. Reserved list controls (sort, page, cursor, …) and nested relation filters (dotted keys like author.name, validated separately by parseNestedFilters) are skipped, not rejected. Pass Lenient to restore the old drop-silently behavior.
This is a thin wrapper around ParseFiltersValues that parses the request URL once. Callers that already have a url.Values (e.g. the CRUD List handler, which parses once and threads the result through every helper) should call ParseFiltersValues directly to avoid the re-parse.
func ParseFiltersValues ¶ added in v0.36.0
func ParseFiltersValues(q url.Values, fields []schema.Field, opts ...FilterOption) ([]ParsedFilter, error)
ParseFiltersValues is the allocation-conscious variant of ParseFilters: it accepts an already-parsed url.Values so a caller that parsed ?field=value once can reuse it across filter/sort/paginate/include helpers without re-paying url.URL.Query (which re-parses RawQuery and allocates a fresh url.Values on every call). Behaviour is identical to ParseFilters for the same underlying query string.
func (ParsedFilter) BindValue ¶ added in v0.65.0
func (f ParsedFilter) BindValue() any
BindValue returns what a query binder should bind for this filter: the schema-coerced value when the filter carries one (set by ParseFiltersValues, Typed or Coerced), the raw string otherwise. Every binder that applies a ParsedFilter to SQL must bind this, not Value, binding the raw string re-opens the SQLite Bool/TEXT affinity bug.
func (ParsedFilter) Coerced ¶ added in v0.65.0
func (f ParsedFilter) Coerced(t schema.FieldType) ParsedFilter
Coerced is Typed for a value the program controls (a facet's fixed options, a read-scope constant): the coerced bind when Value is of type t, the raw string otherwise. Client-borne values use Typed, which refuses a mistyped one instead of binding it.
func (ParsedFilter) InList ¶ added in v0.96.0
func (f ParsedFilter) InList(key string) ParsedFilter
InList returns f tagged with the query key whose IN list it belongs to. A parser that emits one OpIn filter per value (ParseFiltersValues does this itself) tags each value with its key, so a renderer merging adjacent values into one IN clause stops at the next key: two keys on one column (a column and its wire alias) stay two ANDed lists.
func (ParsedFilter) Key ¶ added in v0.96.0
func (f ParsedFilter) Key() string
Key returns the query key the filter was parsed from: set by ParseFiltersValues on every filter it returns, and by InList. A filter built by hand returns "".
func (ParsedFilter) SameINList ¶ added in v0.96.0
func (f ParsedFilter) SameINList(g ParsedFilter) bool
SameINList reports whether g continues f's IN list: both OpIn, on the same field, from the same query key (see InList). A renderer merges adjacent OpIn filters into one IN clause only while this holds.
func (ParsedFilter) Typed ¶ added in v0.96.0
func (f ParsedFilter) Typed(t schema.FieldType) (ParsedFilter, error)
Typed returns a copy of f whose bind value is checked and coerced for a column of type t (see CoerceValue), or an error naming the field when Value is not of that type. A filter whose value came off a request goes through here; OpLike's value is a substring, never coerced.
type ParsedSort ¶
ParsedSort represents sort direction for a field.
func ParseSort ¶
ParseSort extracts sort information from query parameters. Supported: ?sort=field (ascending), ?sort=-field (descending).
Hidden fields are excluded from the allow-list: sorting by a hidden column reveals row ordering by a value the caller can't read, which is an information-disclosure path. NoQuery fields are excluded for the same reason, but rejected by name, they appear in responses, so the caller already knows the column exists. Unknown fields fail closed with a 400-shaped error rather than being silently ignored, silent drop turns probe attempts into "the API works the same with or without this param" oracles that mask broken client code.
Thin wrapper around ParseSortValues; callers that already hold a url.Values should call ParseSortValues directly.
func ParseSortValues ¶ added in v0.36.0
ParseSortValues is the allocation-conscious variant of ParseSort: it accepts an already-parsed url.Values so the CRUD List handler can thread the same parsed query through every helper.
type Predicate ¶ added in v0.20.0
type Predicate struct {
// Leaf fields (Children == nil):
Field string
Op FilterOp
Value string // for scalar ops
Values []string // for OpIn
// Group fields (Children != nil):
Or bool // false = AND, true = OR
Children []Predicate
// contains filtered or unexported fields
}
Predicate is a node in a boolean filter tree: either a LEAF (one field/op/value comparison) or a GROUP (AND/OR of child predicates). It is the parsed, validated form of a `?where=<json>` request, every field has already been checked against the entity's schema allow-list and every operator against the supported set, so BuildPredicate may interpolate field names trusting they are safe while binding all values as placeholders.
Groups let callers express nested boolean logic the flat query-param filters cannot, e.g. `status = A OR (priority = high AND assignee = me)`. The whole tree compiles to ONE parenthesized WHERE clause that the query builder AND-composes with the framework's owner/tenant/ soft-delete scopes; a user OR-group can never widen past those scopes because each is a separate, individually-parenthesized clause.
func ParseWhere ¶ added in v0.20.0
ParseWhere parses a `?where=<json>` predicate tree and validates every leaf field against the schema allow-list (Hidden fields excluded, same value-disclosure-oracle rationale as ParseFilters) and every operator against the supported set. Returns (nil, nil) when raw is empty. Any unknown field, unknown operator, malformed JSON, empty/ambiguous node, or a tree that exceeds the depth/node bounds returns an error, the caller maps it to 400. On success the tree is safe for BuildPredicate to compile.
func ValidatePredicate ¶ added in v0.87.0
ValidatePredicate runs over a predicate tree built in Go code — a view func's return value, a ListOptions.Where, anything that did not arrive through ParseWhere — the SAME checks ParseWhere applies to input from a URL: every leaf field exists on the schema and is neither Hidden nor NoQuery (Hidden under either its column name or its wire alias), the operator is known and suits the field's type, an OpIn leaf carries 1… MaxINListEntries values, and the tree stays inside the depth and node caps.
It never writes to the input: the caller's tree may be shared across goroutines (a parsed Display view handed to concurrent requests), so the two resolutions a validated tree carries — a wire alias resolved to its column name, a Bool leaf's coercion marker — are written on a deep copy that is returned, and that copy is the tree to hand BuildPredicate. The input stays byte-for-byte what the caller built.
Field names in a predicate are spliced into SQL by BuildPredicate (values are bound as placeholders, names are not), so this check is what keeps a Go-built tree safe to compile: a field that is not on the schema's allow-list — an invented name, a Hidden column, SQL metacharacters in a key — is refused before any SQL exists. A node that is neither a well-formed leaf nor a non-empty group is refused too.
nil is valid (the empty predicate) and returns (nil, nil).