Documentation
¶
Index ¶
- Constants
- Variables
- func AppendAliasedRowCondition(sql string, args []any, alias string, cond *rbac.WhereCondition) (string, []any, error)
- func AppendRowCondition(sql string, args []any, cond *rbac.WhereCondition) (string, []any, error)
- func HasInclude(include string) bool
- func ParseCountFlag(params url.Values) (bool, error)
- func ParseFields(res *schema.ResourceSchema, params url.Values, allowedFields []string) ([]string, error)
- func RejectListParams(params url.Values, route string, alsoInclude bool) error
- func SelectList(cols []string) string
- func SelectListAliased(alias string, cols []string) string
- func SingleParam(params url.Values, key string) (string, bool, error)
- type AggMetric
- type AggregateQuery
- type BaseSelect
- type IncludeError
- func BuildGetInclude(baseResource, include, baseSelect string, baseArgs []any, s *schema.APISchema, ...) (sql string, args []any, ierr *IncludeError)
- func BuildGetIncludeFields(baseResource, include string, base BaseSelect, rootFields []string, ...) (sql string, args []any, ierr *IncludeError)
- func BuildListInclude(baseResource, include, baseSelect string, baseArgs []any, ...) (sql string, args []any, ierr *IncludeError)
- func BuildListIncludeFields(baseResource, include string, base BaseSelect, rootFields []string, ...) (sql string, args []any, ierr *IncludeError)
- type QueryBuilder
- func (qb *QueryBuilder) EffectiveOrder() (field, dir string)
- func (qb *QueryBuilder) Fields() []string
- func (qb *QueryBuilder) Page() int
- func (qb *QueryBuilder) PerPage() int
- func (qb *QueryBuilder) SQL() (selectQ, countQ string, selectArgs, countArgs []any)
- func (qb *QueryBuilder) SQLProjected(cols []string) (selectQ string, selectArgs []any)
- func (qb *QueryBuilder) SelectOnly(cols []string) error
- func (qb *QueryBuilder) UsesCursor() bool
- type RelationRBAC
Constants ¶
const ( DefaultPage = 1 DefaultPerPage = 20 MaxPerPage = 100 MaxPage = 10_000 )
const CountAlias = "agg_count"
CountAlias is the SQL column alias COUNT(*) is returned under.
Variables ¶
var ErrAggForbiddenField = errors.New("aggregate references a field not permitted for this role")
ErrAggForbiddenField signals that an aggregate referenced a field the role may not read (its RBAC field allowlist excludes it). Callers map this to 403; any other BuildAggregate error is a 400. Aggregating a hidden field would otherwise leak its values, so it is forbidden — the no-leak-via-aggregate guarantee (G3).
var ErrForbiddenField = errors.New("request references a field not permitted for this role")
ErrForbiddenField signals that a list request named a field the role's RBAC allowlist excludes — in a filter or a sort. Callers map it to 403; every other BuildQuery error stays a 400. SEC-5's lesson, closed generally in PUBLIC-SURFACE-S1: the response allowlist alone left `?filter[hidden][eq]=x` as a value oracle over a column the role could never read (match/no-match reveals its contents by binary search), and `?sort=hidden` as an ordering oracle. The defense must exist wherever a field can be NAMED, not only where it is returned. Same contract as ErrAggForbiddenField on the aggregate path.
Functions ¶
func AppendAliasedRowCondition ¶
func AppendAliasedRowCondition(sql string, args []any, alias string, cond *rbac.WhereCondition) (string, []any, error)
AppendAliasedRowCondition is AppendRowCondition for a statement that JOINs more than one table, so the condition column must be qualified by its table alias to be unambiguous (the relation subresource route: SELECT r.* FROM <target> r JOIN <parent> src …). alias is an engine-controlled identifier (e.g. "r"), never user input; the condition field is validated as a bare identifier and the value is always a bound parameter. The operator is equality — the only operator an RBAC condition may declare (enforced at schema load; see schema.validateConditionOp).
func AppendRowCondition ¶
AppendRowCondition appends a row-level RBAC WhereCondition to a single-row SQL statement that is already parameterized at $1..$len(args) (e.g. a GET-by-id or DELETE filtered on id). The condition field is validated as a bare identifier (the value is always a bound parameter); an invalid field returns an error so the caller fails closed. This is the one canonical implementation used by both the REST and GraphQL get-by-id/delete paths so they enforce the SAME row-level RBAC the list path applies via buildWhere.
func HasInclude ¶
HasInclude reports whether the request opted into relation embedding. Kept tiny so the no-include hot path is a single empty-string check at the call site.
func ParseCountFlag ¶
ParseCountFlag reads the ?count flag by VALUE (ENG-23/ENG-18, ADR-024 §4). The flag used to be tested for presence only — `?count=false` and `?count=0` turned the total ON — and REST disagreed with GraphQL, whose count argument is a real Boolean. Accepted: bare `?count` / `?count=` (on — the documented syntax), true/1 (on), false/0 (off), case-insensitive; anything else is an error naming the value and the accepted set. Absent ⇒ off. A repeated count is rejected like every other engine-owned parameter.
func ParseFields ¶ added in v0.1.13
func ParseFields(res *schema.ResourceSchema, params url.Values, allowedFields []string) ([]string, error)
ParseFields reads the `?fields=` projection (MOTOR-FIELDS-S1) and returns the validated column list — `id` first, duplicates collapsed, request order kept — or nil when the parameter is absent (the caller keeps `SELECT *`).
Rules, each the engine's existing rule for a NAMED field re-applied here:
- absent → nil, nil (no projection; the SQL is byte-identical to before);
- present but empty (`?fields=`) → a named error (ENG-30: presence is the gate, an empty form field must not silently mean "everything");
- repeated (`?fields=a&fields=b`) → the ENG-17 repeated-parameter error;
- an empty ENTRY (`a,,b`, `a,`) → an error naming the extra comma (ENG-24);
- a name that is not a declared field of res (nor the implicit `id`) → an error naming it and listing the available set (ADR-024, like sort);
- a name outside allowedFields (nil = unrestricted; `id` always allowed) is DROPPED from the projection — the role's allowlist wins, exactly as it does on a read without `fields=` (the column is omitted from the response; RBAC-2 registers that hidden-attempt contract). Not the ErrForbiddenField of filter/sort: that 403 defends against a VALUE oracle (`?filter[hidden][eq]=x` reveals a hidden column by match), and a projection reveals nothing. A 403 here would also break every generic client — the contract is role-agnostic, so the embedded /app cannot know the allowlist before asking (it broke exactly so).
The names are validated identifiers from the schema, so SelectList may quote them straight into SQL; values never enter the statement.
func RejectListParams ¶
RejectListParams returns a named error when params carry an engine-owned LIST parameter (or a filter[/order[ prefix) on a route that would silently discard it (NIGHT-SWEEP-S1 audit finding, the ENG-14/ADR-024 class on the single-record and SSE surfaces): GET /api/notes/{id}?filter[status][eq]=paid used to return the row REGARDLESS of the filter — the caller believed they performed a conditional read — while the identical parameter on the list route is a named 400. route names the surface for the message ("a single-record route", "the event stream"); alsoInclude adds ?include= to the rejected set (the event stream embeds nothing). Unknown top-level parameters keep their ADR-024 tolerance — only what the engine OWNS is checked.
func SelectList ¶ added in v0.1.13
SelectList renders a projection as a SQL select list: `*` for nil (every column — the historical statement, byte for byte), else the quoted column names. The names come from ParseFields/SelectOnly, i.e. validated schema identifiers, never client text.
func SelectListAliased ¶ added in v0.1.13
SelectListAliased is SelectList with every column qualified by a table alias (`r.*` / `r."id", r."name"`) for the JOINed relation subroute statement.
Types ¶
type AggMetric ¶
type AggMetric struct {
Fn string // sum | avg | min | max
Field string
Alias string // SQL alias, e.g. "agg_sum_salario"
}
AggMetric is one requested function over one field, with its SQL column alias.
type AggregateQuery ¶
type AggregateQuery struct {
// contains filtered or unexported fields
}
AggregateQuery is a validated aggregation over a resource, scoped by the SAME filters + RBAC row condition as a list read of that resource.
func BuildAggregate ¶
func BuildAggregate( resource string, res *schema.ResourceSchema, params url.Values, condition *rbac.WhereCondition, allowedFields []string, ) (*AggregateQuery, error)
func (*AggregateQuery) GroupBy ¶
func (aq *AggregateQuery) GroupBy() []string
GroupBy returns the group-by field names (empty = a single overall aggregate).
func (*AggregateQuery) HasCount ¶
func (aq *AggregateQuery) HasCount() bool
HasCount reports whether COUNT(*) was requested.
func (*AggregateQuery) Metrics ¶
func (aq *AggregateQuery) Metrics() []AggMetric
Metrics returns the requested sum/avg/min/max metrics (with their SQL aliases).
func (*AggregateQuery) SQL ¶
func (aq *AggregateQuery) SQL() (sql string, args []any)
SQL emits the aggregate SELECT, scoped by the same WHERE (filters + RBAC row condition + search) the list read uses, minus cursor/pagination. All identifiers are quoted schema-validated names (injection-inert) and every function comes from the fixed allowlist. With group_by, rows are GROUP BY'd and ORDER BY'd on the group columns for deterministic output.
type BaseSelect ¶ added in v0.1.13
BaseSelect builds the base statement for the include wrappers: cols nil → the historical `SELECT *` (no projection); otherwise exactly those columns. The args must not depend on cols (a projection changes no parameter).
type IncludeError ¶
IncludeError carries an HTTP status so the handler maps a bad/forbidden include to 400 / 403 (never a 500). A nil *IncludeError means success.
func BuildGetInclude ¶
func BuildGetInclude(baseResource, include, baseSelect string, baseArgs []any, s *schema.APISchema, maxDepth int, rbacFor RelationRBAC) (sql string, args []any, ierr *IncludeError)
BuildGetInclude wraps a single-row base SELECT (SELECT * ... WHERE id=$1 [+ row cond]) so the result is one nested JSON object (column `data`), or zero rows (→ 404). baseSelect/baseArgs come from the caller's get-by-id SQL.
func BuildGetIncludeFields ¶ added in v0.1.13
func BuildGetIncludeFields(baseResource, include string, base BaseSelect, rootFields []string, s *schema.APISchema, maxDepth int, rbacFor RelationRBAC) (sql string, args []any, ierr *IncludeError)
BuildGetIncludeFields is BuildGetInclude with a `?fields=` projection (see BuildListIncludeFields; a single row has no order column).
func BuildListInclude ¶
func BuildListInclude(baseResource, include, baseSelect string, baseArgs []any, orderField, orderDir string, s *schema.APISchema, maxDepth int, rbacFor RelationRBAC) (sql string, args []any, ierr *IncludeError)
BuildListInclude wraps a base list SELECT so each row becomes a nested JSON object with its requested embeds, returning ONE query that yields two columns: `data` (a json array of the wrapped rows, ordered as the base query ordered them) and `n` (the row count, for has_next). baseSelect/baseArgs come from QueryBuilder.SQL(); orderField/orderDir from QueryBuilder.EffectiveOrder().
func BuildListIncludeFields ¶ added in v0.1.13
func BuildListIncludeFields(baseResource, include string, base BaseSelect, rootFields []string, orderField, orderDir string, s *schema.APISchema, maxDepth int, rbacFor RelationRBAC) (sql string, args []any, ierr *IncludeError)
BuildListIncludeFields is BuildListInclude with a `?fields=` projection (MOTOR-FIELDS-S1): rootFields is QueryBuilder.Fields() — nil keeps the historical statement byte for byte. base builds the base subquery for the column list the wrapper needs: the requested fields plus the columns its joins and its ORDER BY reference (baseColumns); the root json object names the requested fields only.
func (*IncludeError) Error ¶
func (e *IncludeError) Error() string
type QueryBuilder ¶
type QueryBuilder struct {
// contains filtered or unexported fields
}
QueryBuilder holds a parsed, validated query ready to emit SQL.
func BuildQuery ¶
func BuildQuery( resource string, res *schema.ResourceSchema, params url.Values, condition *rbac.WhereCondition, allowedFields []string, ) (*QueryBuilder, error)
BuildQuery parses url.Values and returns a validated QueryBuilder. Returns error for unknown filter fields, type-incompatible operators, or non-integer page/per_page; naming a field outside allowedFields (nil = no restriction; the implicit id is always allowed, as in every other surface) returns ErrForbiddenField.
func (*QueryBuilder) EffectiveOrder ¶
func (qb *QueryBuilder) EffectiveOrder() (field, dir string)
EffectiveOrder returns the column and direction (ASC|DESC) that SQL() orders the base rows by — so the include path (RELATIONS-V1) can re-impose the SAME order on its json_agg of wrapped rows. It mirrors SQL()'s ORDER BY logic: keyset cursors order by id (DESC for ?before), an explicit ?sort wins otherwise, and the default is id ASC.
func (*QueryBuilder) Fields ¶ added in v0.1.13
func (qb *QueryBuilder) Fields() []string
Fields returns the `?fields=` projection (id first) or nil when the request asked for every column. Callers that wrap the base SELECT (the ?include= builders) need it to project the root row object the same way.
func (*QueryBuilder) Page ¶
func (qb *QueryBuilder) Page() int
Page returns the current page number (1-based).
func (*QueryBuilder) PerPage ¶
func (qb *QueryBuilder) PerPage() int
PerPage returns the current page size.
func (*QueryBuilder) SQL ¶
func (qb *QueryBuilder) SQL() (selectQ, countQ string, selectArgs, countArgs []any)
SQL returns the SELECT and COUNT queries with their respective arg slices. selectArgs contains LIMIT/OFFSET appended after WHERE args; countArgs does not. When afterID or beforeID is set, keyset pagination is used: no OFFSET, one round-trip.
func (*QueryBuilder) SQLProjected ¶ added in v0.1.13
func (qb *QueryBuilder) SQLProjected(cols []string) (selectQ string, selectArgs []any)
SQLProjected is the BaseSelect of the ?include= wrappers (MOTOR-FIELDS-S1): the list statement with the given column list in place of the `?fields=` projection — nil is the historical `SELECT *`. The wrapper asks for the requested fields plus the FK/order columns its joins need; the builder is left unchanged.
func (*QueryBuilder) SelectOnly ¶ added in v0.1.13
func (qb *QueryBuilder) SelectOnly(cols []string) error
SelectOnly sets the projection from a caller that already knows which columns it will use — the GraphQL resolvers, whose selection set IS the projection (a GraphQL client can only name fields the generated type has). Every name must be a declared field of the resource or `id`; anything else is an error so no unvalidated identifier can reach the SQL. nil/empty restores `SELECT *`. The role's allowlist is NOT re-checked here: GraphQL's contract for a hidden selected field is `null` (the result scrubber), not an error, so its callers intersect with the allowlist before calling.
func (*QueryBuilder) UsesCursor ¶
func (qb *QueryBuilder) UsesCursor() bool
UsesCursor reports whether this query paginates by keyset cursor (?after/ ?before) rather than by page/offset. The list handlers use it to shape meta: a cursor request has NO page number, so meta must not invent one (ENG-15 — meta.page used to echo the default or even a sent-and-ignored page, actively asserting a page the query never used).
type RelationRBAC ¶
type RelationRBAC func(resource string) (allowed bool, fields []string, cond *rbac.WhereCondition)
RelationRBAC resolves, for a target resource, whether the request's role may read it, the field allowlist (empty = all fields), and the row-level condition to inject into the embed's WHERE. The caller wires this to policy.Evaluate so pkg/query stays decoupled from how RBAC is evaluated.