Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,30 @@ All notable changes to this project are documented here. The format follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html) — with the pre-1.0 caveat that a
minor release may break compatibility, in which case the break is spelled out below.

## [Unreleased]

No change to the schema, the grammar or the semantics of evaluation. `query-language-schema.json`
is untouched and its `$id` still names `v0.4.0`.

### Changed

- **The error format is no longer mandated.** [SPEC.md §8](./SPEC.md#8-errors) required
[RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) Problem Details with media type
`application/problem+json`. It now requires only that a rejected filter be answered with
`400 Bad Request` and that the response say **which** of the five conditions applies —
`malformed-query`, `unknown-field`, `unsupported-operator`, `invalid-operand`,
`query-too-complex` — because that is what a client branches on. The envelope is the API's own:
an API with an established error format should express these conditions in it rather than carry
a second format for one endpoint. RFC 9457 remains the RECOMMENDED default where there is none,
and the `type` URIs, the `pointer` member and the recovery members (`queryableFields`,
`accepted`) are unchanged as its encoding. This relaxes a requirement, so nothing that
conformed before stops conforming.
- The prose in [README §Errors](./README.md#errors), [COMPARISON.md §4](./COMPARISON.md), the
OpenAPI examples and `experiments/filter-to-sql` follows: they now describe Problem Details as
the recommended shape rather than the required one, and name the failing *condition* where they
previously said "problem". The examples still model RFC 9457, since it is still the default a
greenfield API should pick.

## [0.4.0] — 2026-09-07

**Breaking.** The `$id` is now `…/v0.4.0/query-language-schema.json`. This release resolves the
Expand Down
2 changes: 1 addition & 1 deletion COMPARISON.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ Stated plainly, because these are the reasons to choose GraphQL and they are goo

| | |
| --- | --- |
| **HTTP survives** | Cache-Control and ETags apply, `QUERY` and `GET` stay safe and cacheable, status codes mean what they say, errors are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problems with an RFC 6901 pointer at the offending clause ([SPEC.md §8](./SPEC.md#8-errors)). GraphQL's single POST endpoint forfeits intermediary caching; `application/graphql-response+json` recovers status codes but not cache keys. |
| **HTTP survives** | Cache-Control and ETags apply, `QUERY` and `GET` stay safe and cacheable, status codes mean what they say, and a rejected filter is a `400` naming which of five conditions failed, with an RFC 6901 pointer at the offending clause, in whatever error format the API already uses ([SPEC.md §8](./SPEC.md#8-errors)). GraphQL's single POST endpoint forfeits intermediary caching; `application/graphql-response+json` recovers status codes but not cache keys. |
| **Null semantics are specified** | [SPEC.md §4.1](./SPEC.md#41-three-valued-logic) and [§4.2](./SPEC.md#42-missing-versus-null) pin down three-valued logic and the missing-versus-null distinction. GraphQL's null propagation is its most notorious sharp edge, and its filter semantics are per-vendor folklore. |
| **Complexity limits are normative** | [SPEC.md §7](./SPEC.md#7-safety-limits) bounds depth, clause count, set length and regex execution, and requires rejection rather than truncation. GraphQL query cost is left to each shop to solve. |
| **Honest partial implementations** | Profiles ([SPEC.md §2.1](./SPEC.md#21-profiles)) let a server advertise the subset it implements and reject the rest, instead of mistranslating it. |
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,11 +266,11 @@ Not every backend can implement every operator, and silently ignoring a clause y

`core` · `strings` · `regex` · `ranges` · `types` · `collections` · `refs` · `text`

Implement `core` in full; take the rest whole or not at all. Reject unsupported operators with an `unsupported-operator` problem, and publish what you accept through a capability document. [SPEC.md §2](./SPEC.md#2-conformance).
Implement `core` in full; take the rest whole or not at all. Reject unsupported operators with an `unsupported-operator` error, and publish what you accept through a capability document. [SPEC.md §2](./SPEC.md#2-conformance).

## Errors

Rejected filters are reported as [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) Problem Details with a `pointer` locating the offending clause:
A rejected filter is a `400` that says *which* of five things went wrong — `malformed-query` · `unknown-field` · `unsupported-operator` · `invalid-operand` · `query-too-complex` — and, ideally, where. The envelope is your API's business; if you have no error format already, [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) Problem Details with a `pointer` at the offending clause is the recommended default:

```json
{
Expand All @@ -282,7 +282,7 @@ Rejected filters are reported as [RFC 9457](https://www.rfc-editor.org/rfc/rfc94
}
```

Types: `malformed-query` · `unknown-field` · `unsupported-operator` · `invalid-operand` · `query-too-complex`. [SPEC.md §8](./SPEC.md#8-errors).
What matters is that the condition is distinguishable and the client can recover from it — an `unknown-field` error carrying the endpoint's queryable paths saves a round trip of guessing. [SPEC.md §8](./SPEC.md#8-errors).

## Generating a per-resource filter schema

Expand Down
16 changes: 9 additions & 7 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Operators are grouped into profiles so that a server can implement a subset hone

A conforming implementation MUST implement `core` in full. Every other profile is OPTIONAL, and MUST be implemented in full or not at all — partial profiles defeat the purpose of advertising them.

An implementation MUST reject an operator it does not support with an `unsupported-operator` problem (§8). It MUST NOT silently ignore the clause: dropping a predicate from a filter widens the result set, which is the most dangerous possible failure mode for an authorization-adjacent filter.
An implementation MUST reject an operator it does not support with an `unsupported-operator` error (§8). It MUST NOT silently ignore the clause: dropping a predicate from a filter widens the result set, which is the most dangerous possible failure mode for an authorization-adjacent filter.

### 2.2 Capability discovery

Expand Down Expand Up @@ -128,7 +128,7 @@ Inside `$some` and `$every` (§5.8) the paths of the nested condition resolve ag

### 3.5 Which paths are queryable

The schema's default path rule is permissive by design: the set of queryable fields is a property of the resource, not of the language. An implementation MUST reject a path it does not expose with an `unknown-field` problem (§8), and SHOULD publish the accepted set through §2.2. Endpoints that want the field set enforced by schema validation can narrow `$defs/FieldPath` in a bundled copy — see README §*Restricting the queryable field set*.
The schema's default path rule is permissive by design: the set of queryable fields is a property of the resource, not of the language. An implementation MUST reject a path it does not expose with an `unknown-field` error (§8), and SHOULD publish the accepted set through §2.2. Endpoints that want the field set enforced by schema validation can narrow `$defs/FieldPath` in a bundled copy — see README §*Restricting the queryable field set*.

An index suffix addresses the elements of a path rather than a member beneath it, so `items[0]` is the field `items` for this purpose: exposing `items` exposes `items[0]`. A **named** member beneath it (`items[0].sku`) is a separate path and MUST be exposed on its own.

Expand Down Expand Up @@ -200,7 +200,7 @@ A type mismatch resolves differently for equality than for ordering, and the dif

`$exists`, `$isNull` and `$type` are total over types by construction and never UNKNOWN for this reason.

This is a deliberate departure from SQL, where `'18' > 17` may or may not succeed depending on the engine. Servers that need coercion (a date column queried with a string, for instance) SHOULD perform it at the *boundary* — mapping the operand into the field's declared type once, before evaluation — and MUST reject an operand that cannot be mapped with an `invalid-operand` problem (§8) rather than evaluating it as UNKNOWN.
This is a deliberate departure from SQL, where `'18' > 17` may or may not succeed depending on the engine. Servers that need coercion (a date column queried with a string, for instance) SHOULD perform it at the *boundary* — mapping the operand into the field's declared type once, before evaluation — and MUST reject an operand that cannot be mapped with an `invalid-operand` error (§8) rather than evaluating it as UNKNOWN.

### 4.4 Implicit AND

Expand Down Expand Up @@ -447,17 +447,19 @@ Fields backed by unindexed storage are their own denial-of-service surface. An i

## 8. Errors

A rejected filter MUST be reported with [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) Problem Details, media type `application/problem+json`, status `400 Bad Request`.
A rejected filter MUST be answered with status `400 Bad Request`, and the response MUST tell the client which of these conditions applies. Which one it is decides what the client does next, so an error that says only "bad request" is not conforming.

| `type` (relative to `https://christosgkoros.com/json/query-language/problems/`) | Meaning |
| Condition | Meaning |
| --- | --- |
| `malformed-query` | The body does not conform to the schema: unknown operator, wrong operand type, structural error. |
| `unknown-field` | The path is well-formed but this endpoint does not expose it. |
| `unsupported-operator` | The operator is part of the language but not of this endpoint's profiles. |
| `invalid-operand` | The operator is supported but the operand is not usable: an uncompilable `$regex`, a malformed `$like` escape, a value outside the field's domain. |
| `query-too-complex` | A limit from §7 was exceeded. |

The problem object SHOULD carry a **`pointer`** extension member: an [RFC 6901](https://www.rfc-editor.org/rfc/rfc6901) JSON Pointer into the request body, locating the offending clause. Without it a client faced with a deeply nested filter has no way to know which clause to fix.
**The wire format is the API's own.** This specification mandates the conditions, not an envelope: an API that already has an error format SHOULD express them in it rather than carry a second format for one endpoint. Where there is no established format, [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) Problem Details with media type `application/problem+json` is RECOMMENDED, taking each condition as a `type` URI relative to `https://christosgkoros.com/json/query-language/problems/`. The examples below use it.

However it is encoded, the error SHOULD locate the offending clause with a **`pointer`**: an [RFC 6901](https://www.rfc-editor.org/rfc/rfc6901) JSON Pointer into the request body. Without it a client faced with a deeply nested filter has no way to know which clause to fix.

```http
HTTP/1.1 400 Bad Request
Expand All @@ -473,7 +475,7 @@ Content-Type: application/problem+json
}
```

A problem SHOULD carry whatever the client needs to build a correct filter on its next attempt, not only a statement of what was wrong:
An error SHOULD carry whatever the client needs to build a correct filter on its next attempt, not only a statement of what was wrong. The member names below are the RECOMMENDED ones; an API expressing these in its own envelope keeps the information and adapts the naming:

- `unknown-field` SHOULD carry a **`queryableFields`** member listing the paths this endpoint does expose. Without it, a client that guessed one field name wrong has no way to converge except by guessing again.
- `unsupported-operator` SHOULD name the endpoint's advertised profiles, as above.
Expand Down
4 changes: 3 additions & 1 deletion examples/openapi-3.1-post-search.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ components:
items:
$ref: '#/components/schemas/Pet'

# RFC 9457 Problem Details. See SPEC.md §8 for the registered type URIs.
# SPEC.md §8 mandates the five error conditions, not a wire format; these
# documents use the RFC 9457 Problem Details shape it recommends as a default.
# See SPEC.md §8 for the type URIs.
Problem:
type: object
required: [type, title, status]
Expand Down
2 changes: 2 additions & 0 deletions examples/openapi-3.2-query-method.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ components:
items:
$ref: '#/components/schemas/Pet'

# SPEC.md §8 mandates the five error conditions, not a wire format; these
# documents use the RFC 9457 Problem Details shape it recommends as a default.
Problem:
type: object
required: [type, title, status]
Expand Down
7 changes: 4 additions & 3 deletions experiments/filter-to-sql/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,10 +231,11 @@ paid for themselves repeatedly:
`x-profiles` out of the grammar rather than restating it, so operator gating is
a map lookup and a membership test that cannot drift from the published
document. The §7 limits are two counters and a length check — about 25 lines,
most of it the problem objects, for the whole `query-too-complex` story.
most of it the error objects, for the whole `query-too-complex` story.

**5. The error model falls out of the recursion.** SPEC §8 wants an RFC 9457
problem carrying a JSON Pointer to the offending clause. The compiler is
**5. The error model falls out of the recursion.** SPEC §8 wants the failing
condition named and a JSON Pointer to the offending clause; this compiler
carries both in the RFC 9457 shape §8 recommends. The compiler is
already recursing with that pointer to look things up, so every `throw` gets it
for free, and `unknown-field` can attach `queryableFields` because the binding
*is* the list. This is the rare specification requirement that costs less to
Expand Down
2 changes: 1 addition & 1 deletion experiments/filter-to-sql/compile.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const PROBLEM_TITLES = {
"query-too-complex": "Query too complex",
};

/** An RFC 9457 problem in flight, per SPEC §8. */
/** A SPEC §8 rejection in flight, carried in the RFC 9457 shape §8 recommends. */
export class QueryProblem extends Error {
constructor(type, detail, pointer, extra = {}) {
super(detail);
Expand Down
2 changes: 1 addition & 1 deletion tools/generate-filter-schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,7 @@ function emitQuantifierTarget(ctx, field, prefix) {
* Emits a Filter over one object schema: logical operators plus one property
* per queryable path. `additionalProperties: false` over an explicit property
* list is what makes an unknown field a validation error rather than a runtime
* `unknown-field` problem.
* `unknown-field` error.
*/
function emitFilter(ctx, node, name, state) {
if (name !== "__root__") ctx.reserved.add(name);
Expand Down
Loading