Summary
queryParamPushdown is generic for top, skip, count and select - each is
driven by a configurable paramName and works with any vendor. orderBy and
filter are not: both hard-require the OData dialect and emit an OData-shaped
value, so an API that expresses ordering with any other convention cannot use the
pushdown at all. This asks for orderBy to take a custom directive the same way
top does. (filter is deliberately out of scope here - vendor filter DSLs are a
much larger problem; see Non-goals.)
Current behaviour
internal/anysdk/query_param_pushdown_apply.go:
func applyPushdownOrderBy(qpp QueryParamPushdown, intent PushdownIntent, res *standardPushdownResult) {
orderBy := intent.GetOrderBy()
if len(orderBy) == 0 { return }
ob, ok := qpp.GetOrderBy()
if !ok { return }
// Only the OData syntax has a well-defined "col asc|desc" rendering here.
if !strings.EqualFold(ob.GetSyntax(), ODataDialect) { return }
...
parts = append(parts, o.GetColumn()+" "+dir) // renders `created_at desc`
res.queryParams[paramName] = strings.Join(parts, ",")
}
Two hard constraints fall out:
- Dialect gate - anything other than
syntax: odata returns early, emitting nothing.
- Fixed rendering - the value is always
<column> <asc|desc>, comma-joined.
Contrast applyPushdownTop, which has no dialect gate and honours any paramName.
That asymmetry is the bug: the framework is generic in principle but OData-only in
two of its six directives.
Motivating case (OpenAI)
OpenAI list endpoints order by a fixed column (created_at) and take direction
only:
GET /v1/containers?order=desc # asc | desc
There is no way to express this today:
syntax: odata renders created_at desc, which OpenAI rejects.
- Any other
syntax value renders nothing (the gate).
So ORDER BY created_at DESC cannot be pushed down, even though the wire
parameter is trivial. The provider currently exposes order as an ordinary WHERE
parameter (WHERE "order" = 'desc'), which works but is not the SQL idiom users
expect, and it cannot participate in the planner's ordering.
This shape is not OpenAI-specific: direction-only ordering is common (Stripe's
created[gt]-style APIs, GitHub's ?direction=asc, many cursor APIs).
Proposal
Give orderBy a render directive, defaulting to today's behaviour so nothing
regresses. Sketch:
x-stackQL-config:
queryParamPushdown:
orderBy:
paramName: order
algorithm: direction_only # new; default remains the odata rendering
supportedColumns: [created_at]
Suggested algorithms:
| algorithm |
renders |
example |
odata (default, current) |
col asc|desc, comma-joined |
$orderby=created_at desc |
direction_only |
just asc/desc; requires exactly one order term whose column is in supportedColumns |
order=desc |
column_only |
just the column name |
sort=created_at |
direction_only must refuse (emit nothing, leaving the ORDER BY client-side) when
more than one order term is present or the column is unsupported - the existing
all-or-nothing guard already models this correctly.
A more general alternative is a template (valueTemplate: '{{ .Direction }}'),
consistent with the existing request-body transform mechanism. Named algorithms
are simpler to validate and match the existing PaginationAlgorithm* precedent
(page_number, odata_next_link), so they are the recommendation; a template is
the fallback if the algorithm set proliferates.
Acceptance
orderBy with algorithm: direction_only, paramName: order,
supportedColumns: [created_at]:
ORDER BY created_at DESC -> ?order=desc
ORDER BY created_at ASC -> ?order=asc
ORDER BY name DESC -> nothing emitted (column unsupported; stays client-side)
ORDER BY created_at, name -> nothing emitted (multi-term)
- Existing OData configs are byte-identical (default path unchanged).
- Client-side ORDER BY remains authoritative in every case - pushdown stays a pure
optimisation, so a refused render can never change results.
Non-goals
filter: deliberately excluded. Vendor filter DSLs vary far more than
ordering (discrete named params, bracket operators, bespoke grammars), so a
useful generalisation needs its own design. Ordering is a small, closed problem
worth solving first.
Related
- REST cursor traversal is not bounded by a pushed
top/LIMIT: GetPushdownLimit
is consumed only in stackql's GraphQL acquire path
(internal/stackql/primitivebuilder/graphql_single_select_acquire.go), while the
REST loop (execution/mono_valent_execution.go) terminates only on token
absence or the global HTTPPageLimit. Tracked separately; it makes a small
pushed LIMIT shrink page size without reducing rows fetched.
casing.ToSnake cannot alias hyphenated wire identifiers - see
any-sdk-casing-hyphen-issue.md.
Summary
queryParamPushdownis generic fortop,skip,countandselect- each isdriven by a configurable
paramNameand works with any vendor.orderByandfilterare not: both hard-require the OData dialect and emit an OData-shapedvalue, so an API that expresses ordering with any other convention cannot use the
pushdown at all. This asks for
orderByto take a custom directive the same waytopdoes. (filteris deliberately out of scope here - vendor filter DSLs are amuch larger problem; see Non-goals.)
Current behaviour
internal/anysdk/query_param_pushdown_apply.go:Two hard constraints fall out:
syntax: odatareturns early, emitting nothing.<column> <asc|desc>, comma-joined.Contrast
applyPushdownTop, which has no dialect gate and honours anyparamName.That asymmetry is the bug: the framework is generic in principle but OData-only in
two of its six directives.
Motivating case (OpenAI)
OpenAI list endpoints order by a fixed column (
created_at) and take directiononly:
There is no way to express this today:
syntax: odatarenderscreated_at desc, which OpenAI rejects.syntaxvalue renders nothing (the gate).So
ORDER BY created_at DESCcannot be pushed down, even though the wireparameter is trivial. The provider currently exposes
orderas an ordinary WHEREparameter (
WHERE "order" = 'desc'), which works but is not the SQL idiom usersexpect, and it cannot participate in the planner's ordering.
This shape is not OpenAI-specific: direction-only ordering is common (Stripe's
created[gt]-style APIs, GitHub's?direction=asc, many cursor APIs).Proposal
Give
orderBya render directive, defaulting to today's behaviour so nothingregresses. Sketch:
Suggested algorithms:
odata(default, current)col asc|desc, comma-joined$orderby=created_at descdirection_onlyasc/desc; requires exactly one order term whose column is insupportedColumnsorder=desccolumn_onlysort=created_atdirection_onlymust refuse (emit nothing, leaving the ORDER BY client-side) whenmore than one order term is present or the column is unsupported - the existing
all-or-nothing guard already models this correctly.
A more general alternative is a template (
valueTemplate: '{{ .Direction }}'),consistent with the existing request-body
transformmechanism. Named algorithmsare simpler to validate and match the existing
PaginationAlgorithm*precedent(
page_number,odata_next_link), so they are the recommendation; a template isthe fallback if the algorithm set proliferates.
Acceptance
orderBywithalgorithm: direction_only,paramName: order,supportedColumns: [created_at]:ORDER BY created_at DESC->?order=descORDER BY created_at ASC->?order=ascORDER BY name DESC-> nothing emitted (column unsupported; stays client-side)ORDER BY created_at, name-> nothing emitted (multi-term)optimisation, so a refused render can never change results.
Non-goals
filter: deliberately excluded. Vendor filter DSLs vary far more thanordering (discrete named params, bracket operators, bespoke grammars), so a
useful generalisation needs its own design. Ordering is a small, closed problem
worth solving first.
Related
top/LIMIT:GetPushdownLimitis consumed only in stackql's GraphQL acquire path
(
internal/stackql/primitivebuilder/graphql_single_select_acquire.go), while theREST loop (
execution/mono_valent_execution.go) terminates only on tokenabsence or the global
HTTPPageLimit. Tracked separately; it makes a smallpushed LIMIT shrink page size without reducing rows fetched.
casing.ToSnakecannot alias hyphenated wire identifiers - seeany-sdk-casing-hyphen-issue.md.