diff --git a/CHANGELOG.md b/CHANGELOG.md index 8840f88..b74f07b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,51 @@ Versioning follows Semantic Versioning with preview suffix `major.minor.patch-pr --- +## [0.1.0-preview.0.2.0] - 2026-05-25 + +This release establishes the **per-row predicate family (`each*`)** as a first-class, type-distinct set of expressions. All `each*` operators now return a per-row boolean *column* (`booleanArrayReturning`), not a single boolean — making PureQL's type system express the row-vs-scalar distinction that SQL hides. This is purely additive on top of `0.1.0-preview.0.1.0`: no operator existing in that release was removed or renamed in a user-visible way. + +### Added + +- **`eachEqual` operator** for all seven comparable types (`boolean`, `number`, `string`, `date`, `time`, `datetime`, `uuid`). Per-row equality between an array-returning expression (typically a field) on `left` and either a single value or another array-returning expression on `right`. Replaces the previous idiom of writing `equal` with a single-element array literal (which kept working but was semantically a whole-sequence comparison, not a per-row one). +- **`eachAnd`, `eachOr`, `eachNot` operators**. Element-wise boolean composition over `booleanArrayReturning` operands; result is itself `booleanArrayReturning`. `eachAnd.conditions` and `eachOr.conditions` are arrays with `minItems: 1`; `eachNot.condition` is a single `booleanArrayReturning`. Use these to compose multiple `each*` predicates inside `where` or `join.on`. +- **Field-to-field per-row comparisons.** The `right` operand of every `each*` comparison now accepts either `*Returning` (a scalar/parameter/aggregate, broadcast to every row) **or** `*ArrayReturning` (another field, compared element-wise). Enables predicates like `order_items.unit_price > order_items.sale_price` directly, without aggregates or workarounds. See `samples/14_each_field_to_field.json`. +- **`where` and `joinItem.on` now accept `booleanArrayReturning`** in addition to `booleanReturning`. This lets `each*` predicates appear directly as the top of a `where` clause or a join condition without a wrapping `and`/`or`. +- New schema definitions: `eachComparison` and `eachEquality` unions; `eachComparisons`, `eachEqualities`, and `eachBooleanOperations` groups. All are referenced from `booleanArrayReturning`. +- Four new reference samples: + - `13_range_filter.json` — `eachGreaterThan` / `eachLessThan` combined with `eachAnd`. + - `14_each_field_to_field.json` — per-row range comparison between two fields (no scalar threshold). + - `15_each_not_equal.json` — `eachNot(eachEqual(...))`, the canonical idiom for "field ≠ literal". + - `16_each_or_composition.json` — `eachAnd` + `eachOr` mixing `eachEqual` and `eachGreaterThan` of different types. + +### Changed + +- **Sample migration to the new convention.** Samples 03–06, 10–12 previously expressed per-row field-to-literal filtering as `equal` with a single-element array literal on `right` (e.g. `{ "type": "stringArray", "value": ["active"] }`). They now use `eachEqual` with an unwrapped scalar (`{ "type": "string", "value": "active" }`). Join conditions in samples 05, 10, 11, 12 likewise switched from `equal` to `eachEqual`. Parameter samples in 10 and 12 dropped their array wrappers (`stringArray` parameter → `string` parameter, etc.). The schema still accepts the old shape — this is a recommended-style migration, not a forced one. +- **README documentation reorganized** around the "two predicate families" model: a single-value family (`and`/`or`/`not`, `equal`, `greaterThan`, …) and a per-row family (`eachAnd`/`eachOr`/`eachNot`, `eachEqual`, `eachGreaterThan`, …). The `where` / `having` section now states explicitly that `having` accepts only single-value expressions (so non-aggregated fields can never appear there). + +### Interpreter notes + +Highlights for downstream interpreter implementations migrating from `0.1.0-preview.0.1.0` (or extending an interpreter that has never seen `each*`): + +- **Result type of `each*`**: every `each*` expression evaluates to a vector of booleans aligned with the current row set, not a single boolean. Concretely, `eachEqual(field, scalar)` produces one boolean per input row; in a SQL backend this maps to a `WHERE` predicate, in a LINQ backend to a row-level lambda body, in an in-memory backend to a `bool[]` mask. +- **`right` operand polymorphism**: `right` is now `oneOf [*Returning, *ArrayReturning]`. Implementations must dispatch on the shape: + - `*Returning` (scalar / parameter / aggregate) → evaluate once, broadcast to every row. + - `*ArrayReturning` (typically another field) → align per row and compare element-wise. In SQL, both forms collapse to the same predicate text; in evaluators that materialize columns, the broadcast vs. zip distinction matters. +- **`eachAnd` / `eachOr` semantics**: element-wise AND / OR over equal-length boolean vectors. With `minItems: 1`, a single-element `eachAnd` is the identity on its operand (interpreters may treat as a no-op). +- **`eachNot` semantics**: element-wise negation of a boolean vector. Distinct from `not`, which inverts a single boolean. +- **`where` dispatch**: the `where` value is either `booleanReturning` (single bool — include all rows if true, none if false) or `booleanArrayReturning` (per-row mask — filter by the mask). Interpreters should branch on which `oneOf` arm matched. +- **`joinItem.on` dispatch**: same polymorphism as `where`. Per-row case is the typical equi-join (`eachEqual(leftField, rightField)`); single-value case is a constant join predicate. +- **`having` is unchanged**: still `booleanReturning` only. The schema now structurally rejects placing an `each*` expression directly in `having`, which earlier could validate by accident. Any interpreter that relied on the looser rule must move row-level predicates to `where`. +- **No `eachNotEqual` operator**: by design, express "field ≠ X" as `eachNot(eachEqual(field, X))`. Interpreters can pattern-match this idiom for optimization if desired. +- **Single-value `and` / `or` / `not` are unchanged** and only accept `booleanReturning` children. Do not allow mixing the two families inside the same boolean operator; the `each*` variants exist precisely so the type system separates them. +- **`equal` (whole-sequence array equality) is unchanged**. It still validates with `*ArrayReturning` on both sides and returns a single boolean. With the new `eachEqual` available, the historical pattern of `equal(field, [singleValue])` should be read as "are these two sequences equal as wholes" rather than "filter rows where field = singleValue" — the latter is now `eachEqual(field, singleValue)`. + +### Versioning + +Minor preview bump (`0.1.0-preview.0.1.0` → `0.1.0-preview.0.2.0`). All changes are additive at the schema level; the migration of bundled samples to the new convention is a recommended-style change, not a constraint tightening. Both `version` and `$id` updated accordingly. + +--- + ## [0.1.0-preview.0.1.0] - 2026-05-20 ### Added diff --git a/PureQL-Specification.json b/PureQL-Specification.json index cd95954..846823d 100644 --- a/PureQL-Specification.json +++ b/PureQL-Specification.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/kudima03/PureQL-Specification/releases/download/0.1.0-preview.0.1.0/PureQL-Specification.json", - "version": "0.1.0-preview.0.1.0", + "$id": "https://github.com/kudima03/PureQL-Specification/releases/download/0.1.0-preview.0.2.0/PureQL-Specification.json", + "version": "0.1.0-preview.0.2.0", "definitions": { "arrayParameters": { "stringArrayParameter": { @@ -485,6 +485,21 @@ }, { "$ref": "#/definitions/arrayParameters/booleanArrayParameter" + }, + { + "$ref": "#/definitions/eachComparison" + }, + { + "$ref": "#/definitions/eachEquality" + }, + { + "$ref": "#/definitions/eachBooleanOperations/eachAnd" + }, + { + "$ref": "#/definitions/eachBooleanOperations/eachOr" + }, + { + "$ref": "#/definitions/eachBooleanOperations/eachNot" } ] }, @@ -1796,7 +1811,14 @@ "minLength": 1 }, "on": { - "$ref": "#/definitions/booleanReturning" + "oneOf": [ + { + "$ref": "#/definitions/booleanReturning" + }, + { + "$ref": "#/definitions/booleanArrayReturning" + } + ] } } }, @@ -2382,6 +2404,446 @@ } } } + }, + "eachComparison": { + "oneOf": [ + { + "$ref": "#/definitions/eachComparisons/numericEachComparison" + }, + { + "$ref": "#/definitions/eachComparisons/stringEachComparison" + }, + { + "$ref": "#/definitions/eachComparisons/dateEachComparison" + }, + { + "$ref": "#/definitions/eachComparisons/datetimeEachComparison" + }, + { + "$ref": "#/definitions/eachComparisons/timeEachComparison" + } + ] + }, + "eachComparisons": { + "numericEachComparison": { + "type": "object", + "required": [ + "operator", + "left", + "right" + ], + "properties": { + "operator": { + "enum": [ + "eachGreaterThan", + "eachLessThan", + "eachGreaterThanOrEqual", + "eachLessThanOrEqual" + ] + }, + "left": { + "$ref": "#/definitions/numericArrayReturning" + }, + "right": { + "oneOf": [ + { + "$ref": "#/definitions/numericReturning" + }, + { + "$ref": "#/definitions/numericArrayReturning" + } + ] + } + } + }, + "stringEachComparison": { + "type": "object", + "required": [ + "operator", + "left", + "right" + ], + "properties": { + "operator": { + "enum": [ + "eachGreaterThan", + "eachLessThan", + "eachGreaterThanOrEqual", + "eachLessThanOrEqual" + ] + }, + "left": { + "$ref": "#/definitions/stringArrayReturning" + }, + "right": { + "oneOf": [ + { + "$ref": "#/definitions/stringReturning" + }, + { + "$ref": "#/definitions/stringArrayReturning" + } + ] + } + } + }, + "dateEachComparison": { + "type": "object", + "required": [ + "operator", + "left", + "right" + ], + "properties": { + "operator": { + "enum": [ + "eachGreaterThan", + "eachLessThan", + "eachGreaterThanOrEqual", + "eachLessThanOrEqual" + ] + }, + "left": { + "$ref": "#/definitions/dateArrayReturning" + }, + "right": { + "oneOf": [ + { + "$ref": "#/definitions/dateReturning" + }, + { + "$ref": "#/definitions/dateArrayReturning" + } + ] + } + } + }, + "datetimeEachComparison": { + "type": "object", + "required": [ + "operator", + "left", + "right" + ], + "properties": { + "operator": { + "enum": [ + "eachGreaterThan", + "eachLessThan", + "eachGreaterThanOrEqual", + "eachLessThanOrEqual" + ] + }, + "left": { + "$ref": "#/definitions/dateTimeArrayReturning" + }, + "right": { + "oneOf": [ + { + "$ref": "#/definitions/dateTimeReturning" + }, + { + "$ref": "#/definitions/dateTimeArrayReturning" + } + ] + } + } + }, + "timeEachComparison": { + "type": "object", + "required": [ + "operator", + "left", + "right" + ], + "properties": { + "operator": { + "enum": [ + "eachGreaterThan", + "eachLessThan", + "eachGreaterThanOrEqual", + "eachLessThanOrEqual" + ] + }, + "left": { + "$ref": "#/definitions/timeArrayReturning" + }, + "right": { + "oneOf": [ + { + "$ref": "#/definitions/timeReturning" + }, + { + "$ref": "#/definitions/timeArrayReturning" + } + ] + } + } + } + }, + "eachEquality": { + "oneOf": [ + { + "$ref": "#/definitions/eachEqualities/booleanEachEquality" + }, + { + "$ref": "#/definitions/eachEqualities/numericEachEquality" + }, + { + "$ref": "#/definitions/eachEqualities/stringEachEquality" + }, + { + "$ref": "#/definitions/eachEqualities/dateEachEquality" + }, + { + "$ref": "#/definitions/eachEqualities/timeEachEquality" + }, + { + "$ref": "#/definitions/eachEqualities/datetimeEachEquality" + }, + { + "$ref": "#/definitions/eachEqualities/uuidEachEquality" + } + ] + }, + "eachEqualities": { + "booleanEachEquality": { + "type": "object", + "required": [ + "operator", + "left", + "right" + ], + "properties": { + "operator": { + "const": "eachEqual" + }, + "left": { + "$ref": "#/definitions/booleanArrayReturning" + }, + "right": { + "oneOf": [ + { + "$ref": "#/definitions/booleanReturning" + }, + { + "$ref": "#/definitions/booleanArrayReturning" + } + ] + } + } + }, + "numericEachEquality": { + "type": "object", + "required": [ + "operator", + "left", + "right" + ], + "properties": { + "operator": { + "const": "eachEqual" + }, + "left": { + "$ref": "#/definitions/numericArrayReturning" + }, + "right": { + "oneOf": [ + { + "$ref": "#/definitions/numericReturning" + }, + { + "$ref": "#/definitions/numericArrayReturning" + } + ] + } + } + }, + "stringEachEquality": { + "type": "object", + "required": [ + "operator", + "left", + "right" + ], + "properties": { + "operator": { + "const": "eachEqual" + }, + "left": { + "$ref": "#/definitions/stringArrayReturning" + }, + "right": { + "oneOf": [ + { + "$ref": "#/definitions/stringReturning" + }, + { + "$ref": "#/definitions/stringArrayReturning" + } + ] + } + } + }, + "dateEachEquality": { + "type": "object", + "required": [ + "operator", + "left", + "right" + ], + "properties": { + "operator": { + "const": "eachEqual" + }, + "left": { + "$ref": "#/definitions/dateArrayReturning" + }, + "right": { + "oneOf": [ + { + "$ref": "#/definitions/dateReturning" + }, + { + "$ref": "#/definitions/dateArrayReturning" + } + ] + } + } + }, + "timeEachEquality": { + "type": "object", + "required": [ + "operator", + "left", + "right" + ], + "properties": { + "operator": { + "const": "eachEqual" + }, + "left": { + "$ref": "#/definitions/timeArrayReturning" + }, + "right": { + "oneOf": [ + { + "$ref": "#/definitions/timeReturning" + }, + { + "$ref": "#/definitions/timeArrayReturning" + } + ] + } + } + }, + "datetimeEachEquality": { + "type": "object", + "required": [ + "operator", + "left", + "right" + ], + "properties": { + "operator": { + "const": "eachEqual" + }, + "left": { + "$ref": "#/definitions/dateTimeArrayReturning" + }, + "right": { + "oneOf": [ + { + "$ref": "#/definitions/dateTimeReturning" + }, + { + "$ref": "#/definitions/dateTimeArrayReturning" + } + ] + } + } + }, + "uuidEachEquality": { + "type": "object", + "required": [ + "operator", + "left", + "right" + ], + "properties": { + "operator": { + "const": "eachEqual" + }, + "left": { + "$ref": "#/definitions/uuidArrayReturning" + }, + "right": { + "oneOf": [ + { + "$ref": "#/definitions/uuidReturning" + }, + { + "$ref": "#/definitions/uuidArrayReturning" + } + ] + } + } + } + }, + "eachBooleanOperations": { + "eachAnd": { + "type": "object", + "required": [ + "operator", + "conditions" + ], + "properties": { + "operator": { + "const": "eachAnd" + }, + "conditions": { + "type": "array", + "items": { + "$ref": "#/definitions/booleanArrayReturning" + }, + "minItems": 1 + } + } + }, + "eachOr": { + "type": "object", + "required": [ + "operator", + "conditions" + ], + "properties": { + "operator": { + "const": "eachOr" + }, + "conditions": { + "type": "array", + "items": { + "$ref": "#/definitions/booleanArrayReturning" + }, + "minItems": 1 + } + } + }, + "eachNot": { + "type": "object", + "required": [ + "operator", + "condition" + ], + "properties": { + "operator": { + "const": "eachNot" + }, + "condition": { + "$ref": "#/definitions/booleanArrayReturning" + } + } + } } }, "properties": { @@ -2400,7 +2862,14 @@ } }, "where": { - "$ref": "#/definitions/booleanReturning" + "oneOf": [ + { + "$ref": "#/definitions/booleanReturning" + }, + { + "$ref": "#/definitions/booleanArrayReturning" + } + ] }, "joins": { "type": "array", @@ -2433,4 +2902,4 @@ ], "title": "PureQL specification", "type": "object" -} \ No newline at end of file +} diff --git a/README.md b/README.md index 94962a8..90a7f7a 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ A field reference selects a column from an entity. The `entity` value should mat { "entity": "users", "field": "email", "type": { "name": "string" } } ``` -Fields carry their type as an **array type** in the schema — they represent a column of values. Use them in `select`, `groupBy`, `orderBy`, `join.on` conditions (via array equality), and as arguments to aggregate functions. +Fields carry their type as an **array type** in the schema — they represent a column of values. Use them in `select`, `groupBy`, `orderBy`, as operands of per-row predicates (`eachEqual`, `eachGreaterThan`, etc.), and as arguments to aggregate functions. ### Scalars @@ -101,25 +101,30 @@ Each item in `select` is a value-returning expression (field, scalar, aggregate, ### `where` / `having` -Both accept any **boolean-returning expression**: a boolean scalar, boolean parameter, boolean operation (`and`/`or`/`not`), an equality, or a comparison. +They accept different shapes because they evaluate in different scopes: + +- **`where`** is evaluated per row. It accepts either a **boolean-returning** expression (single boolean) or a **boolean-array-returning** expression (per-row boolean column). Use the `each*` family for per-row predicates against fields. +- **`having`** is evaluated per group, after `groupBy`. It accepts only a **boolean-returning** expression. Operands must reduce to a single value per group — typically aggregates compared with `greaterThan` / `equal` / etc. Per-row `each*` operators do **not** fit in `having`. + +`where` example using per-row predicates: ```json "where": { - "operator": "and", + "operator": "eachAnd", "conditions": [ - { "operator": "equal", + { "operator": "eachEqual", "left": { "entity": "orders", "field": "status", "type": { "name": "string" } }, - "right": { "type": { "name": "stringArray" }, "value": ["completed"] } }, - { "operator": "equal", + "right": { "type": { "name": "string" }, "value": "completed" } }, + { "operator": "eachEqual", "left": { "entity": "orders", "field": "is_paid", "type": { "name": "boolean" } }, - "right": { "type": { "name": "booleanArray" }, "value": [true] } } + "right": { "type": { "name": "boolean" }, "value": true } } ] } ``` ### `joins` -Each join specifies its type (`inner`, `left`, `right`, `full`), the entity to join, and a boolean `on` condition. +Each join specifies its type (`inner`, `left`, `right`, `full`), the entity to join, and an `on` condition. Like `where`, `on` accepts either a **boolean-returning** or a **boolean-array-returning** expression. For column-to-column matching (the typical join), use `eachEqual`: ```json "joins": [ @@ -127,7 +132,7 @@ Each join specifies its type (`inner`, `left`, `right`, `full`), the entity to j "type": "inner", "entity": "users", "on": { - "operator": "equal", + "operator": "eachEqual", "left": { "entity": "o", "field": "user_id", "type": { "name": "uuid" } }, "right": { "entity": "users", "field": "id", "type": { "name": "uuid" } } } @@ -158,39 +163,101 @@ Both accept an array of field references. ## Operations -### Boolean operations +### Two families of predicates + +PureQL has **two parallel predicate families** that you choose between based on whether you're working with single values or with columns/rows: + +| Need | Family | Returns | Operators | +|---|---|---|---| +| Combine/compare **single-value** expressions (aggregates, scalars) | Single-value family | one boolean | `and`, `or`, `not`, `equal`, `greaterThan`, `lessThan`, … | +| Filter/compare values **per row** of a column | Per-row (`each*`) family | one boolean **per row** | `eachAnd`, `eachOr`, `eachNot`, `eachEqual`, `eachGreaterThan`, `eachLessThan`, … | + +`where` and `join.on` accept either family (they evaluate per row, but a literal `true` is also valid). `having` accepts only the single-value family. `and` / `or` / `not` themselves only accept single-value children — do not mix the two families inside the same boolean operator; use `eachAnd` / `eachOr` / `eachNot` to compose per-row predicates. + +### Single-value boolean operations | Operator | Shape | |----------|-------| -| `and` | `{ "operator": "and", "conditions": [ ...booleanExpressions ] }` | -| `or` | `{ "operator": "or", "conditions": [ ...booleanExpressions ] }` | -| `not` | `{ "operator": "not", "condition": booleanExpression }` | +| `and` | `{ "operator": "and", "conditions": [ ...booleanReturning ] }` | +| `or` | `{ "operator": "or", "conditions": [ ...booleanReturning ] }` | +| `not` | `{ "operator": "not", "condition": booleanReturning }` | -### Equality +Conditions must be **single-boolean** expressions: a boolean scalar, parameter, single-value equality, or single-value comparison. Typical use: combining aggregate comparisons in `having`. + +```json +"having": { + "operator": "and", + "conditions": [ + { "operator": "greaterThan", + "left": { "operator": "count", "arg": { "entity": "orders", "field": "id", "type": { "name": "uuid" } } }, + "right": { "type": { "name": "number" }, "value": 5 } } + ] +} +``` -`equal` compares two values of the same type. To compare a **field** to a literal, use an array scalar on the right-hand side. +### Per-row boolean operations (`eachAnd` / `eachOr` / `eachNot`) + +| Operator | Shape | +|-----------|-------| +| `eachAnd` | `{ "operator": "eachAnd", "conditions": [ ...booleanArrayReturning ] }` | +| `eachOr` | `{ "operator": "eachOr", "conditions": [ ...booleanArrayReturning ] }` | +| `eachNot` | `{ "operator": "eachNot", "condition": booleanArrayReturning }` | + +These combine per-row boolean columns element-wise. The result is also a per-row boolean column. Use them to compose multiple `each*` predicates in `where` or `join.on`. There is no dedicated `eachNotEqual` — express it as `eachNot(eachEqual(...))`. + +### Single-value equality (`equal`) + +Compares two **single-value** expressions of the same type and returns one boolean. Useful in `having` against aggregates, or anywhere both operands reduce to scalars. ```json { "operator": "equal", - "left": { "entity": "users", "field": "role", "type": { "name": "string" } }, - "right": { "type": { "name": "stringArray" }, "value": ["admin"] } + "left": { "operator": "max_number", "arg": { "entity": "orders", "field": "total", "type": { "name": "number" } } }, + "right": { "param_name": "target_max", "type": { "name": "number" } } } ``` -To compare two fields (e.g. in a join condition): +### Whole-sequence equality (`equal` on arrays) + +The same `equal` operator, when both sides are array-returning expressions of the same type, asks **"are these two sequences equal as wholes?"** and returns one boolean. This is rarely needed; most "field equals value" filtering should use `eachEqual` instead. ```json { "operator": "equal", + "left": { "param_name": "expected_ids", "type": { "name": "uuidArray" } }, + "right": { "param_name": "received_ids", "type": { "name": "uuidArray" } } +} +``` + +### Per-row equality (`eachEqual`) + +For each row, returns `true` when `left` equals `right`. The `left` operand is an **array-returning** expression (typically a field). The `right` operand is either a **single-value-returning** expression (the threshold/literal case) or another **array-returning** expression (element-wise field-to-field comparison). + +Supported types: `boolean`, `number`, `string`, `date`, `time`, `datetime`, `uuid`. + +Field-to-literal: + +```json +{ + "operator": "eachEqual", + "left": { "entity": "users", "field": "role", "type": { "name": "string" } }, + "right": { "type": { "name": "string" }, "value": "admin" } +} +``` + +Field-to-field (per-row, used in joins or cross-column filters): + +```json +{ + "operator": "eachEqual", "left": { "entity": "orders", "field": "user_id", "type": { "name": "uuid" } }, "right": { "entity": "users", "field": "id", "type": { "name": "uuid" } } } ``` -### Comparison operators +### Single-value range comparisons -Comparisons work on **single-value-returning** expressions (scalars, parameters, aggregates). They return a boolean. +Range comparisons on **single-value-returning** expressions (scalars, parameters, aggregates). They return a boolean. | Operator | Meaning | |----------------------|---------| @@ -209,6 +276,39 @@ Supported types: `number`, `string`, `date`, `time`, `datetime`. } ``` +### Per-row range comparisons (`each*`) + +Per-row analogues of the range comparisons. `left` is array-returning (typically a field), `right` is either single-value-returning (one threshold for all rows) or array-returning (element-wise field-to-field). + +| Operator | Meaning | +|--------------------------|---------| +| `eachGreaterThan` | `>` | +| `eachLessThan` | `<` | +| `eachGreaterThanOrEqual` | `>=` | +| `eachLessThanOrEqual` | `<=` | + +Supported types: `number`, `string`, `date`, `time`, `datetime`. + +Field vs literal: + +```json +{ + "operator": "eachGreaterThan", + "left": { "entity": "orders", "field": "total", "type": { "name": "number" } }, + "right": { "type": { "name": "number" }, "value": 100 } +} +``` + +Field vs field (per-row): + +```json +{ + "operator": "eachGreaterThan", + "left": { "entity": "order_items", "field": "unit_price", "type": { "name": "number" } }, + "right": { "entity": "order_items", "field": "sale_price", "type": { "name": "number" } } +} +``` + ### Arithmetic operators Arithmetic works on **numeric single-value-returning** expressions. Use aggregates to bridge field data into arithmetic. @@ -272,13 +372,17 @@ The [`samples/`](samples/) directory contains query examples ordered by complexi |------|-------------| | [`01_simple_select.json`](samples/01_simple_select.json) | Select several fields from a single entity | | [`02_aliases_and_pagination.json`](samples/02_aliases_and_pagination.json) | `from` alias, field aliases, and `pagination` | -| [`03_where_equality.json`](samples/03_where_equality.json) | Filter rows with a single `equal` condition | -| [`04_boolean_logic.json`](samples/04_boolean_logic.json) | Nested `and` / `or` / `not` conditions | -| [`05_joins.json`](samples/05_joins.json) | `inner` join and `left` join in one query | +| [`03_where_equality.json`](samples/03_where_equality.json) | Filter rows with a single `eachEqual` condition | +| [`04_boolean_logic.json`](samples/04_boolean_logic.json) | Nested `eachAnd` / `eachOr` / `eachNot` over field equalities | +| [`05_joins.json`](samples/05_joins.json) | `inner` and `left` joins using `eachEqual` for per-row key matching | | [`06_count_aggregate.json`](samples/06_count_aggregate.json) | `count` aggregate with a `where` filter | | [`07_group_by.json`](samples/07_group_by.json) | Multiple aggregates with `groupBy` | -| [`08_having.json`](samples/08_having.json) | `having` clause with `and` of two comparisons | +| [`08_having.json`](samples/08_having.json) | `having` clause with `and` of two aggregate comparisons | | [`09_arithmetic.json`](samples/09_arithmetic.json) | `add`, `multiply`, `divide` on aggregate results | -| [`10_parameters.json`](samples/10_parameters.json) | Named parameters for dynamic query execution | +| [`10_parameters.json`](samples/10_parameters.json) | Named scalar parameters in per-row predicates | | [`11_distinct.json`](samples/11_distinct.json) | `distinct: true` to deduplicate results | -| [`12_complex_query.json`](samples/12_complex_query.json) | Full query: joins, where, groupBy, having, arithmetic, parameters, orderBy, pagination | +| [`12_complex_query.json`](samples/12_complex_query.json) | Full query: joins, per-row `where`, groupBy, single-value `having`, arithmetic, parameters, orderBy, pagination | +| [`13_range_filter.json`](samples/13_range_filter.json) | `eachGreaterThan` and `eachLessThan` combined with `eachAnd` | +| [`14_each_field_to_field.json`](samples/14_each_field_to_field.json) | Per-row range comparison between two fields (no scalar threshold) | +| [`15_each_not_equal.json`](samples/15_each_not_equal.json) | `eachNot` wrapping `eachEqual` — the idiom for "field ≠ literal" | +| [`16_each_or_composition.json`](samples/16_each_or_composition.json) | Mixing `eachEqual` and `eachGreaterThan` via `eachAnd` + `eachOr` | diff --git a/samples/03_where_equality.json b/samples/03_where_equality.json index 8b633ce..3b82568 100644 --- a/samples/03_where_equality.json +++ b/samples/03_where_equality.json @@ -6,8 +6,8 @@ { "entity": "users", "field": "email", "type": { "name": "string" } } ], "where": { - "operator": "equal", + "operator": "eachEqual", "left": { "entity": "users", "field": "status", "type": { "name": "string" } }, - "right": { "type": { "name": "stringArray" }, "value": ["active"] } + "right": { "type": { "name": "string" }, "value": "active" } } } diff --git a/samples/04_boolean_logic.json b/samples/04_boolean_logic.json index b8dc410..b504438 100644 --- a/samples/04_boolean_logic.json +++ b/samples/04_boolean_logic.json @@ -8,33 +8,33 @@ { "entity": "products", "field": "price", "type": { "name": "number" } } ], "where": { - "operator": "and", + "operator": "eachAnd", "conditions": [ { - "operator": "equal", + "operator": "eachEqual", "left": { "entity": "products", "field": "category", "type": { "name": "string" } }, - "right": { "type": { "name": "stringArray" }, "value": ["electronics"] } + "right": { "type": { "name": "string" }, "value": "electronics" } }, { - "operator": "not", + "operator": "eachNot", "condition": { - "operator": "equal", + "operator": "eachEqual", "left": { "entity": "products", "field": "is_discontinued", "type": { "name": "boolean" } }, - "right": { "type": { "name": "booleanArray" }, "value": [true] } + "right": { "type": { "name": "boolean" }, "value": true } } }, { - "operator": "or", + "operator": "eachOr", "conditions": [ { - "operator": "equal", + "operator": "eachEqual", "left": { "entity": "products", "field": "brand", "type": { "name": "string" } }, - "right": { "type": { "name": "stringArray" }, "value": ["Sony"] } + "right": { "type": { "name": "string" }, "value": "Sony" } }, { - "operator": "equal", + "operator": "eachEqual", "left": { "entity": "products", "field": "brand", "type": { "name": "string" } }, - "right": { "type": { "name": "stringArray" }, "value": ["Samsung"] } + "right": { "type": { "name": "string" }, "value": "Samsung" } } ] } diff --git a/samples/05_joins.json b/samples/05_joins.json index d7d70ff..a0e03d2 100644 --- a/samples/05_joins.json +++ b/samples/05_joins.json @@ -12,7 +12,7 @@ "type": "inner", "entity": "users", "on": { - "operator": "equal", + "operator": "eachEqual", "left": { "entity": "o", "field": "user_id", "type": { "name": "uuid" } }, "right": { "entity": "users", "field": "id", "type": { "name": "uuid" } } } @@ -21,7 +21,7 @@ "type": "left", "entity": "coupons", "on": { - "operator": "equal", + "operator": "eachEqual", "left": { "entity": "o", "field": "coupon_id", "type": { "name": "uuid" } }, "right": { "entity": "coupons", "field": "id", "type": { "name": "uuid" } } } diff --git a/samples/06_count_aggregate.json b/samples/06_count_aggregate.json index 6bf4e28..01ae6cc 100644 --- a/samples/06_count_aggregate.json +++ b/samples/06_count_aggregate.json @@ -8,8 +8,8 @@ } ], "where": { - "operator": "equal", + "operator": "eachEqual", "left": { "entity": "orders", "field": "status", "type": { "name": "string" } }, - "right": { "type": { "name": "stringArray" }, "value": ["completed"] } + "right": { "type": { "name": "string" }, "value": "completed" } } } diff --git a/samples/10_parameters.json b/samples/10_parameters.json index df9c12f..25f7432 100644 --- a/samples/10_parameters.json +++ b/samples/10_parameters.json @@ -12,29 +12,29 @@ "type": "inner", "entity": "users", "on": { - "operator": "equal", + "operator": "eachEqual", "left": { "entity": "o", "field": "user_id", "type": { "name": "uuid" } }, "right": { "entity": "users", "field": "id", "type": { "name": "uuid" } } } } ], "where": { - "operator": "and", + "operator": "eachAnd", "conditions": [ { - "operator": "equal", + "operator": "eachEqual", "left": { "entity": "o", "field": "status", "type": { "name": "string" } }, - "right": { "param_name": "order_status", "type": { "name": "stringArray" } } + "right": { "param_name": "order_status", "type": { "name": "string" } } }, { - "operator": "equal", + "operator": "eachEqual", "left": { "entity": "users", "field": "is_verified", "type": { "name": "boolean" } }, - "right": { "param_name": "only_verified", "type": { "name": "booleanArray" } } + "right": { "param_name": "only_verified", "type": { "name": "boolean" } } }, { - "operator": "equal", + "operator": "eachEqual", "left": { "entity": "o", "field": "order_date", "type": { "name": "date" } }, - "right": { "param_name": "target_date", "type": { "name": "dateArray" } } + "right": { "param_name": "target_date", "type": { "name": "date" } } } ] }, diff --git a/samples/11_distinct.json b/samples/11_distinct.json index a057daa..ffe6b70 100644 --- a/samples/11_distinct.json +++ b/samples/11_distinct.json @@ -11,18 +11,15 @@ "type": "inner", "entity": "products", "on": { - "operator": "equal", + "operator": "eachEqual", "left": { "entity": "oi", "field": "product_id", "type": { "name": "uuid" } }, "right": { "entity": "products", "field": "id", "type": { "name": "uuid" } } } } ], "where": { - "operator": "equal", + "operator": "eachEqual", "left": { "entity": "oi", "field": "order_id", "type": { "name": "uuid" } }, - "right": { - "type": { "name": "uuidArray" }, - "value": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"] - } + "right": { "type": { "name": "uuid" }, "value": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } } } diff --git a/samples/12_complex_query.json b/samples/12_complex_query.json index 8b1f30a..9255a17 100644 --- a/samples/12_complex_query.json +++ b/samples/12_complex_query.json @@ -47,7 +47,7 @@ "type": "inner", "entity": "users", "on": { - "operator": "equal", + "operator": "eachEqual", "left": { "entity": "o", "field": "user_id", "type": { "name": "uuid" } }, "right": { "entity": "users", "field": "id", "type": { "name": "uuid" } } } @@ -56,32 +56,32 @@ "type": "left", "entity": "referrals", "on": { - "operator": "equal", + "operator": "eachEqual", "left": { "entity": "users", "field": "id", "type": { "name": "uuid" } }, "right": { "entity": "referrals","field": "referred_id", "type": { "name": "uuid" } } } } ], "where": { - "operator": "and", + "operator": "eachAnd", "conditions": [ { - "operator": "equal", + "operator": "eachEqual", "left": { "entity": "users", "field": "is_active", "type": { "name": "boolean" } }, - "right": { "type": { "name": "booleanArray" }, "value": [true] } + "right": { "type": { "name": "boolean" }, "value": true } }, { - "operator": "not", + "operator": "eachNot", "condition": { - "operator": "equal", + "operator": "eachEqual", "left": { "entity": "users", "field": "role", "type": { "name": "string" } }, - "right": { "type": { "name": "stringArray" }, "value": ["banned"] } + "right": { "type": { "name": "string" }, "value": "banned" } } }, { - "operator": "equal", + "operator": "eachEqual", "left": { "entity": "o", "field": "status", "type": { "name": "string" } }, - "right": { "param_name": "order_status", "type": { "name": "stringArray" } } + "right": { "param_name": "order_status", "type": { "name": "string" } } } ] }, diff --git a/samples/13_range_filter.json b/samples/13_range_filter.json new file mode 100644 index 0000000..43c59b0 --- /dev/null +++ b/samples/13_range_filter.json @@ -0,0 +1,23 @@ +{ + "from": { "entity": "orders" }, + "select": [ + { "entity": "orders", "field": "id", "type": { "name": "uuid" } }, + { "entity": "orders", "field": "total", "type": { "name": "number" } }, + { "entity": "orders", "field": "created_at", "type": { "name": "datetime" } } + ], + "where": { + "operator": "eachAnd", + "conditions": [ + { + "operator": "eachGreaterThan", + "left": { "entity": "orders", "field": "total", "type": { "name": "number" } }, + "right": { "type": { "name": "number" }, "value": 100 } + }, + { + "operator": "eachLessThan", + "left": { "entity": "orders", "field": "created_at", "type": { "name": "datetime" } }, + "right": { "type": { "name": "datetime" }, "value": "2024-01-01T00:00:00Z" } + } + ] + } +} diff --git a/samples/14_each_field_to_field.json b/samples/14_each_field_to_field.json new file mode 100644 index 0000000..4f424c2 --- /dev/null +++ b/samples/14_each_field_to_field.json @@ -0,0 +1,14 @@ +{ + "from": { "entity": "order_items" }, + "select": [ + { "entity": "order_items", "field": "id", "type": { "name": "uuid" } }, + { "entity": "order_items", "field": "product_id", "type": { "name": "uuid" } }, + { "entity": "order_items", "field": "unit_price", "type": { "name": "number" } }, + { "entity": "order_items", "field": "sale_price", "type": { "name": "number" } } + ], + "where": { + "operator": "eachGreaterThan", + "left": { "entity": "order_items", "field": "unit_price", "type": { "name": "number" } }, + "right": { "entity": "order_items", "field": "sale_price", "type": { "name": "number" } } + } +} diff --git a/samples/15_each_not_equal.json b/samples/15_each_not_equal.json new file mode 100644 index 0000000..6ad88bc --- /dev/null +++ b/samples/15_each_not_equal.json @@ -0,0 +1,16 @@ +{ + "from": { "entity": "products" }, + "select": [ + { "entity": "products", "field": "id", "type": { "name": "uuid" } }, + { "entity": "products", "field": "name", "type": { "name": "string" } }, + { "entity": "products", "field": "category", "type": { "name": "string" } } + ], + "where": { + "operator": "eachNot", + "condition": { + "operator": "eachEqual", + "left": { "entity": "products", "field": "category", "type": { "name": "string" } }, + "right": { "type": { "name": "string" }, "value": "discontinued" } + } + } +} diff --git a/samples/16_each_or_composition.json b/samples/16_each_or_composition.json new file mode 100644 index 0000000..f92c86a --- /dev/null +++ b/samples/16_each_or_composition.json @@ -0,0 +1,34 @@ +{ + "from": { "entity": "orders" }, + "select": [ + { "entity": "orders", "field": "id", "type": { "name": "uuid" } }, + { "entity": "orders", "field": "status", "type": { "name": "string" } }, + { "entity": "orders", "field": "total_amount", "type": { "name": "number" } }, + { "entity": "orders", "field": "created_at", "type": { "name": "datetime" } } + ], + "where": { + "operator": "eachAnd", + "conditions": [ + { + "operator": "eachOr", + "conditions": [ + { + "operator": "eachEqual", + "left": { "entity": "orders", "field": "status", "type": { "name": "string" } }, + "right": { "type": { "name": "string" }, "value": "pending" } + }, + { + "operator": "eachEqual", + "left": { "entity": "orders", "field": "status", "type": { "name": "string" } }, + "right": { "type": { "name": "string" }, "value": "processing" } + } + ] + }, + { + "operator": "eachGreaterThan", + "left": { "entity": "orders", "field": "total_amount", "type": { "name": "number" } }, + "right": { "type": { "name": "number" }, "value": 50 } + } + ] + } +}