query

package
v0.1.16 Latest Latest
Warning

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

Go to latest
Published: Aug 30, 2026 License: Apache-2.0 Imports: 10 Imported by: 0

Documentation

Index

Constants

View Source
const (
	DefaultPage    = 1
	DefaultPerPage = 20
	MaxPerPage     = 100
	MaxPage        = 10_000
)
View Source
const CountAlias = "agg_count"

CountAlias is the SQL column alias COUNT(*) is returned under.

Variables

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

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

func AppendRowCondition(sql string, args []any, cond *rbac.WhereCondition) (string, []any, error)

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

func HasInclude(include string) bool

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

func ParseCountFlag(params url.Values) (bool, error)

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

func RejectListParams(params url.Values, route string, alsoInclude bool) error

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

func SelectList(cols []string) string

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

func SelectListAliased(alias string, cols []string) string

SelectListAliased is SelectList with every column qualified by a table alias (`r.*` / `r."id", r."name"`) for the JOINed relation subroute statement.

func SingleParam

func SingleParam(params url.Values, key string) (string, bool, error)

SingleParam is singleValue for callers OUTSIDE this package that read an engine-owned parameter directly (codegen's ?include=): same ENG-17 contract — present-or-not plus a named rejection when repeated.

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

type BaseSelect func(cols []string) (sql string, args []any)

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

type IncludeError struct {
	Status int
	Msg    string
}

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.

Jump to

Keyboard shortcuts

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