From e0cfdd9e9215ce1eca500197a5968899f2703849 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 17 Oct 2026 23:00:43 +1100 Subject: [PATCH 1/6] feat(eql): give EQL v4 range query domains and eql_v4.between ADR-0009 decision 7 makes a Between query one term holding both bounds, and asks that it stay one operand in SQL. EQL v4 had no domain to carry it and no function to apply it, so a Between term had nothing to bind to but two comparisons, which loses the one-term guarantee (#1237). Every ordered family now has one range query domain per order term, beside its single-bound ones (decision 8): eql_v4.query_integer_range_ore beside query_integer_ord_ore, query_integer_range_ope beside query_integer_ord_ope, and the same for smallint, bigint, date, timestamp, numeric, text, real and double. A range is {"v": 4, "i": ..., "lo": , "hi": }: each bound is the value the single-bound domain holds under ob or op, which is Stack Encrypt's RangeQuery { lo, hi } with nothing added. The CHECK requires both bounds in their term's shape and forbids c. eql_v4.between(column, range) has an overload for every ordered column domain against the range of the term it stores, and is ord_term(a) >= lo(r) AND ord_term(a) <= hi(r): LANGUAGE sql, IMMUTABLE, not STRICT, no SET clause, so PostgreSQL inlines it and a btree on the column's order term serves it as one range scan. lo and hi read a bound with the term's own constructor, as a single-bound term is read; they are in eql_v4_internal, since only between calls them. An ORE column's index needs the ore_block_256 btree opclass, which a non-superuser install skips; the docs say to use an OPE column there. All of it is generated from the catalog (Term::range_suffix, DomainFamily::range_terms / range_columns, RANGE_BOUND_KEYS) by a new eql-codegen range module, as are the bindings: a v4 struct per range domain (IntegerRangeOreQuery, ..., v4::all_range_query()) and a Go type of the same name in encrypt/eql, so a range binds as one parameter. EQL v3 does not change. One source tree renders both versions, so the range files carry a new directive, `-- ONLY EQL VERSION: 4`. The build selects each version's files with `eql-codegen select` before rendering (which refuses a kept file that requires a dropped one, so each order stays closed under REQUIRE), `render` refuses to render a confined file as the other version, the installer completeness gate filters its disk side with its own grep, the symbol-order checker now runs on the v4 order too, and the Doxygen filter reads a confined file from its version's rendered tree. The v3 installer and both uninstallers are byte-identical to the base; the v4 installer only gains lines. The ordered EQL types are not producible yet (#1132, #1236), so the PostgreSQL tests (tests/encryption/tests/v4_range.rs, run by test:encryption:postgres) take their order terms from cipherstash-client's ORE and OPE indexers over FakeDataKeySource's index key, and the rest of each value from a real Stack Encrypt Integer or TextEq. They cover rows inside, outside and at both bounds for ORE and OPE over integers and text, inverted and NULL ranges, a range of the wrong term, the CHECKs, and an EXPLAIN showing the order-term index serving between with both bounds in its index condition. --- .changeset/eql-v4-bindings.md | 2 + languages/golang/CHANGELOG.md | 10 + languages/golang/encrypt/README.md | 7 +- languages/golang/encrypt/eql/eql_gen.go | 432 ++++++++++++ packages/eql/AGENTS.md | 8 +- packages/eql/crates/eql-bindings/CHANGELOG.md | 12 + packages/eql/crates/eql-bindings/README.md | 9 + .../eql/crates/eql-bindings/src/v4/bigint.rs | 84 +++ .../eql/crates/eql-bindings/src/v4/date.rs | 84 +++ .../eql/crates/eql-bindings/src/v4/double.rs | 84 +++ .../eql/crates/eql-bindings/src/v4/integer.rs | 84 +++ .../crates/eql-bindings/src/v4/inventory.rs | 27 +- .../eql/crates/eql-bindings/src/v4/mod.rs | 7 +- .../eql/crates/eql-bindings/src/v4/numeric.rs | 84 +++ .../eql/crates/eql-bindings/src/v4/real.rs | 84 +++ .../crates/eql-bindings/src/v4/smallint.rs | 84 +++ .../eql/crates/eql-bindings/src/v4/text.rs | 84 +++ .../crates/eql-bindings/src/v4/timestamp.rs | 84 +++ .../eql/crates/eql-codegen/src/bindings.rs | 114 ++- .../eql/crates/eql-codegen/src/context.rs | 10 + .../eql/crates/eql-codegen/src/generate.rs | 23 +- packages/eql/crates/eql-codegen/src/go_eql.rs | 57 ++ packages/eql/crates/eql-codegen/src/lib.rs | 1 + packages/eql/crates/eql-codegen/src/main.rs | 47 ++ packages/eql/crates/eql-codegen/src/range.rs | 467 +++++++++++++ packages/eql/crates/eql-codegen/src/render.rs | 263 ++++++- .../templates/range_functions.sql.j2 | 54 ++ .../eql-codegen/templates/range_types.sql.j2 | 45 ++ packages/eql/crates/eql-codegen/tests/cli.rs | 56 ++ packages/eql/crates/eql-domains/src/lib.rs | 12 + packages/eql/crates/eql-domains/src/spec.rs | 142 +++- packages/eql/crates/eql-domains/src/term.rs | 26 + packages/eql/docs/reference/eql-functions.md | 41 ++ packages/eql/mise.toml | 4 +- .../bigint/query_bigint_range_functions.sql | 155 +++++ .../bigint/query_bigint_range_types.sql | 66 ++ .../date/query_date_range_functions.sql | 155 +++++ .../scalars/date/query_date_range_types.sql | 66 ++ .../double/query_double_range_functions.sql | 155 +++++ .../double/query_double_range_types.sql | 66 ++ .../integer/query_integer_range_functions.sql | 155 +++++ .../integer/query_integer_range_types.sql | 66 ++ .../numeric/query_numeric_range_functions.sql | 155 +++++ .../numeric/query_numeric_range_types.sql | 66 ++ .../real/query_real_range_functions.sql | 155 +++++ .../scalars/real/query_real_range_types.sql | 66 ++ .../query_smallint_range_functions.sql | 155 +++++ .../smallint/query_smallint_range_types.sql | 66 ++ .../text/query_text_range_functions.sql | 207 ++++++ .../scalars/text/query_text_range_types.sql | 66 ++ .../query_timestamp_range_functions.sql | 155 +++++ .../timestamp/query_timestamp_range_types.sql | 66 ++ packages/eql/tasks/build.sh | 40 +- packages/eql/tasks/docs/doxygen-filter.sh | 10 +- .../tasks/test/verify_installer_complete.sh | 22 +- packages/eql/tasks/test/verify_no_v3_in_v4.sh | 3 +- .../eql/tests/encryption/tests/v4_range.rs | 658 ++++++++++++++++++ 57 files changed, 5378 insertions(+), 28 deletions(-) create mode 100644 packages/eql/crates/eql-codegen/src/range.rs create mode 100644 packages/eql/crates/eql-codegen/templates/range_functions.sql.j2 create mode 100644 packages/eql/crates/eql-codegen/templates/range_types.sql.j2 create mode 100644 packages/eql/src/v3/scalars/bigint/query_bigint_range_functions.sql create mode 100644 packages/eql/src/v3/scalars/bigint/query_bigint_range_types.sql create mode 100644 packages/eql/src/v3/scalars/date/query_date_range_functions.sql create mode 100644 packages/eql/src/v3/scalars/date/query_date_range_types.sql create mode 100644 packages/eql/src/v3/scalars/double/query_double_range_functions.sql create mode 100644 packages/eql/src/v3/scalars/double/query_double_range_types.sql create mode 100644 packages/eql/src/v3/scalars/integer/query_integer_range_functions.sql create mode 100644 packages/eql/src/v3/scalars/integer/query_integer_range_types.sql create mode 100644 packages/eql/src/v3/scalars/numeric/query_numeric_range_functions.sql create mode 100644 packages/eql/src/v3/scalars/numeric/query_numeric_range_types.sql create mode 100644 packages/eql/src/v3/scalars/real/query_real_range_functions.sql create mode 100644 packages/eql/src/v3/scalars/real/query_real_range_types.sql create mode 100644 packages/eql/src/v3/scalars/smallint/query_smallint_range_functions.sql create mode 100644 packages/eql/src/v3/scalars/smallint/query_smallint_range_types.sql create mode 100644 packages/eql/src/v3/scalars/text/query_text_range_functions.sql create mode 100644 packages/eql/src/v3/scalars/text/query_text_range_types.sql create mode 100644 packages/eql/src/v3/scalars/timestamp/query_timestamp_range_functions.sql create mode 100644 packages/eql/src/v3/scalars/timestamp/query_timestamp_range_types.sql create mode 100644 packages/eql/tests/encryption/tests/v4_range.rs diff --git a/.changeset/eql-v4-bindings.md b/.changeset/eql-v4-bindings.md index 88887e454..a7bb2cc0a 100644 --- a/.changeset/eql-v4-bindings.md +++ b/.changeset/eql-v4-bindings.md @@ -4,4 +4,6 @@ **The `eql-bindings` crate has an `eql_bindings::v4` module, with the `stack-encrypt` feature: payload types for the EQL v4 domains.** EQL v4 is the v3 SQL installed under a second name (ADR-0002), for values written by Stack Encrypt, so the module exists only with that feature and its types derive Stack Encrypt's traits without a further gate. The module is generated from the same catalog as `v3`, with `eql_v4_*` domain names and `"v": 4`. `SchemaVersion` is now generic over the version it admits: `v3::SchemaVersion` is `SchemaVersion<3>` and `v4::SchemaVersion` is `SchemaVersion<4>`, each refusing the other's payloads, and plain `SchemaVersion` still means v3. Only v4 types derive Stack Encrypt's `EncryptFrom` and `DecryptInto`, and the Stack Encrypt target table (now `eql_bindings::v4::targets`) names v4 domains. Besides `TextEq`, Stack Encrypt now produces the storage-only types `Text`, `Integer`, `Bigint`, `Boolean`, `Real` and `Double`, and `TextMatch` (3-grams, `k = 6`, a 2048-bit filter, pinned as `eql_bindings::encryption::EqlMatch`; text shorter than three characters is refused, stored or queried, and only `stored @@ query` matches until #1171 is fixed), from the Rust primitives `i32`, `i64`, `bool`, `f32` and `f64`; the Go SDK's `encrypt/eql` table marks them producible. Stack Encrypt also produces the number equality types `IntegerEq`, `BigintEq`, `RealEq` and `DoubleEq`, whose `=` compares as PostgreSQL compares the plain types: an integer has one term whatever its width, and for floats `-0.0 = 0.0` and `NaN = NaN` (ADR-0002's semantic baseline). Every type it cannot produce yet names the issue that tracks its blocker. The by-name target entry points (`eql_bindings::encryption::targets::encrypt`, `decrypt` and `query`) exchange the plaintext as Stack Encrypt's `dynamic::ValueFrame`, one value as its transport frame, rather than a value tree. They, and the generated `v4::targets` table, need the new `targets` feature, which turns on `stack-encrypt` and Stack Encrypt's `dynamic` feature. `stack-encrypt` alone no longer builds Stack Encrypt's `dynamic` layer, so a Rust program using the typed v4 types through `encrypt_as` doesn't compile it. +**EQL v4 has range query domains and `eql_v4.between`, so a `BETWEEN` query is one operand.** Every ordered family has one range query domain per order term, beside its single-bound ones: `eql_v4.query_integer_range_ore` beside `eql_v4.query_integer_ord_ore`, `eql_v4.query_integer_range_ope` beside `eql_v4.query_integer_ord_ope`, and the same for `smallint`, `bigint`, `date`, `timestamp`, `numeric`, `text`, `real` and `double`. A range holds both bounds as `{"v": 4, "i": …, "lo": , "hi": }`, each bound the term the single-bound domain holds under `ob` or `op`. `eql_v4.between(column, range)` is true when the column's order term lies between the bounds, both included, and has an overload for every ordered column domain with the range of the term it stores (`_ord_ore` and `_search_ore` take the ORE range; `_ord`, `_ord_ope` and `_search` the OPE one). It is an inlinable SQL function, so a btree on the column's order term (`eql_v4.ord_term_ore(col)` or `eql_v4.ord_term(col)`) serves it as one index range scan. Write it schema-qualified: `BETWEEN` is an SQL keyword, so a bare `between(...)` does not parse. `eql_bindings::v4` has a type for each range domain (`IntegerRangeOreQuery`, `TextRangeOpeQuery`, …, listed by `v4::all_range_query()`), so a range binds as one parameter. EQL v3 has no range domains: its SQL and types are unchanged (ADR-0009, decision 7). + Nothing a released version shipped has changed. The v3 types, the v3 SQL and the TypeScript package are as they were; the `stack-encrypt` derives on the v3 text types and the `v3::targets` module were added after the last release and are moved to v4 here, not removed from a release. diff --git a/languages/golang/CHANGELOG.md b/languages/golang/CHANGELOG.md index 9d656a1aa..9f367a66b 100644 --- a/languages/golang/CHANGELOG.md +++ b/languages/golang/CHANGELOG.md @@ -56,6 +56,16 @@ listed under **Breaking**. Releases are tagged `languages/golang/vX.Y.Z`. type. A type wider than the EQL type (an `int64` into `Integer`) is range checked value by value, and `Encrypt` refuses a value that does not fit with `ErrEncoding`. `encrypt.Int128` and `Uint128` are not converted. +- **Range query types in `encrypt/eql`**, one per ordered family and order + index: `eql.IntegerRangeOreQuery`, `eql.IntegerRangeOpeQuery`, + `eql.TextRangeOreQuery`, `eql.TextRangeOpeQuery`, and the same for + `smallint`, `bigint`, `date`, `timestamp`, `numeric`, `real` and `double`. + Each holds both bounds of one `BETWEEN` as the JSON value + `eql_v4.between(column, $1::eql_v4.query__range_)` takes, + so a range binds as one parameter, and implements `driver.Valuer`, + `sql.Scanner` and JSON like the other query types. They are not in + `eql.Types`, which lists stored types, and no generated method returns one + yet: the ordered EQL types are not producible. - **`encrypt.MatchQuery` and `encrypt.EQLMatchQuery`**: what a `Matches` method returns. Each carries the term or EQL query value to bind, `Verify` (check decrypted rows, since a match can return false positives) diff --git a/languages/golang/encrypt/README.md b/languages/golang/encrypt/README.md index fe6021930..a803cb097 100644 --- a/languages/golang/encrypt/README.md +++ b/languages/golang/encrypt/README.md @@ -296,7 +296,12 @@ is refused, because `Verify` checks a match query's rows by decrypting them. Every query type implements `driver.Valuer`, so it binds as a parameter in any library built on `database/sql` or pgx. A `Range` binds as two -parameters, `Low` and `High`. +parameters, `Low` and `High`. On an EQL column a range is one value instead: +package `encrypt/eql` has a range query type per ordered family and order +index (`eql.IntegerRangeOreQuery`, `eql.TextRangeOpeQuery`, ...), the one +operand of `eql_v4.between(column, $1::eql_v4.query_integer_range_ore)`. No +generated method returns one yet, because the ordered EQL types are not +producible. `Eq`, the orderings and `Between` are exact: the server's answer is the answer. `Matches` is probabilistic: the server answers with every row whose diff --git a/languages/golang/encrypt/eql/eql_gen.go b/languages/golang/encrypt/eql/eql_gen.go index d0ea96ef9..26d99b77a 100644 --- a/languages/golang/encrypt/eql/eql_gen.go +++ b/languages/golang/encrypt/eql/eql_gen.go @@ -2205,3 +2205,435 @@ func (v JSON) MarshalJSON() ([]byte, error) { func (v *JSON) UnmarshalJSON(b []byte) error { return unmarshal((*[]byte)(v), b) } + +// IntegerRangeOreQuery is a range query value over the ore index of the integer family: both bounds of one BETWEEN, +// included, as the one operand eql_v4.query_integer_range_ore takes in eql_v4.between. It ranges over IntegerOrdOre. +type IntegerRangeOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v IntegerRangeOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v4.query_integer_range_ore column. +func (v *IntegerRangeOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "IntegerRangeOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v IntegerRangeOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "IntegerRangeOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *IntegerRangeOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// IntegerRangeOpeQuery is a range query value over the ope index of the integer family: both bounds of one BETWEEN, +// included, as the one operand eql_v4.query_integer_range_ope takes in eql_v4.between. It ranges over IntegerOrd, IntegerOrdOpe. +type IntegerRangeOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v IntegerRangeOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v4.query_integer_range_ope column. +func (v *IntegerRangeOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "IntegerRangeOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v IntegerRangeOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "IntegerRangeOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *IntegerRangeOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// SmallintRangeOreQuery is a range query value over the ore index of the smallint family: both bounds of one BETWEEN, +// included, as the one operand eql_v4.query_smallint_range_ore takes in eql_v4.between. It ranges over SmallintOrdOre. +type SmallintRangeOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v SmallintRangeOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v4.query_smallint_range_ore column. +func (v *SmallintRangeOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "SmallintRangeOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v SmallintRangeOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "SmallintRangeOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *SmallintRangeOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// SmallintRangeOpeQuery is a range query value over the ope index of the smallint family: both bounds of one BETWEEN, +// included, as the one operand eql_v4.query_smallint_range_ope takes in eql_v4.between. It ranges over SmallintOrd, SmallintOrdOpe. +type SmallintRangeOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v SmallintRangeOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v4.query_smallint_range_ope column. +func (v *SmallintRangeOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "SmallintRangeOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v SmallintRangeOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "SmallintRangeOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *SmallintRangeOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// BigintRangeOreQuery is a range query value over the ore index of the bigint family: both bounds of one BETWEEN, +// included, as the one operand eql_v4.query_bigint_range_ore takes in eql_v4.between. It ranges over BigintOrdOre. +type BigintRangeOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v BigintRangeOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v4.query_bigint_range_ore column. +func (v *BigintRangeOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "BigintRangeOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v BigintRangeOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "BigintRangeOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *BigintRangeOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// BigintRangeOpeQuery is a range query value over the ope index of the bigint family: both bounds of one BETWEEN, +// included, as the one operand eql_v4.query_bigint_range_ope takes in eql_v4.between. It ranges over BigintOrd, BigintOrdOpe. +type BigintRangeOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v BigintRangeOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v4.query_bigint_range_ope column. +func (v *BigintRangeOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "BigintRangeOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v BigintRangeOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "BigintRangeOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *BigintRangeOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DateRangeOreQuery is a range query value over the ore index of the date family: both bounds of one BETWEEN, +// included, as the one operand eql_v4.query_date_range_ore takes in eql_v4.between. It ranges over DateOrdOre. +type DateRangeOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DateRangeOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v4.query_date_range_ore column. +func (v *DateRangeOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "DateRangeOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DateRangeOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "DateRangeOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DateRangeOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DateRangeOpeQuery is a range query value over the ope index of the date family: both bounds of one BETWEEN, +// included, as the one operand eql_v4.query_date_range_ope takes in eql_v4.between. It ranges over DateOrd, DateOrdOpe. +type DateRangeOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DateRangeOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v4.query_date_range_ope column. +func (v *DateRangeOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "DateRangeOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DateRangeOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "DateRangeOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DateRangeOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TimestampRangeOreQuery is a range query value over the ore index of the timestamp family: both bounds of one BETWEEN, +// included, as the one operand eql_v4.query_timestamp_range_ore takes in eql_v4.between. It ranges over TimestampOrdOre. +type TimestampRangeOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TimestampRangeOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v4.query_timestamp_range_ore column. +func (v *TimestampRangeOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TimestampRangeOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TimestampRangeOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TimestampRangeOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TimestampRangeOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TimestampRangeOpeQuery is a range query value over the ope index of the timestamp family: both bounds of one BETWEEN, +// included, as the one operand eql_v4.query_timestamp_range_ope takes in eql_v4.between. It ranges over TimestampOrd, TimestampOrdOpe. +type TimestampRangeOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TimestampRangeOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v4.query_timestamp_range_ope column. +func (v *TimestampRangeOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TimestampRangeOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TimestampRangeOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TimestampRangeOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TimestampRangeOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// NumericRangeOreQuery is a range query value over the ore index of the numeric family: both bounds of one BETWEEN, +// included, as the one operand eql_v4.query_numeric_range_ore takes in eql_v4.between. It ranges over NumericOrdOre. +type NumericRangeOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v NumericRangeOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v4.query_numeric_range_ore column. +func (v *NumericRangeOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "NumericRangeOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v NumericRangeOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "NumericRangeOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *NumericRangeOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// NumericRangeOpeQuery is a range query value over the ope index of the numeric family: both bounds of one BETWEEN, +// included, as the one operand eql_v4.query_numeric_range_ope takes in eql_v4.between. It ranges over NumericOrd, NumericOrdOpe. +type NumericRangeOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v NumericRangeOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v4.query_numeric_range_ope column. +func (v *NumericRangeOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "NumericRangeOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v NumericRangeOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "NumericRangeOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *NumericRangeOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextRangeOreQuery is a range query value over the ore index of the text family: both bounds of one BETWEEN, +// included, as the one operand eql_v4.query_text_range_ore takes in eql_v4.between. It ranges over TextOrdOre, TextSearchOre. +type TextRangeOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextRangeOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v4.query_text_range_ore column. +func (v *TextRangeOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TextRangeOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextRangeOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TextRangeOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextRangeOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextRangeOpeQuery is a range query value over the ope index of the text family: both bounds of one BETWEEN, +// included, as the one operand eql_v4.query_text_range_ope takes in eql_v4.between. It ranges over TextOrd, TextOrdOpe, TextSearch. +type TextRangeOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextRangeOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v4.query_text_range_ope column. +func (v *TextRangeOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TextRangeOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextRangeOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TextRangeOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextRangeOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// RealRangeOreQuery is a range query value over the ore index of the real family: both bounds of one BETWEEN, +// included, as the one operand eql_v4.query_real_range_ore takes in eql_v4.between. It ranges over RealOrdOre. +type RealRangeOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v RealRangeOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v4.query_real_range_ore column. +func (v *RealRangeOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "RealRangeOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v RealRangeOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "RealRangeOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *RealRangeOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// RealRangeOpeQuery is a range query value over the ope index of the real family: both bounds of one BETWEEN, +// included, as the one operand eql_v4.query_real_range_ope takes in eql_v4.between. It ranges over RealOrd, RealOrdOpe. +type RealRangeOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v RealRangeOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v4.query_real_range_ope column. +func (v *RealRangeOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "RealRangeOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v RealRangeOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "RealRangeOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *RealRangeOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DoubleRangeOreQuery is a range query value over the ore index of the double family: both bounds of one BETWEEN, +// included, as the one operand eql_v4.query_double_range_ore takes in eql_v4.between. It ranges over DoubleOrdOre. +type DoubleRangeOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DoubleRangeOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v4.query_double_range_ore column. +func (v *DoubleRangeOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "DoubleRangeOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DoubleRangeOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "DoubleRangeOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DoubleRangeOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DoubleRangeOpeQuery is a range query value over the ope index of the double family: both bounds of one BETWEEN, +// included, as the one operand eql_v4.query_double_range_ope takes in eql_v4.between. It ranges over DoubleOrd, DoubleOrdOpe. +type DoubleRangeOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DoubleRangeOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v4.query_double_range_ope column. +func (v *DoubleRangeOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "DoubleRangeOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DoubleRangeOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "DoubleRangeOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DoubleRangeOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} diff --git a/packages/eql/AGENTS.md b/packages/eql/AGENTS.md index c63d9f676..91138de6b 100644 --- a/packages/eql/AGENTS.md +++ b/packages/eql/AGENTS.md @@ -41,7 +41,7 @@ This project uses `mise` for task management. Common commands: - Build outputs to `release/` directory (one installer + uninstaller per EQL version, both rendered from `src/v3` alone): - `cipherstash-encrypt.sql` - The `eql_v3` installer: the self-contained surface, globbed from `src/v3` only (no `eql_v2`; installable into a DB with no `eql_v2` present) - `cipherstash-encrypt-uninstall.sql` - Matching uninstaller - - `cipherstash-encrypt-v4.sql` / `cipherstash-encrypt-v4-uninstall.sql` - The same SQL as `eql_v4` (ADR-0002). `tasks/test/verify_no_v3_in_v4.sh` fails the build if either names `eql_v3` or checks for payload version 3. Both versions install side by side; `tests/sqlx/tests/v3_v4_coexistence_tests.rs` pins what that enforces, including the gap that a plain `=` across versions falls back to `jsonb = jsonb` and returns no rows + - `cipherstash-encrypt-v4.sql` / `cipherstash-encrypt-v4-uninstall.sql` - The same SQL as `eql_v4` (ADR-0002), plus the files confined to v4 (see "Files confined to one EQL version" below). `tasks/test/verify_no_v3_in_v4.sh` fails the build if either names `eql_v3` or checks for payload version 3. Both versions install side by side; `tests/sqlx/tests/v3_v4_coexistence_tests.rs` pins what that enforces, including the gap that a plain `=` across versions falls back to `jsonb = jsonb` and returns no rows #### Schema and version placeholders @@ -56,6 +56,10 @@ The SQL source never names its schemas or its payload version literally. It writ `tasks/build.sh` renders the ordered source into `build/render/eql_v3/` and `build/render/eql_v4/` (gitignored, same relative paths) with `eql-codegen render`, runs the symbol-order and installer-completeness gates on the rendered files, and assembles `release/` from them. The renderer refuses three things: an unknown placeholder, an unterminated `{{`, and a **literal `eql_v3` or `eql_v4`** anywhere in the source, comments included. A literal name renders identically for both versions, so the v4 bundle would reach into v3. Write the placeholder in prose too. +#### Files confined to one EQL version + +A source file belongs to every version unless it carries the line `-- ONLY EQL VERSION: N` (the payload version, not the schema name, which the source may not write). Today only the generated range surface does: `src/v3/scalars//query__range_types.sql` and `query__range_functions.sql` hold the range query domains (`eql_v4.query__range_ore` / `_range_ope`), their `lo` / `hi` accessors and `eql_v4.between(column, range)` (ADR-0009 decision 7), and are `-- ONLY EQL VERSION: 4`. EQL v3 is what cipherstash-client writes, nothing there produces a range, and its SQL must not change. `tasks/build.sh` runs the install order through `eql-codegen select <3|4>` before rendering each version, `eql-codegen render` refuses to render a confined file as the other version, `verify_installer_complete.sh` filters its disk side the same way with its own `grep`, the symbol-order checker runs on the v4 order too (on a copy mapped back to `eql_v3` names), and the Doxygen filter reads a confined file from its own version's rendered tree. `eql-codegen order` still orders every file, so `src/deps-ordered-v3.txt` lists the v4-only files; the per-version lists are `build/render/eql_v/deps-ordered.txt`. + Anything that needs the source as real SQL reads the rendered tree, not `src/`: `docs:validate:documented-sql` runs psql over `build/render/eql_v3/src`, and the Doxygen input filter (`tasks/docs/doxygen-filter.sh`) reads each file's rendered copy from `build/render/eql_v3/`. A new tool that parses `src/v3` must do the same, and depend on `build`; do not substitute the placeholders a second way. There are no longer separate Main / Supabase / Protect / v3-only build variants. The combined `eql_v2` build that previously produced multiple artefacts has been removed; the v3 surface now ships as one self-contained installer under the canonical `cipherstash-encrypt.sql` name (`eql-codegen order` walks `src/v3` only). Because the surface owns no `eql_v2` dependency, it is already Supabase / managed-Postgres compatible (functional indexes over extractors, no superuser-only operator classes) without a dedicated subset build. Self-containment — no `-- REQUIRE:` edge pointing outside `src/v3`, no `eql_v2.` anywhere in the surface — is enforced at build time by `surface_order`'s `OutsideSurface` error (`crates/eql-codegen/src/ordering.rs`) and CI-gated by `mise run test:self_contained_v3`. @@ -88,7 +92,7 @@ This is the **Encrypt Query Language (EQL)** - a PostgreSQL extension for search Adding a scalar encrypted-domain type is one row in the Rust catalog `eql-domains::CATALOG` (`crates/eql-domains/src/lib.rs`): a `DomainFamily` giving the type `name` (e.g. `bigint`), its `ScalarKind` (the `kind` field), the `Domain`s mapping each generated (bare) domain name to its fixed index `Term`s (`eq => [Hm]`, `ord`/`ord_ore => [Ore]`), and the `Fixture` value list. Term capabilities are fixed in the `Term` enum's `impl` methods (with unit tests): `Hm` provides equality, and `Ore` provides equality plus ordering. There is no TOML manifest and no Python — the catalog is the source of truth, validated by the compiler (an undefined term or unknown scalar is a compile error) plus catalog `#[test]`s. `mise run build` runs `cargo run -p eql-codegen`, which regenerates the scalar SQL surface into `src/v3/scalars//` from `CATALOG` at the start of every build; that surface includes supported comparison wrappers plus blockers for native `json` operators that would otherwise be reachable through domain fallback. `cargo run -p eql-codegen` regenerates every type at once (the same call `mise run build` uses; there is no per-type codegen task). The generated `*_types.sql` / `*_functions.sql` / `*_operators.sql` / `*_aggregates.sql` files are **committed in place** under `src/v3/scalars//` and drift-gated by `mise run codegen:parity` (regenerate in place + `git diff` + untracked check — the same regenerate-and-diff pattern `types:check` uses for the committed bindings). They are still machine-generated: change the catalog and rebuild, never hand-edit (CI fails on drift). The per-type plaintext fixture lists the SQLx matrix consumes are **not** a generated file — they are materialised from each `CATALOG` row at compile time as `eql_domains::INT4_VALUES` / `INT2_VALUES` (the `int_values!` macro) and read directly by `ScalarType::FIXTURE_VALUES`; a Rust source of truth no longer round-trips through a committed generated `.rs`. Generated SQL carries a `-- AUTOMATICALLY GENERATED FILE` header (the project-wide marker `docs:validate` greps on); change the catalog and rebuild, never hand-edit. Hand-written SQL beyond the fixed surface goes in `src/v3/scalars//_extensions.sql` with no auto-generated header and explicit `-- REQUIRE:` edges — that file IS committed. `json`'s **SteVec** domains never enter this pipeline, but its **scalar storage domain does**: `generate_all` iterates `eql_domains::families_with_scalar_domains()` and renders each family's scalar domains (so `public.eql_v3_json` is generated into `src/v3/scalars/json/`), while `list-types` and the SQLx matrix still iterate `eql_domains::scalar_families()`, which excludes the whole non-scalar `json` family — so `json` stays out of the operator/coverage matrix by construction (a storage-only domain carries no operator matrix), even though its ciphertext-only storage domain now has a generated SQL surface. -**EQL v4 bindings:** the generator writes the scalar domains a second time into `crates/eql-bindings/src/v4/` (`eql_v4_*` names, `v: 4` through `SchemaVersion<4>`, an `inventory.rs`), plus the Stack Encrypt target table `v4/targets.rs`. **Only v4 structs derive Stack Encrypt's `EncryptFrom`/`DecryptInto`**: Stack Encrypt's output is not byte-for-byte compatible with cipherstash-client's, so `encryption_gap` refuses every v3 domain and a codegen test asserts no v3 struct derives either. v4 has no SteVec bindings, payload enums, TypeScript export or published JSON Schema yet. The hand-written `v4/mod.rs` re-exports v3's term newtypes and `DomainType`, so a change to a term's validation in `v3/terms.rs` changes v4 too; the version boundary is the envelope's `SchemaVersion` (one generic type in `lib.rs`; `v3::SchemaVersion` and `v4::SchemaVersion` are `SchemaVersion<3>` and `SchemaVersion<4>`). +**EQL v4 bindings:** the generator writes the scalar domains a second time into `crates/eql-bindings/src/v4/` (`eql_v4_*` names, `v: 4` through `SchemaVersion<4>`, an `inventory.rs`), plus the Stack Encrypt target table `v4/targets.rs`. **Only v4 structs derive Stack Encrypt's `EncryptFrom`/`DecryptInto`**: Stack Encrypt's output is not byte-for-byte compatible with cipherstash-client's, so `encryption_gap` refuses every v3 domain and a codegen test asserts no v3 struct derives either. v4 also has one range query struct per ordered family and order term (`IntegerRangeOreQuery`, `TextRangeOpeQuery`, …: `{v, i, lo, hi}`, each bound the single-bound query twin's term newtype), listed by `v4::all_range_query()`; v3 has none, matching the SQL. v4 has no SteVec bindings, payload enums, TypeScript export or published JSON Schema yet. The hand-written `v4/mod.rs` re-exports v3's term newtypes and `DomainType`, so a change to a term's validation in `v3/terms.rs` changes v4 too; the version boundary is the envelope's `SchemaVersion` (one generic type in `lib.rs`; `v3::SchemaVersion` and `v4::SchemaVersion` are `SchemaVersion<3>` and `SchemaVersion<4>`). The same generator also emits the **Rust payload bindings** under `crates/eql-bindings/src/v3/.rs` (structs + `DomainType` impls) and the `inventory.rs` `all()` list, from the same `CATALOG` — committed with a `// @generated` header (the bindings must exist on a clean clone because `ts-rs`/`schemars` derive the committed TypeScript/JSON Schema off them; the SQL surface is now committed too, so both generated targets are handled consistently — committed in place, drift-gated by regenerate-and-`git diff`). The hand-written `DomainType` trait, the shared newtypes (`SchemaVersion`/`Identifier`/`Ciphertext`/`Hmac256`/`OreBlock256`/`BloomFilter`), the `PhantomData` plumbing, and the architectural module doc (including the non-derivable float-NaN and bool storage-only caveats) stay hand-written in `crates/eql-bindings/src/v3/{mod,domain_type,terms}.rs`. Generated structs carry a catalog-derived struct doc — a summary line (`` `eql_v3.` — . ``) plus a detail line listing the supported operators and required payload keys, all derived from data the catalog already holds (the capability label, `Term::operators_for_terms`, and `ENVELOPE_KEYS` ++ `Term::term_json_keys` — see `struct_doc_lines` in `crates/eql-codegen/src/bindings.rs`). The required-key list makes structural distinctions visible — e.g. `text_ord` lists `` `v` `i` `c` `hm` `ob` `` (dual-term) versus an integer `integer_ord`'s `` `v` `i` `c` `ob` ``. There are **no per-field docs**: per-field/term semantics live on the shared term newtypes (`terms.rs`, flowing into the TS term files and JSON Schema `$defs`), and non-derivable per-family caveats (float-NaN, bool storage-only) in `mod.rs`. Free-form prose belongs at the **struct level** (a future optional catalog `doc` field emitted as extra `#[doc]` lines), never as per-field docs. JSON Schemas are emitted by **schemars 1.x** as JSON Schema 2020-12. `mise run types:generate` regenerates the Rust bindings (via `eql-codegen bindings`) then the TS/JSON; `mise run types:check` is the committed-reference drift gate — it regenerates and `git diff`s the Rust bindings (`crates/eql-bindings/src/v3` and `src/v4`), `bindings/` and `schema/`, the same regenerate-and-diff pattern `codegen:parity` now uses for the committed SQL surface. Both gates run in CI; `mise run install-hooks` wires a pre-commit hook that runs both locally whenever a commit touches the catalog, the generator, or a committed generated surface. The `json` family's **SteVec** structs (`SteVecDocument`, `SteVecEntry`, `SteVecQuery`, and `SteVecQueryEntry`) are hand-written for the same reason as their SQL — in `crates/eql-bindings/src/v3/json.rs`, symmetric with `src/v3/json/`. Their direct optional fields and `deny_unknown_fields` validation keep the Rust, TypeScript, JSON Schema, and PostgreSQL entry contracts aligned. The family's scalar storage struct **is** generated (a flat `{v,i,c}` struct, `Json`), via `render_scalar_only_bindings(&JSON)` into `crates/eql-bindings/src/v3/json_storage.rs`; the hand-written `json.rs` re-exports it (`pub use crate::v3::json_storage::Json;`) so `super::json::Json` resolves for the generated inventory/payload. Only inventory membership and ordering are catalog-driven: `inventory.rs::all()` iterates the full `CATALOG` (not `scalar_families()`), branching on `Shape` to include the three SteVec structs plus the generated `Json` alongside the other scalar structs. diff --git a/packages/eql/crates/eql-bindings/CHANGELOG.md b/packages/eql/crates/eql-bindings/CHANGELOG.md index 199d7e626..fac229ccd 100644 --- a/packages/eql/crates/eql-bindings/CHANGELOG.md +++ b/packages/eql/crates/eql-bindings/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Range query operands for `eql_v4.between`** (`stack-encrypt` feature). + Every ordered v4 family has two: `IntegerRangeOreQuery` and + `IntegerRangeOpeQuery`, `TextRangeOreQuery` and `TextRangeOpeQuery`, and so + on for `smallint`, `bigint`, `date`, `timestamp`, `numeric`, `real` and + `double`, listed by `v4::all_range_query()`. Each holds both bounds of one + `BETWEEN` as `{v, i, lo, hi}`, each bound the term the family's + single-bound query operand holds (`OreBlock256` for ORE, `OpeCllw` for + OPE), so a range binds as one parameter + (`$1::eql_v4.query_integer_range_ore`). They bind the + `eql_v4.query__range_` domains in the v4 SQL bundle. No + range type derives Stack Encrypt's traits yet, because the ordered types + they range over are not producible. There are no v3 range types. - **`eql_bindings::v4` needs the `stack-encrypt` feature.** Only Stack Encrypt produces an EQL v4 value (ADR-0002), so the module is compiled with that feature alone, and its producible types derive `EncryptFrom` and diff --git a/packages/eql/crates/eql-bindings/README.md b/packages/eql/crates/eql-bindings/README.md index 179b34330..0530326e8 100644 --- a/packages/eql/crates/eql-bindings/README.md +++ b/packages/eql/crates/eql-bindings/README.md @@ -137,6 +137,15 @@ ordering types wait for the engine's order keys (#1236), text's order types (#1131) and block ORE (#1132), and `Json` for a decision on sealing a document as one leaf. +Each ordered v4 family also has two range query types, one per order term: +`IntegerRangeOreQuery` and `IntegerRangeOpeQuery`, and so on +(`v4::all_range_query()` lists them). A range holds both bounds of one +`BETWEEN` as `{v, i, lo, hi}` and binds as one parameter to +`eql_v4.between(column, $1::eql_v4.query_integer_range_ore)`, which includes +both bounds. Nothing produces one yet, since the ordered types are not +producible; the types and the SQL are in place for when they are. EQL v3 has +no range types or `between`. + The generated API docs include a complete, executable example in the `eql_bindings::encryption` module, from cipher setup through decryption. Its [source](src/encryption/example.rs) is also run by the encryption test crate: diff --git a/packages/eql/crates/eql-bindings/src/v4/bigint.rs b/packages/eql/crates/eql-bindings/src/v4/bigint.rs index 9d1009024..c6aded881 100644 --- a/packages/eql/crates/eql-bindings/src/v4/bigint.rs +++ b/packages/eql/crates/eql-bindings/src/v4/bigint.rs @@ -403,3 +403,87 @@ impl DomainType for BigintOrdOpeQuery { format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) } } +/// `eql_v4.query_bigint_range_ore` — block ORE range query operand: both bounds of one `BETWEEN`. +/// +/// Function: `eql_v4.between(column, range)`, both bounds included. Columns: `public.eql_v4_bigint_ord_ore`. Required keys: `v` `i` `lo` `hi`. +#[cfg_attr(doc, doc = "")] +#[cfg_attr( + doc, + doc = " See the [EQL bigint reference](https://cipherstash.com/docs/reference/eql/numbers) for SQL domain variants, operators, and query examples." +)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct BigintRangeOreQuery { + pub v: SchemaVersion, + pub i: Identifier, + pub lo: OreBlock256, + pub hi: OreBlock256, +} +impl DomainType for BigintRangeOreQuery { + fn sql_domain_static() -> &'static str { + "eql_v4.query_bigint_range_ore" + } + fn sql_domain(&self) -> &'static str { + Self::sql_domain_static() + } + fn term_json_keys_static() -> Option<&'static [&'static str]> { + Some(&["lo", "hi"]) + } + fn term_json_keys(&self) -> Option<&'static [&'static str]> { + Self::term_json_keys_static() + } + fn parse_value(&self, value: &serde_json::Value) -> Result<(), serde_json::Error> { + BigintRangeOreQuery::deserialize(value).map(|_| ()) + } + fn schema(&self) -> Schema { + schema_for!(BigintRangeOreQuery) + } + fn schema_id_base_static() -> &'static str { + super::SCHEMA_ID_BASE + } + fn schema_id(&self) -> String { + format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) + } +} +/// `eql_v4.query_bigint_range_ope` — CLLW OPE range query operand: both bounds of one `BETWEEN`. +/// +/// Function: `eql_v4.between(column, range)`, both bounds included. Columns: `public.eql_v4_bigint_ord` `public.eql_v4_bigint_ord_ope`. Required keys: `v` `i` `lo` `hi`. +#[cfg_attr(doc, doc = "")] +#[cfg_attr( + doc, + doc = " See the [EQL bigint reference](https://cipherstash.com/docs/reference/eql/numbers) for SQL domain variants, operators, and query examples." +)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct BigintRangeOpeQuery { + pub v: SchemaVersion, + pub i: Identifier, + pub lo: OpeCllw, + pub hi: OpeCllw, +} +impl DomainType for BigintRangeOpeQuery { + fn sql_domain_static() -> &'static str { + "eql_v4.query_bigint_range_ope" + } + fn sql_domain(&self) -> &'static str { + Self::sql_domain_static() + } + fn term_json_keys_static() -> Option<&'static [&'static str]> { + Some(&["lo", "hi"]) + } + fn term_json_keys(&self) -> Option<&'static [&'static str]> { + Self::term_json_keys_static() + } + fn parse_value(&self, value: &serde_json::Value) -> Result<(), serde_json::Error> { + BigintRangeOpeQuery::deserialize(value).map(|_| ()) + } + fn schema(&self) -> Schema { + schema_for!(BigintRangeOpeQuery) + } + fn schema_id_base_static() -> &'static str { + super::SCHEMA_ID_BASE + } + fn schema_id(&self) -> String { + format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) + } +} diff --git a/packages/eql/crates/eql-bindings/src/v4/date.rs b/packages/eql/crates/eql-bindings/src/v4/date.rs index f704939bb..1610a43bb 100644 --- a/packages/eql/crates/eql-bindings/src/v4/date.rs +++ b/packages/eql/crates/eql-bindings/src/v4/date.rs @@ -379,3 +379,87 @@ impl DomainType for DateOrdOpeQuery { format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) } } +/// `eql_v4.query_date_range_ore` — block ORE range query operand: both bounds of one `BETWEEN`. +/// +/// Function: `eql_v4.between(column, range)`, both bounds included. Columns: `public.eql_v4_date_ord_ore`. Required keys: `v` `i` `lo` `hi`. +#[cfg_attr(doc, doc = "")] +#[cfg_attr( + doc, + doc = " See the [EQL date reference](https://cipherstash.com/docs/reference/eql/dates-and-times) for SQL domain variants, operators, and query examples." +)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct DateRangeOreQuery { + pub v: SchemaVersion, + pub i: Identifier, + pub lo: OreBlock256, + pub hi: OreBlock256, +} +impl DomainType for DateRangeOreQuery { + fn sql_domain_static() -> &'static str { + "eql_v4.query_date_range_ore" + } + fn sql_domain(&self) -> &'static str { + Self::sql_domain_static() + } + fn term_json_keys_static() -> Option<&'static [&'static str]> { + Some(&["lo", "hi"]) + } + fn term_json_keys(&self) -> Option<&'static [&'static str]> { + Self::term_json_keys_static() + } + fn parse_value(&self, value: &serde_json::Value) -> Result<(), serde_json::Error> { + DateRangeOreQuery::deserialize(value).map(|_| ()) + } + fn schema(&self) -> Schema { + schema_for!(DateRangeOreQuery) + } + fn schema_id_base_static() -> &'static str { + super::SCHEMA_ID_BASE + } + fn schema_id(&self) -> String { + format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) + } +} +/// `eql_v4.query_date_range_ope` — CLLW OPE range query operand: both bounds of one `BETWEEN`. +/// +/// Function: `eql_v4.between(column, range)`, both bounds included. Columns: `public.eql_v4_date_ord` `public.eql_v4_date_ord_ope`. Required keys: `v` `i` `lo` `hi`. +#[cfg_attr(doc, doc = "")] +#[cfg_attr( + doc, + doc = " See the [EQL date reference](https://cipherstash.com/docs/reference/eql/dates-and-times) for SQL domain variants, operators, and query examples." +)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct DateRangeOpeQuery { + pub v: SchemaVersion, + pub i: Identifier, + pub lo: OpeCllw, + pub hi: OpeCllw, +} +impl DomainType for DateRangeOpeQuery { + fn sql_domain_static() -> &'static str { + "eql_v4.query_date_range_ope" + } + fn sql_domain(&self) -> &'static str { + Self::sql_domain_static() + } + fn term_json_keys_static() -> Option<&'static [&'static str]> { + Some(&["lo", "hi"]) + } + fn term_json_keys(&self) -> Option<&'static [&'static str]> { + Self::term_json_keys_static() + } + fn parse_value(&self, value: &serde_json::Value) -> Result<(), serde_json::Error> { + DateRangeOpeQuery::deserialize(value).map(|_| ()) + } + fn schema(&self) -> Schema { + schema_for!(DateRangeOpeQuery) + } + fn schema_id_base_static() -> &'static str { + super::SCHEMA_ID_BASE + } + fn schema_id(&self) -> String { + format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) + } +} diff --git a/packages/eql/crates/eql-bindings/src/v4/double.rs b/packages/eql/crates/eql-bindings/src/v4/double.rs index f20074728..7b141752e 100644 --- a/packages/eql/crates/eql-bindings/src/v4/double.rs +++ b/packages/eql/crates/eql-bindings/src/v4/double.rs @@ -403,3 +403,87 @@ impl DomainType for DoubleOrdOpeQuery { format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) } } +/// `eql_v4.query_double_range_ore` — block ORE range query operand: both bounds of one `BETWEEN`. +/// +/// Function: `eql_v4.between(column, range)`, both bounds included. Columns: `public.eql_v4_double_ord_ore`. Required keys: `v` `i` `lo` `hi`. +#[cfg_attr(doc, doc = "")] +#[cfg_attr( + doc, + doc = " See the [EQL double reference](https://cipherstash.com/docs/reference/eql/numbers) for SQL domain variants, operators, and query examples." +)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct DoubleRangeOreQuery { + pub v: SchemaVersion, + pub i: Identifier, + pub lo: OreBlock256, + pub hi: OreBlock256, +} +impl DomainType for DoubleRangeOreQuery { + fn sql_domain_static() -> &'static str { + "eql_v4.query_double_range_ore" + } + fn sql_domain(&self) -> &'static str { + Self::sql_domain_static() + } + fn term_json_keys_static() -> Option<&'static [&'static str]> { + Some(&["lo", "hi"]) + } + fn term_json_keys(&self) -> Option<&'static [&'static str]> { + Self::term_json_keys_static() + } + fn parse_value(&self, value: &serde_json::Value) -> Result<(), serde_json::Error> { + DoubleRangeOreQuery::deserialize(value).map(|_| ()) + } + fn schema(&self) -> Schema { + schema_for!(DoubleRangeOreQuery) + } + fn schema_id_base_static() -> &'static str { + super::SCHEMA_ID_BASE + } + fn schema_id(&self) -> String { + format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) + } +} +/// `eql_v4.query_double_range_ope` — CLLW OPE range query operand: both bounds of one `BETWEEN`. +/// +/// Function: `eql_v4.between(column, range)`, both bounds included. Columns: `public.eql_v4_double_ord` `public.eql_v4_double_ord_ope`. Required keys: `v` `i` `lo` `hi`. +#[cfg_attr(doc, doc = "")] +#[cfg_attr( + doc, + doc = " See the [EQL double reference](https://cipherstash.com/docs/reference/eql/numbers) for SQL domain variants, operators, and query examples." +)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct DoubleRangeOpeQuery { + pub v: SchemaVersion, + pub i: Identifier, + pub lo: OpeCllw, + pub hi: OpeCllw, +} +impl DomainType for DoubleRangeOpeQuery { + fn sql_domain_static() -> &'static str { + "eql_v4.query_double_range_ope" + } + fn sql_domain(&self) -> &'static str { + Self::sql_domain_static() + } + fn term_json_keys_static() -> Option<&'static [&'static str]> { + Some(&["lo", "hi"]) + } + fn term_json_keys(&self) -> Option<&'static [&'static str]> { + Self::term_json_keys_static() + } + fn parse_value(&self, value: &serde_json::Value) -> Result<(), serde_json::Error> { + DoubleRangeOpeQuery::deserialize(value).map(|_| ()) + } + fn schema(&self) -> Schema { + schema_for!(DoubleRangeOpeQuery) + } + fn schema_id_base_static() -> &'static str { + super::SCHEMA_ID_BASE + } + fn schema_id(&self) -> String { + format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) + } +} diff --git a/packages/eql/crates/eql-bindings/src/v4/integer.rs b/packages/eql/crates/eql-bindings/src/v4/integer.rs index c6b0037dc..12c902fa8 100644 --- a/packages/eql/crates/eql-bindings/src/v4/integer.rs +++ b/packages/eql/crates/eql-bindings/src/v4/integer.rs @@ -403,3 +403,87 @@ impl DomainType for IntegerOrdOpeQuery { format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) } } +/// `eql_v4.query_integer_range_ore` — block ORE range query operand: both bounds of one `BETWEEN`. +/// +/// Function: `eql_v4.between(column, range)`, both bounds included. Columns: `public.eql_v4_integer_ord_ore`. Required keys: `v` `i` `lo` `hi`. +#[cfg_attr(doc, doc = "")] +#[cfg_attr( + doc, + doc = " See the [EQL integer reference](https://cipherstash.com/docs/reference/eql/numbers) for SQL domain variants, operators, and query examples." +)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct IntegerRangeOreQuery { + pub v: SchemaVersion, + pub i: Identifier, + pub lo: OreBlock256, + pub hi: OreBlock256, +} +impl DomainType for IntegerRangeOreQuery { + fn sql_domain_static() -> &'static str { + "eql_v4.query_integer_range_ore" + } + fn sql_domain(&self) -> &'static str { + Self::sql_domain_static() + } + fn term_json_keys_static() -> Option<&'static [&'static str]> { + Some(&["lo", "hi"]) + } + fn term_json_keys(&self) -> Option<&'static [&'static str]> { + Self::term_json_keys_static() + } + fn parse_value(&self, value: &serde_json::Value) -> Result<(), serde_json::Error> { + IntegerRangeOreQuery::deserialize(value).map(|_| ()) + } + fn schema(&self) -> Schema { + schema_for!(IntegerRangeOreQuery) + } + fn schema_id_base_static() -> &'static str { + super::SCHEMA_ID_BASE + } + fn schema_id(&self) -> String { + format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) + } +} +/// `eql_v4.query_integer_range_ope` — CLLW OPE range query operand: both bounds of one `BETWEEN`. +/// +/// Function: `eql_v4.between(column, range)`, both bounds included. Columns: `public.eql_v4_integer_ord` `public.eql_v4_integer_ord_ope`. Required keys: `v` `i` `lo` `hi`. +#[cfg_attr(doc, doc = "")] +#[cfg_attr( + doc, + doc = " See the [EQL integer reference](https://cipherstash.com/docs/reference/eql/numbers) for SQL domain variants, operators, and query examples." +)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct IntegerRangeOpeQuery { + pub v: SchemaVersion, + pub i: Identifier, + pub lo: OpeCllw, + pub hi: OpeCllw, +} +impl DomainType for IntegerRangeOpeQuery { + fn sql_domain_static() -> &'static str { + "eql_v4.query_integer_range_ope" + } + fn sql_domain(&self) -> &'static str { + Self::sql_domain_static() + } + fn term_json_keys_static() -> Option<&'static [&'static str]> { + Some(&["lo", "hi"]) + } + fn term_json_keys(&self) -> Option<&'static [&'static str]> { + Self::term_json_keys_static() + } + fn parse_value(&self, value: &serde_json::Value) -> Result<(), serde_json::Error> { + IntegerRangeOpeQuery::deserialize(value).map(|_| ()) + } + fn schema(&self) -> Schema { + schema_for!(IntegerRangeOpeQuery) + } + fn schema_id_base_static() -> &'static str { + super::SCHEMA_ID_BASE + } + fn schema_id(&self) -> String { + format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) + } +} diff --git a/packages/eql/crates/eql-bindings/src/v4/inventory.rs b/packages/eql/crates/eql-bindings/src/v4/inventory.rs index 0e8fc7f53..a8b77dad9 100644 --- a/packages/eql/crates/eql-bindings/src/v4/inventory.rs +++ b/packages/eql/crates/eql-bindings/src/v4/inventory.rs @@ -1,5 +1,5 @@ // @generated by eql-codegen from the eql-domains catalog — do not edit -//! The `all()` / `all_query()` inventories — every v4 stored and query-operand payload type in eql-domains::CATALOG order. Generated from the catalog. +//! The `all()` / `all_query()` / `all_range_query()` inventories — every v4 stored, query-operand and range query-operand payload type in eql-domains::CATALOG order. Generated from the catalog. use super::DomainType; use std::marker::PhantomData; /// Every v4 stored-payload domain type, in `eql-domains::CATALOG` @@ -103,3 +103,28 @@ pub fn all_query() -> Vec> { Box::new(PhantomData::), ] } +/// Every v4 RANGE query operand (`eql_v4.query__range_`), +/// one per family and ordering term, in `eql-domains::CATALOG` order — +/// generated. The operand of `eql_v4.between`; EQL v3 has none. +pub fn all_range_query() -> Vec> { + vec![ + Box::new(PhantomData::), + Box::new(PhantomData::), + Box::new(PhantomData::), + Box::new(PhantomData::), + Box::new(PhantomData::), + Box::new(PhantomData::), + Box::new(PhantomData::), + Box::new(PhantomData::), + Box::new(PhantomData::), + Box::new(PhantomData::), + Box::new(PhantomData::), + Box::new(PhantomData::), + Box::new(PhantomData::), + Box::new(PhantomData::), + Box::new(PhantomData::), + Box::new(PhantomData::), + Box::new(PhantomData::), + Box::new(PhantomData::), + ] +} diff --git a/packages/eql/crates/eql-bindings/src/v4/mod.rs b/packages/eql/crates/eql-bindings/src/v4/mod.rs index b795fb9fa..2122e5504 100644 --- a/packages/eql/crates/eql-bindings/src/v4/mod.rs +++ b/packages/eql/crates/eql-bindings/src/v4/mod.rs @@ -13,6 +13,11 @@ //! `eql-codegen`'s `ENCRYPTION_DOMAINS`; the rest say why in //! `crate::encryption::targets`. //! +//! Beyond v3's types, every ordered family has a range query operand per +//! order term (`IntegerRangeOreQuery`, `IntegerRangeOpeQuery`, …, listed by +//! [`all_range_query`]): both bounds of one `BETWEEN`, the one operand of +//! `eql_v4.between(column, range)`. EQL v3 has no range domains. +//! //! v4 has no SteVec bindings, payload enums, TypeScript export or //! published JSON Schema yet; those follow in `@cipherstash/eql` 4.0. The //! term newtypes and the [`DomainType`] trait are shared with v3: a v4 @@ -44,7 +49,7 @@ pub mod timestamp; pub use crate::v3::domain_type::{DomainType, SQL_SCHEMA}; pub use crate::v3::terms; -pub use inventory::{all, all_query}; +pub use inventory::{all, all_query, all_range_query}; /// The PostgreSQL typname prefix every `public` v4 domain carries. pub const PUBLIC_TYPNAME_PREFIX: &str = "eql_v4_"; diff --git a/packages/eql/crates/eql-bindings/src/v4/numeric.rs b/packages/eql/crates/eql-bindings/src/v4/numeric.rs index ff46246d4..615c07800 100644 --- a/packages/eql/crates/eql-bindings/src/v4/numeric.rs +++ b/packages/eql/crates/eql-bindings/src/v4/numeric.rs @@ -379,3 +379,87 @@ impl DomainType for NumericOrdOpeQuery { format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) } } +/// `eql_v4.query_numeric_range_ore` — block ORE range query operand: both bounds of one `BETWEEN`. +/// +/// Function: `eql_v4.between(column, range)`, both bounds included. Columns: `public.eql_v4_numeric_ord_ore`. Required keys: `v` `i` `lo` `hi`. +#[cfg_attr(doc, doc = "")] +#[cfg_attr( + doc, + doc = " See the [EQL numeric reference](https://cipherstash.com/docs/reference/eql/numbers) for SQL domain variants, operators, and query examples." +)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NumericRangeOreQuery { + pub v: SchemaVersion, + pub i: Identifier, + pub lo: OreBlock256, + pub hi: OreBlock256, +} +impl DomainType for NumericRangeOreQuery { + fn sql_domain_static() -> &'static str { + "eql_v4.query_numeric_range_ore" + } + fn sql_domain(&self) -> &'static str { + Self::sql_domain_static() + } + fn term_json_keys_static() -> Option<&'static [&'static str]> { + Some(&["lo", "hi"]) + } + fn term_json_keys(&self) -> Option<&'static [&'static str]> { + Self::term_json_keys_static() + } + fn parse_value(&self, value: &serde_json::Value) -> Result<(), serde_json::Error> { + NumericRangeOreQuery::deserialize(value).map(|_| ()) + } + fn schema(&self) -> Schema { + schema_for!(NumericRangeOreQuery) + } + fn schema_id_base_static() -> &'static str { + super::SCHEMA_ID_BASE + } + fn schema_id(&self) -> String { + format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) + } +} +/// `eql_v4.query_numeric_range_ope` — CLLW OPE range query operand: both bounds of one `BETWEEN`. +/// +/// Function: `eql_v4.between(column, range)`, both bounds included. Columns: `public.eql_v4_numeric_ord` `public.eql_v4_numeric_ord_ope`. Required keys: `v` `i` `lo` `hi`. +#[cfg_attr(doc, doc = "")] +#[cfg_attr( + doc, + doc = " See the [EQL numeric reference](https://cipherstash.com/docs/reference/eql/numbers) for SQL domain variants, operators, and query examples." +)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NumericRangeOpeQuery { + pub v: SchemaVersion, + pub i: Identifier, + pub lo: OpeCllw, + pub hi: OpeCllw, +} +impl DomainType for NumericRangeOpeQuery { + fn sql_domain_static() -> &'static str { + "eql_v4.query_numeric_range_ope" + } + fn sql_domain(&self) -> &'static str { + Self::sql_domain_static() + } + fn term_json_keys_static() -> Option<&'static [&'static str]> { + Some(&["lo", "hi"]) + } + fn term_json_keys(&self) -> Option<&'static [&'static str]> { + Self::term_json_keys_static() + } + fn parse_value(&self, value: &serde_json::Value) -> Result<(), serde_json::Error> { + NumericRangeOpeQuery::deserialize(value).map(|_| ()) + } + fn schema(&self) -> Schema { + schema_for!(NumericRangeOpeQuery) + } + fn schema_id_base_static() -> &'static str { + super::SCHEMA_ID_BASE + } + fn schema_id(&self) -> String { + format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) + } +} diff --git a/packages/eql/crates/eql-bindings/src/v4/real.rs b/packages/eql/crates/eql-bindings/src/v4/real.rs index e6d316bc6..fbadc1b67 100644 --- a/packages/eql/crates/eql-bindings/src/v4/real.rs +++ b/packages/eql/crates/eql-bindings/src/v4/real.rs @@ -403,3 +403,87 @@ impl DomainType for RealOrdOpeQuery { format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) } } +/// `eql_v4.query_real_range_ore` — block ORE range query operand: both bounds of one `BETWEEN`. +/// +/// Function: `eql_v4.between(column, range)`, both bounds included. Columns: `public.eql_v4_real_ord_ore`. Required keys: `v` `i` `lo` `hi`. +#[cfg_attr(doc, doc = "")] +#[cfg_attr( + doc, + doc = " See the [EQL real reference](https://cipherstash.com/docs/reference/eql/numbers) for SQL domain variants, operators, and query examples." +)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RealRangeOreQuery { + pub v: SchemaVersion, + pub i: Identifier, + pub lo: OreBlock256, + pub hi: OreBlock256, +} +impl DomainType for RealRangeOreQuery { + fn sql_domain_static() -> &'static str { + "eql_v4.query_real_range_ore" + } + fn sql_domain(&self) -> &'static str { + Self::sql_domain_static() + } + fn term_json_keys_static() -> Option<&'static [&'static str]> { + Some(&["lo", "hi"]) + } + fn term_json_keys(&self) -> Option<&'static [&'static str]> { + Self::term_json_keys_static() + } + fn parse_value(&self, value: &serde_json::Value) -> Result<(), serde_json::Error> { + RealRangeOreQuery::deserialize(value).map(|_| ()) + } + fn schema(&self) -> Schema { + schema_for!(RealRangeOreQuery) + } + fn schema_id_base_static() -> &'static str { + super::SCHEMA_ID_BASE + } + fn schema_id(&self) -> String { + format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) + } +} +/// `eql_v4.query_real_range_ope` — CLLW OPE range query operand: both bounds of one `BETWEEN`. +/// +/// Function: `eql_v4.between(column, range)`, both bounds included. Columns: `public.eql_v4_real_ord` `public.eql_v4_real_ord_ope`. Required keys: `v` `i` `lo` `hi`. +#[cfg_attr(doc, doc = "")] +#[cfg_attr( + doc, + doc = " See the [EQL real reference](https://cipherstash.com/docs/reference/eql/numbers) for SQL domain variants, operators, and query examples." +)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RealRangeOpeQuery { + pub v: SchemaVersion, + pub i: Identifier, + pub lo: OpeCllw, + pub hi: OpeCllw, +} +impl DomainType for RealRangeOpeQuery { + fn sql_domain_static() -> &'static str { + "eql_v4.query_real_range_ope" + } + fn sql_domain(&self) -> &'static str { + Self::sql_domain_static() + } + fn term_json_keys_static() -> Option<&'static [&'static str]> { + Some(&["lo", "hi"]) + } + fn term_json_keys(&self) -> Option<&'static [&'static str]> { + Self::term_json_keys_static() + } + fn parse_value(&self, value: &serde_json::Value) -> Result<(), serde_json::Error> { + RealRangeOpeQuery::deserialize(value).map(|_| ()) + } + fn schema(&self) -> Schema { + schema_for!(RealRangeOpeQuery) + } + fn schema_id_base_static() -> &'static str { + super::SCHEMA_ID_BASE + } + fn schema_id(&self) -> String { + format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) + } +} diff --git a/packages/eql/crates/eql-bindings/src/v4/smallint.rs b/packages/eql/crates/eql-bindings/src/v4/smallint.rs index 5341a7810..478776ed9 100644 --- a/packages/eql/crates/eql-bindings/src/v4/smallint.rs +++ b/packages/eql/crates/eql-bindings/src/v4/smallint.rs @@ -379,3 +379,87 @@ impl DomainType for SmallintOrdOpeQuery { format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) } } +/// `eql_v4.query_smallint_range_ore` — block ORE range query operand: both bounds of one `BETWEEN`. +/// +/// Function: `eql_v4.between(column, range)`, both bounds included. Columns: `public.eql_v4_smallint_ord_ore`. Required keys: `v` `i` `lo` `hi`. +#[cfg_attr(doc, doc = "")] +#[cfg_attr( + doc, + doc = " See the [EQL smallint reference](https://cipherstash.com/docs/reference/eql/numbers) for SQL domain variants, operators, and query examples." +)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SmallintRangeOreQuery { + pub v: SchemaVersion, + pub i: Identifier, + pub lo: OreBlock256, + pub hi: OreBlock256, +} +impl DomainType for SmallintRangeOreQuery { + fn sql_domain_static() -> &'static str { + "eql_v4.query_smallint_range_ore" + } + fn sql_domain(&self) -> &'static str { + Self::sql_domain_static() + } + fn term_json_keys_static() -> Option<&'static [&'static str]> { + Some(&["lo", "hi"]) + } + fn term_json_keys(&self) -> Option<&'static [&'static str]> { + Self::term_json_keys_static() + } + fn parse_value(&self, value: &serde_json::Value) -> Result<(), serde_json::Error> { + SmallintRangeOreQuery::deserialize(value).map(|_| ()) + } + fn schema(&self) -> Schema { + schema_for!(SmallintRangeOreQuery) + } + fn schema_id_base_static() -> &'static str { + super::SCHEMA_ID_BASE + } + fn schema_id(&self) -> String { + format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) + } +} +/// `eql_v4.query_smallint_range_ope` — CLLW OPE range query operand: both bounds of one `BETWEEN`. +/// +/// Function: `eql_v4.between(column, range)`, both bounds included. Columns: `public.eql_v4_smallint_ord` `public.eql_v4_smallint_ord_ope`. Required keys: `v` `i` `lo` `hi`. +#[cfg_attr(doc, doc = "")] +#[cfg_attr( + doc, + doc = " See the [EQL smallint reference](https://cipherstash.com/docs/reference/eql/numbers) for SQL domain variants, operators, and query examples." +)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SmallintRangeOpeQuery { + pub v: SchemaVersion, + pub i: Identifier, + pub lo: OpeCllw, + pub hi: OpeCllw, +} +impl DomainType for SmallintRangeOpeQuery { + fn sql_domain_static() -> &'static str { + "eql_v4.query_smallint_range_ope" + } + fn sql_domain(&self) -> &'static str { + Self::sql_domain_static() + } + fn term_json_keys_static() -> Option<&'static [&'static str]> { + Some(&["lo", "hi"]) + } + fn term_json_keys(&self) -> Option<&'static [&'static str]> { + Self::term_json_keys_static() + } + fn parse_value(&self, value: &serde_json::Value) -> Result<(), serde_json::Error> { + SmallintRangeOpeQuery::deserialize(value).map(|_| ()) + } + fn schema(&self) -> Schema { + schema_for!(SmallintRangeOpeQuery) + } + fn schema_id_base_static() -> &'static str { + super::SCHEMA_ID_BASE + } + fn schema_id(&self) -> String { + format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) + } +} diff --git a/packages/eql/crates/eql-bindings/src/v4/text.rs b/packages/eql/crates/eql-bindings/src/v4/text.rs index 2e549eca0..c8670d7ab 100644 --- a/packages/eql/crates/eql-bindings/src/v4/text.rs +++ b/packages/eql/crates/eql-bindings/src/v4/text.rs @@ -682,3 +682,87 @@ impl DomainType for TextSearchQuery { format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) } } +/// `eql_v4.query_text_range_ore` — block ORE range query operand: both bounds of one `BETWEEN`. +/// +/// Function: `eql_v4.between(column, range)`, both bounds included. Columns: `public.eql_v4_text_ord_ore` `public.eql_v4_text_search_ore`. Required keys: `v` `i` `lo` `hi`. +#[cfg_attr(doc, doc = "")] +#[cfg_attr( + doc, + doc = " See the [EQL text reference](https://cipherstash.com/docs/reference/eql/text) for SQL domain variants, operators, and query examples." +)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TextRangeOreQuery { + pub v: SchemaVersion, + pub i: Identifier, + pub lo: OreBlock256, + pub hi: OreBlock256, +} +impl DomainType for TextRangeOreQuery { + fn sql_domain_static() -> &'static str { + "eql_v4.query_text_range_ore" + } + fn sql_domain(&self) -> &'static str { + Self::sql_domain_static() + } + fn term_json_keys_static() -> Option<&'static [&'static str]> { + Some(&["lo", "hi"]) + } + fn term_json_keys(&self) -> Option<&'static [&'static str]> { + Self::term_json_keys_static() + } + fn parse_value(&self, value: &serde_json::Value) -> Result<(), serde_json::Error> { + TextRangeOreQuery::deserialize(value).map(|_| ()) + } + fn schema(&self) -> Schema { + schema_for!(TextRangeOreQuery) + } + fn schema_id_base_static() -> &'static str { + super::SCHEMA_ID_BASE + } + fn schema_id(&self) -> String { + format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) + } +} +/// `eql_v4.query_text_range_ope` — CLLW OPE range query operand: both bounds of one `BETWEEN`. +/// +/// Function: `eql_v4.between(column, range)`, both bounds included. Columns: `public.eql_v4_text_ord` `public.eql_v4_text_ord_ope` `public.eql_v4_text_search`. Required keys: `v` `i` `lo` `hi`. +#[cfg_attr(doc, doc = "")] +#[cfg_attr( + doc, + doc = " See the [EQL text reference](https://cipherstash.com/docs/reference/eql/text) for SQL domain variants, operators, and query examples." +)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TextRangeOpeQuery { + pub v: SchemaVersion, + pub i: Identifier, + pub lo: OpeCllw, + pub hi: OpeCllw, +} +impl DomainType for TextRangeOpeQuery { + fn sql_domain_static() -> &'static str { + "eql_v4.query_text_range_ope" + } + fn sql_domain(&self) -> &'static str { + Self::sql_domain_static() + } + fn term_json_keys_static() -> Option<&'static [&'static str]> { + Some(&["lo", "hi"]) + } + fn term_json_keys(&self) -> Option<&'static [&'static str]> { + Self::term_json_keys_static() + } + fn parse_value(&self, value: &serde_json::Value) -> Result<(), serde_json::Error> { + TextRangeOpeQuery::deserialize(value).map(|_| ()) + } + fn schema(&self) -> Schema { + schema_for!(TextRangeOpeQuery) + } + fn schema_id_base_static() -> &'static str { + super::SCHEMA_ID_BASE + } + fn schema_id(&self) -> String { + format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) + } +} diff --git a/packages/eql/crates/eql-bindings/src/v4/timestamp.rs b/packages/eql/crates/eql-bindings/src/v4/timestamp.rs index ef9545462..43231e503 100644 --- a/packages/eql/crates/eql-bindings/src/v4/timestamp.rs +++ b/packages/eql/crates/eql-bindings/src/v4/timestamp.rs @@ -379,3 +379,87 @@ impl DomainType for TimestampOrdOpeQuery { format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) } } +/// `eql_v4.query_timestamp_range_ore` — block ORE range query operand: both bounds of one `BETWEEN`. +/// +/// Function: `eql_v4.between(column, range)`, both bounds included. Columns: `public.eql_v4_timestamp_ord_ore`. Required keys: `v` `i` `lo` `hi`. +#[cfg_attr(doc, doc = "")] +#[cfg_attr( + doc, + doc = " See the [EQL timestamp reference](https://cipherstash.com/docs/reference/eql/dates-and-times) for SQL domain variants, operators, and query examples." +)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TimestampRangeOreQuery { + pub v: SchemaVersion, + pub i: Identifier, + pub lo: OreBlock256, + pub hi: OreBlock256, +} +impl DomainType for TimestampRangeOreQuery { + fn sql_domain_static() -> &'static str { + "eql_v4.query_timestamp_range_ore" + } + fn sql_domain(&self) -> &'static str { + Self::sql_domain_static() + } + fn term_json_keys_static() -> Option<&'static [&'static str]> { + Some(&["lo", "hi"]) + } + fn term_json_keys(&self) -> Option<&'static [&'static str]> { + Self::term_json_keys_static() + } + fn parse_value(&self, value: &serde_json::Value) -> Result<(), serde_json::Error> { + TimestampRangeOreQuery::deserialize(value).map(|_| ()) + } + fn schema(&self) -> Schema { + schema_for!(TimestampRangeOreQuery) + } + fn schema_id_base_static() -> &'static str { + super::SCHEMA_ID_BASE + } + fn schema_id(&self) -> String { + format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) + } +} +/// `eql_v4.query_timestamp_range_ope` — CLLW OPE range query operand: both bounds of one `BETWEEN`. +/// +/// Function: `eql_v4.between(column, range)`, both bounds included. Columns: `public.eql_v4_timestamp_ord` `public.eql_v4_timestamp_ord_ope`. Required keys: `v` `i` `lo` `hi`. +#[cfg_attr(doc, doc = "")] +#[cfg_attr( + doc, + doc = " See the [EQL timestamp reference](https://cipherstash.com/docs/reference/eql/dates-and-times) for SQL domain variants, operators, and query examples." +)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TimestampRangeOpeQuery { + pub v: SchemaVersion, + pub i: Identifier, + pub lo: OpeCllw, + pub hi: OpeCllw, +} +impl DomainType for TimestampRangeOpeQuery { + fn sql_domain_static() -> &'static str { + "eql_v4.query_timestamp_range_ope" + } + fn sql_domain(&self) -> &'static str { + Self::sql_domain_static() + } + fn term_json_keys_static() -> Option<&'static [&'static str]> { + Some(&["lo", "hi"]) + } + fn term_json_keys(&self) -> Option<&'static [&'static str]> { + Self::term_json_keys_static() + } + fn parse_value(&self, value: &serde_json::Value) -> Result<(), serde_json::Error> { + TimestampRangeOpeQuery::deserialize(value).map(|_| ()) + } + fn schema(&self) -> Schema { + schema_for!(TimestampRangeOpeQuery) + } + fn schema_id_base_static() -> &'static str { + super::SCHEMA_ID_BASE + } + fn schema_id(&self) -> String { + format!("{}{}.json", super::SCHEMA_ID_BASE, self.domain()) + } +} diff --git a/packages/eql/crates/eql-codegen/src/bindings.rs b/packages/eql/crates/eql-codegen/src/bindings.rs index 6219c1c64..6fdc8d2a2 100644 --- a/packages/eql/crates/eql-codegen/src/bindings.rs +++ b/packages/eql/crates/eql-codegen/src/bindings.rs @@ -11,7 +11,7 @@ use std::path::{Path, PathBuf}; use proc_macro2::TokenStream; use quote::{format_ident, quote}; -use eql_domains::{Domain, DomainFamily, Term, CATALOG, ENVELOPE_KEYS}; +use eql_domains::{Domain, DomainFamily, Term, CATALOG, ENVELOPE_KEYS, RANGE_BOUND_KEYS}; use crate::consts::RUST_GENERATED_MARKER; use crate::render::EqlVersion; @@ -333,6 +333,83 @@ fn render_query_struct(eql: EqlVersion, family: &DomainFamily, domain: &Domain) } } +/// One range query-operand struct + its `DomainType` impl: the operand of +/// `eql_v4.between(column, range)` for the ordering term `term`, holding both +/// bounds of one `BETWEEN` (ADR-0009 decision 7). EQL v4 only: no v3 domain +/// carries a range, so [`render_family_bindings`] emits these for v4 alone. +/// +/// The fields are the envelope's `v` and `i`, then the bounds under +/// [`RANGE_BOUND_KEYS`], each the term newtype the family's single-bound query +/// struct for the same term holds (`OreBlock256` for `ob`, `OpeCllw` for `op`), +/// so a range is Stack Encrypt's `RangeQuery { lo, hi }` with nothing added. +/// No struct derives Stack Encrypt's traits yet: the ordered types it ranges +/// over are not producible. +fn render_range_query_struct(family: &DomainFamily, term: Term) -> TokenStream { + let eql = crate::range::RANGE_VERSION; + let ident = format_ident!("{}", family.range_struct_ident(term)); + let sql_domain = crate::range::range_sql_domain(family, term); + let reference = reference_docs(family); + let schema_id = schema_id_override(eql); + let term_name = crate::range::term_label(term); + let summary = + format!(" `{sql_domain}` — {term_name} range query operand: both bounds of one `BETWEEN`."); + let columns = family + .range_columns(term) + .iter() + .map(|d| format!("`{}`", domain_name(eql, &d.full_name(family.name)))) + .collect::>() + .join(" "); + let keys_str = crate::range::range_required_keys() + .map(|k| format!("`{k}`")) + .collect::>() + .join(" "); + let detail = format!( + " Function: `{prefix}.between(column, range)`, both bounds included. Columns: \ + {columns}. Required keys: {keys_str}.", + prefix = eql.prefix() + ); + let tid = format_ident!("{}", term.binding_newtype()); + let [lo, hi] = RANGE_BOUND_KEYS.map(|k| format_ident!("{k}")); + let bound_keys = RANGE_BOUND_KEYS; + + quote! { + #[doc = #summary] + #[doc = ""] + #[doc = #detail] + #reference + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS, JsonSchema)] + #[serde(deny_unknown_fields)] + pub struct #ident { + pub v: SchemaVersion, + pub i: Identifier, + pub #lo: #tid, + pub #hi: #tid, + } + + impl DomainType for #ident { + fn sql_domain_static() -> &'static str { + #sql_domain + } + fn sql_domain(&self) -> &'static str { + Self::sql_domain_static() + } + fn term_json_keys_static() -> Option<&'static [&'static str]> { + Some(&[#(#bound_keys),*]) + } + fn term_json_keys(&self) -> Option<&'static [&'static str]> { + Self::term_json_keys_static() + } + fn parse_value(&self, value: &serde_json::Value) -> Result<(), serde_json::Error> { + #ident::deserialize(value).map(|_| ()) + } + fn schema(&self) -> Schema { + schema_for!(#ident) + } + #schema_id + } + } +} + /// The `(family, domain)` pairs whose generated v4 structs carry the opt-in /// `stack-encrypt` derives: the storage and equality domains of every family /// whose plaintext the engine's value enum carries, and text match. Every @@ -523,7 +600,12 @@ pub fn render_family_bindings(version: EqlVersion, family: &DomainFamily) -> Str let imports = family_imports(version, &used); // Storage structs for every domain, then a query twin for each term-bearing - // domain (storage-only domains have no operators, so no query operand). + // domain (storage-only domains have no operators, so no query operand), + // then, for v4, a range query operand per ordering term. + let ranges = match version { + EqlVersion::V3 => Vec::new(), + EqlVersion::V4 => family.range_terms(), + }; let structs: TokenStream = family .domains .iter() @@ -535,6 +617,11 @@ pub fn render_family_bindings(version: EqlVersion, family: &DomainFamily) -> Str .filter(|d| !d.terms.is_empty()) .map(|d| render_query_struct(version, family, d)), ) + .chain( + ranges + .into_iter() + .map(|t| render_range_query_struct(family, t)), + ) .collect(); let mod_doc = format!( @@ -643,10 +730,18 @@ pub fn render_v4_inventory_rs() -> String { quote! { Box::new(PhantomData::), } }) .collect(); + let range_entries: TokenStream = eql_domains::families_with_scalar_domains() + .flat_map(|f| f.range_terms().into_iter().map(move |t| (f, t))) + .map(|(f, t)| { + let m = format_ident!("{}", f.name); + let s = format_ident!("{}", f.range_struct_ident(t)); + quote! { Box::new(PhantomData::), } + }) + .collect(); - let mod_doc = " The `all()` / `all_query()` inventories — every v4 stored and \ - query-operand payload type in eql-domains::CATALOG order. \ - Generated from the catalog."; + let mod_doc = " The `all()` / `all_query()` / `all_range_query()` inventories — every v4 \ + stored, query-operand and range query-operand payload type in \ + eql-domains::CATALOG order. Generated from the catalog."; let file = quote! { #![doc = #mod_doc] @@ -670,6 +765,15 @@ pub fn render_v4_inventory_rs() -> String { #query_entries ] } + + /// Every v4 RANGE query operand (`eql_v4.query__range_`), + /// one per family and ordering term, in `eql-domains::CATALOG` order — + /// generated. The operand of `eql_v4.between`; EQL v3 has none. + pub fn all_range_query() -> Vec> { + vec![ + #range_entries + ] + } }; format_rs(file) diff --git a/packages/eql/crates/eql-codegen/src/context.rs b/packages/eql/crates/eql-codegen/src/context.rs index cc94907a2..a34cbd69b 100644 --- a/packages/eql/crates/eql-codegen/src/context.rs +++ b/packages/eql/crates/eql-codegen/src/context.rs @@ -54,6 +54,16 @@ pub fn environment() -> minijinja::Environment<'static> { include_str!("../templates/aggregates.sql.j2"), ) .expect("aggregates.sql template"); + env.add_template( + "range_types.sql", + include_str!("../templates/range_types.sql.j2"), + ) + .expect("range_types.sql template"); + env.add_template( + "range_functions.sql", + include_str!("../templates/range_functions.sql.j2"), + ) + .expect("range_functions.sql template"); env.add_template( "ore_fallback.sql", include_str!("../templates/ore_fallback.sql.j2"), diff --git a/packages/eql/crates/eql-codegen/src/generate.rs b/packages/eql/crates/eql-codegen/src/generate.rs index d6c70836e..b6df602af 100644 --- a/packages/eql/crates/eql-codegen/src/generate.rs +++ b/packages/eql/crates/eql-codegen/src/generate.rs @@ -964,6 +964,22 @@ pub fn render_type(spec: &DomainFamily, out_dir: &Path) -> Vec<(PathBuf, String) rendered.push((out_dir.join(format!("{name}_aggregates.sql")), agg)); } } + // The EQL v4 range surface (ADR-0009 decisions 7 and 8): a range query + // domain per ordering term, its bound accessors and `between`. Both files + // carry `-- ONLY EQL VERSION: 4`, so the build renders them into the v4 + // bundle only. None for a family with no ordering term. + if let Some(body) = crate::range::render_range_types_file(spec) { + rendered.push(( + out_dir.join(crate::range::range_types_file(family_name)), + body, + )); + } + if let Some(body) = crate::range::render_range_functions_file(spec) { + rendered.push(( + out_dir.join(crate::range::range_functions_file(family_name)), + body, + )); + } // json_entry cross-type operators: bind the fixed // public.eql_v3_json_entry leaf to every query operand this family declares // that carries the term json_entry can serve (Term::Ope) AND whose values @@ -1278,10 +1294,13 @@ mod tests { // Query-operand + json_entry cross-type files for the term-bearing domains. assert!(names.contains(&"json_entry_integer_functions.sql".to_string())); assert!(names.contains(&"json_entry_integer_operators.sql".to_string())); + // The EQL v4 range surface: domains, then accessors and `between`. + assert!(names.contains(&"query_integer_range_types.sql".to_string())); + assert!(names.contains(&"query_integer_range_functions.sql".to_string())); // 1 types + 1 query_types + 2 per domain (5) + 2 query per term-bearing // domain (4) + 3 ord-capable aggregates = 1+1+10+8+3 = 23, plus the 2 - // json_entry cross files. - assert_eq!(written.len(), 25); + // json_entry cross files and the 2 range files. + assert_eq!(written.len(), 27); for p in &written { assert!(fs::read_to_string(p) .unwrap() diff --git a/packages/eql/crates/eql-codegen/src/go_eql.rs b/packages/eql/crates/eql-codegen/src/go_eql.rs index e644e0a73..030b0faf4 100644 --- a/packages/eql/crates/eql-codegen/src/go_eql.rs +++ b/packages/eql/crates/eql-codegen/src/go_eql.rs @@ -147,9 +147,39 @@ pub fn render_go_eql() -> String { methods(&mut out, &query_go, query_domain); } } + range_types(&mut out); out } +/// The range query types: one per family and ordering term, the operand of +/// `eql_v4.between` (ADR-0009 decision 7), so a `Between` term binds as one +/// parameter. Each holds the JSON `{"v", "i", "lo", "hi"}` value as bytes, +/// like every other query type. Not in the `Types` table, which lists +/// stored types; no stored type is producible yet for a range to query. +fn range_types(out: &mut String) { + for family in eql_domains::families_with_scalar_domains() { + for term in family.range_terms() { + let go = go_name(&family.range_struct_ident(term)); + let domain = crate::range::range_sql_domain(family, term); + let index = crate::targets::index_key(term); + let columns: Vec = family + .range_columns(term) + .iter() + .map(|d| go_name(&d.rust_struct_name(family.name))) + .collect(); + let _ = write!( + out, + "\n// {go} is a range query value over the {index} index of the {family} family: \ + both bounds of one BETWEEN,\n// included, as the one operand {domain} takes in \ + eql_v4.between. It ranges over {columns}.\ntype {go} []byte\n", + family = family.name, + columns = columns.join(", "), + ); + methods(out, &go, &domain); + } + } +} + /// Write `eql_gen.go` under `out_root` (the EQL subtree root, or the /// override `EQL_CODEGEN_OUT_ROOT`). Returns the path written. pub fn generate_go_eql(out_root: &Path) -> std::io::Result { @@ -213,6 +243,33 @@ mod tests { } } + #[test] + fn the_package_has_one_range_type_per_family_and_ordering_term() { + let out = render_go_eql(); + let mut count = 0; + for family in eql_domains::families_with_scalar_domains() { + for term in family.range_terms() { + let go = family.range_struct_ident(term); + assert_eq!( + out.matches(&format!("\ntype {go} []byte\n")).count(), + 1, + "{go}" + ); + assert!(out.contains(&format!("func (v {go}) Value() (driver.Value, error)"))); + count += 1; + } + } + assert_eq!(count, 18, "nine ordered families, ORE and OPE each"); + assert!(out.contains( + "// IntegerRangeOreQuery is a range query value over the ore index of the integer family: \ + both bounds of one BETWEEN,\n// included, as the one operand eql_v4.query_integer_range_ore \ + takes in eql_v4.between. It ranges over IntegerOrdOre.\n" + )); + assert!(out.contains("It ranges over TextOrd, TextOrdOpe, TextSearch.\n")); + // Range types are query types, not stored ones: not in the table. + assert!(!out.contains("Name: \"IntegerRangeOreQuery\"")); + } + #[test] fn rendering_is_deterministic() { assert_eq!(render_go_eql(), render_go_eql()); diff --git a/packages/eql/crates/eql-codegen/src/lib.rs b/packages/eql/crates/eql-codegen/src/lib.rs index de20c0f95..02137825b 100644 --- a/packages/eql/crates/eql-codegen/src/lib.rs +++ b/packages/eql/crates/eql-codegen/src/lib.rs @@ -15,6 +15,7 @@ pub mod generate; pub mod go_eql; pub mod operator_surface; pub mod ordering; +pub mod range; pub mod render; pub mod targets; pub mod writer; diff --git a/packages/eql/crates/eql-codegen/src/main.rs b/packages/eql/crates/eql-codegen/src/main.rs index d7155ea3e..5c473d439 100644 --- a/packages/eql/crates/eql-codegen/src/main.rs +++ b/packages/eql/crates/eql-codegen/src/main.rs @@ -169,6 +169,52 @@ fn main() -> ExitCode { }; } + // `select <3|4> ...`: print, in the order given, the files that + // belong to that EQL version: every file but one confined to the other + // version by a `-- ONLY EQL VERSION:` line. A kept file that `-- REQUIRE:`s + // a dropped one is refused. tasks/build.sh runs the + // install order through this before rendering each version's bundle, so + // the v4-only range surface never reaches the v3 installer. + if args.len() >= 3 && args[1] == "select" { + let version = match args[2].parse::() { + Ok(v) => v, + Err(e) => { + eprintln!("error: {e}"); + return ExitCode::from(2); + } + }; + let mut sources = Vec::new(); + for file in &args[3..] { + match std::fs::read_to_string(file) { + Ok(src) => sources.push((file.clone(), src)), + Err(e) => { + eprintln!("error: {file}: {e}"); + return ExitCode::FAILURE; + } + } + } + // Refuses a kept file that requires a dropped one, so each + // version's order stays closed under `-- REQUIRE:`. + let selected = match eql_codegen::render::select(&sources, version) { + Ok(selected) => selected, + Err(e) => { + eprintln!("error: {e}"); + return ExitCode::FAILURE; + } + }; + if selected.is_empty() { + eprintln!( + "error: select chose no file for EQL version {}", + version.number() + ); + return ExitCode::FAILURE; + } + for file in selected { + println!("{file}"); + } + return ExitCode::SUCCESS; + } + // `clean`: remove the generated SQL surface (marker-aware) under every // src/v3/scalars/* type dir. Replaces build.sh's filename-pattern sweep; // hand-written files (no AUTO-GENERATED marker) are preserved. @@ -205,6 +251,7 @@ fn main() -> ExitCode { eprintln!(" eql-codegen order (print the src/v3 install order, one path per line)"); eprintln!(" eql-codegen clean (remove the generated SQL surface)"); eprintln!(" eql-codegen render <3|4> ... (render placeholder SQL as one EQL version)"); + eprintln!(" eql-codegen select <3|4> ... (print the files that belong to one EQL version)"); eprintln!(" eql-codegen list-types (print catalog tokens)"); eprintln!( " eql-codegen list-schemas [3|4] (print owned schemas, rendered, public first)" diff --git a/packages/eql/crates/eql-codegen/src/range.rs b/packages/eql/crates/eql-codegen/src/range.rs new file mode 100644 index 000000000..d48aa914c --- /dev/null +++ b/packages/eql/crates/eql-codegen/src/range.rs @@ -0,0 +1,467 @@ +//! The EQL v4 range surface: one range query domain per (family, ordering +//! term), its two bound accessors, and `between(column, range)` for every +//! stored domain that carries the term (ADR-0009, decisions 7 and 8). +//! +//! A `Between` query is one term holding both bounds (Stack Encrypt's +//! `RangeQuery { lo, hi }`), and in SQL it stays one operand: +//! +//! ```sql +//! WHERE eql_v4.between(age, $1::eql_v4.query_integer_range_ore) +//! ``` +//! +//! The wire shape is `{v, i, lo, hi}` +//! ([`RANGE_BOUND_KEYS`]): each bound is the +//! value the single-bound query domain for the same term holds under its term +//! key, so a bound of an ORE range is an `ob` array and a bound of an OPE range +//! an `op` hex string. +//! +//! EQL v3 has none of this. v3 is what cipherstash-client writes, and nothing +//! there produces a range. Both files carry the `-- ONLY EQL VERSION: 4` line +//! ([`ONLY_DIRECTIVE`]), so the build renders +//! them into the v4 bundle only, and the renderer refuses to render them as v3. + +use eql_domains::{DomainFamily, Term, RANGE_BOUND_KEYS}; + +use crate::consts::{sql_str, INTERNAL_SCHEMA}; +use crate::context::{domain_name, environment}; +use crate::render::{EqlVersion, ONLY_DIRECTIVE}; + +/// The version the range surface belongs to. +pub const RANGE_VERSION: EqlVersion = EqlVersion::V4; + +/// The directive line every range file carries: `-- ONLY EQL VERSION: 4`. +pub fn only_directive() -> String { + format!("{ONLY_DIRECTIVE} {}", RANGE_VERSION.number()) +} + +/// The file holding a family's range domains: `query_integer_range_types.sql`. +pub fn range_types_file(family_name: &str) -> String { + format!("query_{family_name}_range_types.sql") +} + +/// The file holding a family's bound accessors and `between` overloads. +pub fn range_functions_file(family_name: &str) -> String { + format!("query_{family_name}_range_functions.sql") +} + +/// How a term reads in prose: `block ORE`, `CLLW OPE`. The SQL comments +/// and the binding structs' docs both read it here. +pub fn term_label(term: Term) -> &'static str { + match term { + Term::Ore => "block ORE", + Term::Ope => "CLLW OPE", + Term::Hm | Term::Bloom => unreachable!("{term:?} has no range domain"), + } +} + +/// The CHECK clauses one bound of a `term` range must satisfy: the shape the +/// single-bound query domain's term key has. An ORE bound is a non-empty +/// array of block terms, as `ob` is; an OPE bound is a hex string, as `op` is. +fn bound_checks(term: Term, bound: &str) -> Vec { + let bound = sql_str(bound); + match term { + Term::Ore => vec![ + format!("jsonb_typeof(VALUE -> '{bound}') = 'array'"), + format!("jsonb_array_length(VALUE -> '{bound}') > 0"), + ], + Term::Ope => vec![format!("jsonb_typeof(VALUE -> '{bound}') = 'string'")], + Term::Hm | Term::Bloom => unreachable!("{term:?} has no range domain"), + } +} + +/// The keys a range value must carry, in order: the envelope's `v` and `i`, +/// then the bounds ([`RANGE_BOUND_KEYS`]). What the domain CHECK requires +/// and what the binding structs' docs list. +pub fn range_required_keys() -> impl Iterator { + ["v", "i"].into_iter().chain(RANGE_BOUND_KEYS) +} + +#[derive(serde::Serialize)] +struct RangeDomainBlock { + name: String, + typname: String, + term_label: &'static str, + keys: Vec, + bound_checks: Vec, + comment: String, +} + +#[derive(serde::Serialize)] +struct RangeTypesContext { + only_directive: String, + family_name: String, + domains: Vec, +} + +/// Body of `query__range_types.sql`, or `None` for a family with no +/// ordering term. +pub fn render_range_types_file(spec: &DomainFamily) -> Option { + let terms = spec.range_terms(); + if terms.is_empty() { + return None; + } + let domains = terms + .into_iter() + .map(|term| { + let name = spec.range_query_name(term); + RangeDomainBlock { + typname: sql_str(&name), + name, + term_label: term_label(term), + keys: range_required_keys().map(sql_str).collect(), + bound_checks: RANGE_BOUND_KEYS + .iter() + .flat_map(|b| bound_checks(term, b)) + .collect(), + comment: sql_str(&format!( + "EQL {} range query operand ({})", + spec.name, + term_label(term) + )), + } + }) + .collect(); + let ctx = RangeTypesContext { + only_directive: only_directive(), + family_name: spec.name.to_string(), + domains, + }; + Some( + environment() + .get_template("range_types.sql") + .unwrap() + .render(&ctx) + .expect("render range_types.sql"), + ) +} + +#[derive(serde::Serialize)] +struct Accessor { + side: &'static str, + function_name: &'static str, + range: String, + term_label: &'static str, + returns: String, + body: String, +} + +#[derive(serde::Serialize)] +struct Between { + column: String, + range: String, + extractor: &'static str, + /// An ORE range, whose index needs the opclass a non-superuser + /// install skips: the generated docs say so. + ore: bool, +} + +#[derive(serde::Serialize)] +struct RangeFunctionsContext { + only_directive: String, + family_name: String, + requires: Vec, + accessors: Vec, + betweens: Vec, +} + +/// Body of `query__range_functions.sql`, or `None` for a family with no +/// ordering term: `lo` and `hi` for each range domain (in the internal +/// schema: only `between` calls them), then +/// `between(column, range)` for each stored domain carrying the range's term. +/// +/// An accessor reads its bound with the term's own constructor +/// (`ore_block_256` / `ope_cllw` over `{"ob": …}` / `{"op": …}`), the one +/// path a single-bound term is read by. `between` compares the column's +/// extractor, the expression a functional index is built on, against both. +pub fn render_range_functions_file(spec: &DomainFamily) -> Option { + let terms = spec.range_terms(); + if terms.is_empty() { + return None; + } + let family_name = spec.name; + let mut requires = vec![ + "src/v3/schema.sql".to_string(), + format!( + "src/v3/scalars/{family_name}/{}", + range_types_file(family_name) + ), + ]; + let mut accessors = Vec::new(); + let mut betweens = Vec::new(); + for &term in &terms { + let range = spec.range_query_name(term); + let returns = format!("{INTERNAL_SCHEMA}.{}", term.ctor()); + for (side, function_name, bound) in [ + ("low", "lo", RANGE_BOUND_KEYS[0]), + ("high", "hi", RANGE_BOUND_KEYS[1]), + ] { + accessors.push(Accessor { + side, + function_name, + range: range.clone(), + term_label: term_label(term), + returns: returns.clone(), + body: format!( + "{INTERNAL_SCHEMA}.{}(jsonb_build_object('{}', r::jsonb -> '{}'))", + term.ctor(), + sql_str(term.json_key()), + sql_str(bound) + ), + }); + } + for domain in spec.range_columns(term) { + let full = domain.full_name(family_name); + let functions = format!("src/v3/scalars/{family_name}/{full}_functions.sql"); + if !requires.contains(&functions) { + requires.push(functions); + } + betweens.push(Between { + column: domain_name(&full), + range: range.clone(), + extractor: term.extractor(), + ore: term == Term::Ore, + }); + } + } + for extra in Term::term_requires(&terms) { + if !requires.iter().any(|r| r == extra) { + requires.push(extra.to_string()); + } + } + let ctx = RangeFunctionsContext { + only_directive: only_directive(), + family_name: family_name.to_string(), + requires, + accessors, + betweens, + }; + Some( + environment() + .get_template("range_functions.sql") + .unwrap() + .render(&ctx) + .expect("render range_functions.sql"), + ) +} + +/// The schema-qualified range domain for `term` under `spec`, as a v4 SQL +/// name: `eql_v4.query_integer_range_ore`. What the bindings name. +pub fn range_sql_domain(spec: &DomainFamily, term: Term) -> String { + format!("{}.{}", RANGE_VERSION.prefix(), spec.range_query_name(term)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::render::{belongs_to, render_str}; + use eql_domains::{BOOLEAN, CATALOG, JSON, TEXT}; + + fn integer() -> &'static DomainFamily { + CATALOG.iter().find(|f| f.name == "integer").unwrap() + } + + #[test] + fn a_family_without_ordering_has_no_range_files() { + for f in [&BOOLEAN, &JSON] { + assert!(render_range_types_file(f).is_none(), "{}", f.name); + assert!(render_range_functions_file(f).is_none(), "{}", f.name); + } + } + + #[test] + fn range_files_belong_to_eql_v4_only() { + for f in CATALOG.iter().filter(|f| !f.range_terms().is_empty()) { + for body in [ + render_range_types_file(f).unwrap(), + render_range_functions_file(f).unwrap(), + ] { + assert_eq!(body.lines().nth(1), Some("-- ONLY EQL VERSION: 4")); + assert_eq!(belongs_to(&body, EqlVersion::V4), Ok(true)); + assert_eq!(belongs_to(&body, EqlVersion::V3), Ok(false)); + assert!(render_str(&body, EqlVersion::V3).is_err()); + render_str(&body, EqlVersion::V4).unwrap(); + } + } + } + + #[test] + fn the_range_domains_check_the_bound_shape_of_their_term() { + let v4 = render_str(&render_range_types_file(integer()).unwrap(), EqlVersion::V4).unwrap(); + assert!(v4.contains("CREATE DOMAIN eql_v4.query_integer_range_ore AS jsonb")); + assert!(v4.contains("CREATE DOMAIN eql_v4.query_integer_range_ope AS jsonb")); + let ore = v4 + .split("CREATE DOMAIN eql_v4.query_integer_range_ore") + .nth(1) + .unwrap() + .split("END IF;") + .next() + .unwrap(); + for clause in [ + "VALUE ? 'v'", + "VALUE ? 'i'", + "VALUE ? 'lo'", + "VALUE ? 'hi'", + "NOT (VALUE ? 'c')", + "jsonb_typeof(VALUE -> 'lo') = 'array'", + "jsonb_array_length(VALUE -> 'lo') > 0", + "jsonb_typeof(VALUE -> 'hi') = 'array'", + "jsonb_array_length(VALUE -> 'hi') > 0", + "VALUE->>'v' = '4'", + ] { + assert!(ore.contains(clause), "ORE range lacks {clause}:\n{ore}"); + } + let ope = v4 + .split("CREATE DOMAIN eql_v4.query_integer_range_ope") + .nth(1) + .unwrap() + .split("END IF;") + .next() + .unwrap(); + assert!(ope.contains("jsonb_typeof(VALUE -> 'lo') = 'string'")); + assert!(ope.contains("jsonb_typeof(VALUE -> 'hi') = 'string'")); + assert!(!ope.contains("jsonb_array_length"), "{ope}"); + assert!(v4.contains( + "COMMENT ON DOMAIN eql_v4.query_integer_range_ore IS 'EQL integer range query operand (block ORE)'" + )); + assert!(v4.contains( + "COMMENT ON DOMAIN eql_v4.query_integer_range_ope IS 'EQL integer range query operand (CLLW OPE)'" + )); + } + + #[test] + fn between_is_inlinable_and_compares_the_column_extractor_to_both_bounds() { + let v4 = render_str( + &render_range_functions_file(integer()).unwrap(), + EqlVersion::V4, + ) + .unwrap(); + assert!(v4.contains( + "CREATE FUNCTION eql_v4.between(a public.eql_v4_integer_ord_ore, r eql_v4.query_integer_range_ore)\n\ + RETURNS boolean\n\ + LANGUAGE sql IMMUTABLE PARALLEL SAFE\n\ + AS $$ SELECT eql_v4.ord_term_ore(a) >= eql_v4_internal.lo(r) AND eql_v4.ord_term_ore(a) <= eql_v4_internal.hi(r) $$;" + ), "{v4}"); + // OPE serves both OPE-backed column domains. + for column in ["public.eql_v4_integer_ord", "public.eql_v4_integer_ord_ope"] { + assert!(v4.contains(&format!( + "CREATE FUNCTION eql_v4.between(a {column}, r eql_v4.query_integer_range_ope)\n\ + RETURNS boolean\n\ + LANGUAGE sql IMMUTABLE PARALLEL SAFE\n\ + AS $$ SELECT eql_v4.ord_term(a) >= eql_v4_internal.lo(r) AND eql_v4.ord_term(a) <= eql_v4_internal.hi(r) $$;" + )), "{column}"); + } + assert_eq!(v4.matches("CREATE FUNCTION eql_v4.between(").count(), 3); + // Inlining needs no STRICT and no SET clause on `between`. + for between in v4.split("CREATE FUNCTION eql_v4.between(").skip(1) { + let header = between.split("AS $$").next().unwrap(); + assert!(!header.contains("STRICT"), "{header}"); + assert!(!header.contains("SET "), "{header}"); + } + } + + #[test] + fn each_bound_is_read_by_its_terms_own_constructor() { + let v4 = render_str( + &render_range_functions_file(integer()).unwrap(), + EqlVersion::V4, + ) + .unwrap(); + for (side, bound) in [("lo", "lo"), ("hi", "hi")] { + assert!(v4.contains(&format!( + "CREATE FUNCTION eql_v4_internal.{side}(r eql_v4.query_integer_range_ore)\n\ + RETURNS eql_v4_internal.ore_block_256\n\ + LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\n\ + AS $$ SELECT eql_v4_internal.ore_block_256(jsonb_build_object('ob', r::jsonb -> '{bound}')) $$;" + )), "{side} ORE"); + assert!(v4.contains(&format!( + "CREATE FUNCTION eql_v4_internal.{side}(r eql_v4.query_integer_range_ope)\n\ + RETURNS eql_v4_internal.ope_cllw\n\ + LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\n\ + AS $$ SELECT eql_v4_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> '{bound}')) $$;" + )), "{side} OPE"); + } + } + + #[test] + fn the_functions_file_requires_what_it_calls() { + let body = render_range_functions_file(&TEXT).unwrap(); + let requires: Vec<&str> = body + .lines() + .filter_map(|l| l.strip_prefix("-- REQUIRE: ")) + .collect(); + assert_eq!( + requires, + [ + "src/v3/schema.sql", + "src/v3/scalars/text/query_text_range_types.sql", + "src/v3/scalars/text/text_ord_ore_functions.sql", + "src/v3/scalars/text/text_search_ore_functions.sql", + "src/v3/scalars/text/text_ord_functions.sql", + "src/v3/scalars/text/text_ord_ope_functions.sql", + "src/v3/scalars/text/text_search_functions.sql", + "src/v3/sem/ore_block_256/functions.sql", + "src/v3/sem/ore_block_256/operators.sql", + "src/v3/sem/ope_cllw/functions.sql", + ] + ); + // Five text column domains carry an ordering term: five overloads. + assert_eq!( + body.matches("CREATE FUNCTION {{prefix}}.between(").count(), + 5 + ); + assert_eq!( + body.matches("CREATE FUNCTION {{prefix}}_internal.lo(") + .count(), + 2 + ); + assert_eq!( + body.matches("CREATE FUNCTION {{prefix}}_internal.hi(") + .count(), + 2 + ); + } + + /// Only the ORE `between` overloads say their index needs the + /// `ore_block_256` btree opclass, which a non-superuser install skips; + /// the OPE ones index everywhere. + #[test] + fn the_ore_overloads_name_the_opclass_they_need() { + let body = render_range_functions_file(integer()).unwrap(); + for overload in body.split("--! @brief Whether ").skip(1) { + let ore = overload.contains("_range_ore)"); + assert_eq!( + overload.contains("default btree operator class"), + ore, + "{overload}" + ); + } + } + + #[test] + fn every_function_is_documented() { + for f in CATALOG.iter().filter(|f| !f.range_terms().is_empty()) { + let body = render_range_functions_file(f).unwrap(); + let functions = body.matches("\nCREATE FUNCTION ").count(); + assert_eq!( + body.matches("\n--! @brief ").count(), + functions + 1, + "{}", + f.name + ); + assert_eq!( + body.matches("\n--! @return ").count(), + functions, + "{}", + f.name + ); + } + } + + #[test] + fn the_sql_domain_names_the_v4_schema() { + assert_eq!( + range_sql_domain(integer(), Term::Ope), + "eql_v4.query_integer_range_ope" + ); + } +} diff --git a/packages/eql/crates/eql-codegen/src/render.rs b/packages/eql/crates/eql-codegen/src/render.rs index a9bf6243a..6f39f3b01 100644 --- a/packages/eql/crates/eql-codegen/src/render.rs +++ b/packages/eql/crates/eql-codegen/src/render.rs @@ -18,6 +18,13 @@ //! placeholder is an error, and so is a literal `eql_v3` or `eql_v4` in the //! source: a literal name would render the same in both outputs, so the `eql_v4` //! bundle would quietly reach into `eql_v3`. +//! +//! A file belongs to every version unless it says otherwise with one line, +//! [`ONLY_DIRECTIVE`] and a version number: `-- ONLY EQL VERSION: 4`. That is +//! how a surface EQL v4 has and EQL v3 must not (the range query domains and +//! `between`, ADR-0009 decision 7) shares the one source tree: the build +//! renders a file only for the version it belongs to ([`belongs_to`]), and +//! [`render_str`] refuses to render it as any other. use std::fmt; use std::fs; @@ -30,6 +37,12 @@ pub const PREFIX: &str = "{{prefix}}"; /// The payload-version placeholder. pub const EQL_VERSION: &str = "{{eql_version}}"; +/// The start of the line that confines a source file to one EQL version: +/// `-- ONLY EQL VERSION: 4`. At most one per file; a file without one belongs +/// to every version. The number is the payload version ([`EqlVersion::number`]), +/// not the schema name, because the source may not name a schema literally. +pub const ONLY_DIRECTIVE: &str = "-- ONLY EQL VERSION:"; + /// An EQL version the source can be rendered as. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum EqlVersion { @@ -80,6 +93,16 @@ pub enum RenderError { Unterminated { line: usize }, /// A literal `eql_v3` or `eql_v4` where a placeholder belongs. LiteralName { line: usize, literal: &'static str }, + /// An [`ONLY_DIRECTIVE`] line that does not name a version. + BadOnlyDirective { line: usize, value: String }, + /// A second [`ONLY_DIRECTIVE`] line. + DuplicateOnlyDirective { line: usize }, + /// A file confined to another version, rendered as this one. + OtherVersion { + line: usize, + only: EqlVersion, + version: EqlVersion, + }, } impl fmt::Display for RenderError { @@ -98,14 +121,118 @@ impl fmt::Display for RenderError { so the name follows the EQL version (`{literal}_internal` is \ `{PREFIX}_internal`)" ), + RenderError::BadOnlyDirective { line, value } => write!( + f, + "line {line}: `{ONLY_DIRECTIVE}{value}` names no EQL version (expected \ + `{ONLY_DIRECTIVE} 3` or `{ONLY_DIRECTIVE} 4`)" + ), + RenderError::DuplicateOnlyDirective { line } => { + write!(f, "line {line}: a second `{ONLY_DIRECTIVE}` line") + } + RenderError::OtherVersion { + line, + only, + version, + } => write!( + f, + "line {line}: this file belongs to {} only and cannot be rendered as {}", + only.prefix(), + version.prefix() + ), } } } impl std::error::Error for RenderError {} -/// Render one source text as `version`. +/// The one version `src` is confined to by its [`ONLY_DIRECTIVE`] line, with +/// the line's number, or `None` when it belongs to every version. +fn only_version(src: &str) -> Result, RenderError> { + let mut found = None; + for (i, line) in src.lines().enumerate() { + let Some(value) = line.strip_prefix(ONLY_DIRECTIVE) else { + continue; + }; + let lineno = i + 1; + if found.is_some() { + return Err(RenderError::DuplicateOnlyDirective { line: lineno }); + } + let version = value + .strip_prefix(' ') + .and_then(|v| v.trim_end().parse::().ok()) + .ok_or_else(|| RenderError::BadOnlyDirective { + line: lineno, + value: value.to_string(), + })?; + found = Some((lineno, version)); + } + Ok(found) +} + +/// Whether `src` belongs to `version`: true unless an [`ONLY_DIRECTIVE`] line +/// confines it to another. The build asks this of every ordered file before +/// rendering a version's bundle, so a file confined to EQL v4 never reaches +/// the EQL v3 installer. +pub fn belongs_to(src: &str, version: EqlVersion) -> Result { + Ok(only_version(src)?.is_none_or(|(_, only)| only == version)) +} + +/// The files of an install order that belong to `version`, in the order +/// given: every `(path, source)` but one confined to another version. +/// +/// The kept order must stay closed under `-- REQUIRE:`: a kept file that +/// requires a dropped one would install before (or without) what it +/// calls, and nothing later can tell, since the symbol-order gate matches +/// names without overloads. So a kept file requiring a dropped one is +/// refused, every such edge listed. +/// +/// # Errors +/// +/// A malformed directive in any file, as [`belongs_to`] reports it with the +/// file's path; or every edge from a kept file to a dropped one. +pub fn select(sources: &[(String, String)], version: EqlVersion) -> Result, String> { + let mut kept = Vec::new(); + let mut dropped = std::collections::BTreeSet::new(); + for (path, src) in sources { + match belongs_to(src, version) { + Ok(true) => kept.push((path.as_str(), src.as_str())), + Ok(false) => { + dropped.insert(path.as_str()); + } + Err(e) => return Err(format!("{path}: {e}")), + } + } + let broken: Vec = kept + .iter() + .flat_map(|(path, src)| { + crate::ordering::requires_of(src) + .into_iter() + .filter(|target| dropped.contains(target.as_str())) + .map(move |target| format!(" {path} requires {target}")) + }) + .collect(); + if !broken.is_empty() { + return Err(format!( + "these files belong to {} but require a file confined to another version:\n{}", + version.prefix(), + broken.join("\n") + )); + } + Ok(kept.into_iter().map(|(path, _)| path).collect()) +} + +/// Render one source text as `version`. A source confined to another version +/// by its [`ONLY_DIRECTIVE`] line is refused ([`RenderError::OtherVersion`]). pub fn render_str(src: &str, version: EqlVersion) -> Result { + if let Some((line, only)) = only_version(src)? { + if only != version { + return Err(RenderError::OtherVersion { + line, + only, + version, + }); + } + } let mut out = String::with_capacity(src.len()); for (i, line) in src.split_inclusive('\n').enumerate() { let lineno = i + 1; @@ -309,6 +436,123 @@ mod tests { ); } + const V4_ONLY: &str = "-- AUTOMATICALLY GENERATED FILE.\n\ + -- ONLY EQL VERSION: 4\n\ + SELECT {{prefix}}.between();\n"; + + #[test] + fn a_file_without_the_directive_belongs_to_every_version() { + for v in EqlVersion::ALL { + assert_eq!(belongs_to(SRC, v), Ok(true)); + } + } + + #[test] + fn a_confined_file_belongs_to_its_version_alone_and_renders_only_as_it() { + assert_eq!(belongs_to(V4_ONLY, EqlVersion::V4), Ok(true)); + assert_eq!(belongs_to(V4_ONLY, EqlVersion::V3), Ok(false)); + assert_eq!( + render_str(V4_ONLY, EqlVersion::V4).unwrap(), + "-- AUTOMATICALLY GENERATED FILE.\n\ + -- ONLY EQL VERSION: 4\n\ + SELECT eql_v4.between();\n" + ); + let err = render_str(V4_ONLY, EqlVersion::V3).unwrap_err(); + assert_eq!( + err, + RenderError::OtherVersion { + line: 2, + only: EqlVersion::V4, + version: EqlVersion::V3 + } + ); + assert_eq!( + err.to_string(), + "line 2: this file belongs to eql_v4 only and cannot be rendered as eql_v3" + ); + // Trailing whitespace is tolerated; the version is the number. + assert_eq!( + belongs_to("-- ONLY EQL VERSION: 3 \n", EqlVersion::V3), + Ok(true) + ); + } + + fn sources(files: &[(&str, &str)]) -> Vec<(String, String)> { + files + .iter() + .map(|(p, s)| (p.to_string(), s.to_string())) + .collect() + } + + #[test] + fn select_keeps_a_versions_files_in_order() { + let files = sources(&[ + ("src/v3/a.sql", SRC), + ("src/v3/range.sql", V4_ONLY), + ("src/v3/b.sql", "-- REQUIRE: src/v3/a.sql\nSELECT 1;\n"), + ]); + assert_eq!( + select(&files, EqlVersion::V4).unwrap(), + ["src/v3/a.sql", "src/v3/range.sql", "src/v3/b.sql"] + ); + assert_eq!( + select(&files, EqlVersion::V3).unwrap(), + ["src/v3/a.sql", "src/v3/b.sql"] + ); + } + + /// A kept file that requires a dropped one would leave the version's + /// order without what it calls: refused, naming the edge. + #[test] + fn select_refuses_a_kept_file_requiring_a_dropped_one() { + let files = sources(&[ + ("src/v3/range.sql", V4_ONLY), + ("src/v3/b.sql", "-- REQUIRE: src/v3/range.sql\nSELECT 1;\n"), + ]); + assert!(select(&files, EqlVersion::V4).is_ok()); + let err = select(&files, EqlVersion::V3).unwrap_err(); + assert!( + err.contains("src/v3/b.sql requires src/v3/range.sql"), + "{err}" + ); + assert!(err.contains("belong to eql_v3"), "{err}"); + } + + #[test] + fn a_malformed_or_repeated_directive_is_refused() { + for (src, value) in [ + ("x\n-- ONLY EQL VERSION: 2\n", " 2"), + ("x\n-- ONLY EQL VERSION:4\n", "4"), + ("x\n-- ONLY EQL VERSION: v4\n", " v4"), + ("x\n-- ONLY EQL VERSION:\n", ""), + ] { + let err = belongs_to(src, EqlVersion::V4).unwrap_err(); + assert_eq!( + err, + RenderError::BadOnlyDirective { + line: 2, + value: value.to_string() + } + ); + assert!(err.to_string().starts_with("line 2: "), "{err}"); + assert_eq!(render_str(src, EqlVersion::V4), Err(err)); + } + let twice = "-- ONLY EQL VERSION: 4\n-- ONLY EQL VERSION: 4\n"; + assert_eq!( + belongs_to(twice, EqlVersion::V4), + Err(RenderError::DuplicateOnlyDirective { line: 2 }) + ); + assert_eq!( + RenderError::DuplicateOnlyDirective { line: 2 }.to_string(), + "line 2: a second `-- ONLY EQL VERSION:` line" + ); + // Prose that mentions the directive mid-line is not a directive. + assert_eq!( + belongs_to("--! see `-- ONLY EQL VERSION: 4`\n", EqlVersion::V3), + Ok(true) + ); + } + #[test] fn parses_versions() { assert_eq!("3".parse(), Ok(EqlVersion::V3)); @@ -356,6 +600,23 @@ mod tests { ); } + #[test] + fn render_files_refuses_a_file_confined_to_the_other_version() { + let scratch = Scratch::new("eql-render-only"); + let dir = &scratch.0; + let root = dir.join("root"); + let out = dir.join("out"); + fs::create_dir_all(root.join("src/v3")).unwrap(); + fs::write(root.join("src/v3/range.sql"), V4_ONLY).unwrap(); + let files = [PathBuf::from("src/v3/range.sql")]; + + let err = render_files(&root, &files, &out, EqlVersion::V3).unwrap_err(); + assert!(err.to_string().contains("belongs to eql_v4 only"), "{err}"); + assert!(!out.exists()); + render_files(&root, &files, &out, EqlVersion::V4).unwrap(); + assert!(out.join("src/v3/range.sql").exists()); + } + #[test] fn render_files_refuses_paths_outside_out() { let scratch = Scratch::new("eql-render-abs"); diff --git a/packages/eql/crates/eql-codegen/templates/range_functions.sql.j2 b/packages/eql/crates/eql-codegen/templates/range_functions.sql.j2 new file mode 100644 index 000000000..714ae845f --- /dev/null +++ b/packages/eql/crates/eql-codegen/templates/range_functions.sql.j2 @@ -0,0 +1,54 @@ +-- AUTOMATICALLY GENERATED FILE. +{{ only_directive }} +{% for r in requires -%} +-- REQUIRE: {{ r }} +{% endfor %} +--! @file v3/scalars/{{ family_name }}/query_{{ family_name }}_range_functions.sql +--! @brief The bound accessors and `{{ schema }}.between` for {{ family_name }}'s range query domains. +--! @note EQL version {{ eql_version }} only, like the range domains themselves. +{% for a in accessors %} +--! @brief The {{ a.side }} bound of a {{ schema }}.{{ a.range }}, as a {{ a.term_label }} term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{ schema }}.between`'s body calls it, so it is not part of +--! the public `{{ schema }}` surface. +--! +--! @param r {{ schema }}.{{ a.range }} Range query operand +--! @return {{ a.returns }} The {{ a.side }} bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{ schema }}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{ internal_schema }}.{{ a.function_name }}(r {{ schema }}.{{ a.range }}) +RETURNS {{ a.returns }} +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{ a.body }} $$; +{% endfor %} +{%- for b in betweens %} +--! @brief Whether a {{ b.column }} value lies in a range, both bounds included. +--! +--! True when `{{ internal_schema }}.lo(r) <= {{ schema }}.{{ b.extractor }}(a) <= {{ internal_schema }}.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{ schema }}.between(col, $1::{{ schema }}.{{ b.range }})`. +--! +--! @param a {{ b.column }} Encrypted column value +--! @param r {{ schema }}.{{ b.range }} Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{ schema }}.{{ b.extractor }}(a)`, which a btree index on that expression +--! serves as one range scan.{% if b.ore %} For this ORE range that index needs the +--! default btree operator class on {{ internal_schema }}.ore_block_256, which the +--! installer creates only with superuser privileges. Without it (on Supabase, +--! for instance) the installer disables the ORE column domains, so use an OPE +--! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere.{% endif %} +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{ schema }}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{ schema }}.between(a {{ b.column }}, r {{ schema }}.{{ b.range }}) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{ schema }}.{{ b.extractor }}(a) >= {{ internal_schema }}.lo(r) AND {{ schema }}.{{ b.extractor }}(a) <= {{ internal_schema }}.hi(r) $$; +{% endfor -%} diff --git a/packages/eql/crates/eql-codegen/templates/range_types.sql.j2 b/packages/eql/crates/eql-codegen/templates/range_types.sql.j2 new file mode 100644 index 000000000..c1b4d9f6a --- /dev/null +++ b/packages/eql/crates/eql-codegen/templates/range_types.sql.j2 @@ -0,0 +1,45 @@ +-- AUTOMATICALLY GENERATED FILE. +{{ only_directive }} +-- REQUIRE: src/v3/schema.sql + +--! @file v3/scalars/{{ family_name }}/query_{{ family_name }}_range_types.sql +--! @brief Range query-operand domains for {{ family_name }}: the operand of `{{ schema }}.between`. +--! @note A range holds both bounds of one `BETWEEN` as one value: +--! `{"v": {{ eql_version }}, "i": {...}, "lo": , "hi": }`. Each bound is the +--! value the family's single-bound query domain for the same ordering +--! term holds under its term key (`ob` or `op`). Both bounds are +--! included. There is one range domain per ordering term the family +--! stores (ADR-0009, decisions 7 and 8). +--! @note EQL version {{ eql_version }} only: the `-- ONLY EQL VERSION:` line above keeps this +--! file out of every other version's installer. +--! @note Like every query operand, a range lives in `{{ schema }}`, not `public`, +--! and holds no ciphertext `c`. The client refuses a range whose low +--! bound is above its high bound; one that reaches the database anyway +--! matches no row. + +DO $$ +BEGIN +{%- for d in domains %} + --! @brief Range query-operand domain {{ schema }}.{{ d.name }} (two {{ d.term_label }} bounds; no `c`). + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = '{{ d.typname }}' AND typnamespace = '{{ schema }}'::regnamespace + ) THEN + CREATE DOMAIN {{ schema }}.{{ d.name }} AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + {%- for k in d.keys %} + AND VALUE ? '{{ k }}' + {%- endfor %} + AND NOT (VALUE ? 'c') + {%- for c in d.bound_checks %} + AND {{ c }} + {%- endfor %} + AND VALUE->>'v' = '{{ eql_version }}' + ); + END IF; + + COMMENT ON DOMAIN {{ schema }}.{{ d.name }} IS '{{ d.comment }}'; +{% endfor -%} +END +$$; diff --git a/packages/eql/crates/eql-codegen/tests/cli.rs b/packages/eql/crates/eql-codegen/tests/cli.rs index 31467846c..5e9f6abd0 100644 --- a/packages/eql/crates/eql-codegen/tests/cli.rs +++ b/packages/eql/crates/eql-codegen/tests/cli.rs @@ -164,6 +164,62 @@ fn order_subcommand_fails_on_a_dangling_require() { ); } +/// `select` prints, in the order given, the files that belong to a version, +/// dropping one confined to the other version. `tasks/build.sh` builds each +/// bundle's file list with it, so stdout is paths only. +#[test] +fn select_subcommand_drops_files_confined_to_the_other_version() { + let root = tempdir(); + let shared = root.0.join("shared.sql"); + let v4_only = root.0.join("range.sql"); + std::fs::write(&shared, "SELECT {{prefix}}.f();\n").unwrap(); + std::fs::write( + &v4_only, + "-- AUTOMATICALLY GENERATED FILE.\n-- ONLY EQL VERSION: 4\nSELECT 1;\n", + ) + .unwrap(); + let run = |version: &str, files: &[&std::path::Path]| { + Command::new(bin()) + .arg("select") + .arg(version) + .args(files) + .output() + .expect("run eql-codegen select") + }; + + let out = run("4", &[&v4_only, &shared]); + assert!(out.status.success()); + assert_eq!( + String::from_utf8_lossy(&out.stdout), + format!("{}\n{}\n", v4_only.display(), shared.display()) + ); + let out = run("3", &[&v4_only, &shared]); + assert!(out.status.success()); + assert_eq!( + String::from_utf8_lossy(&out.stdout), + format!("{}\n", shared.display()) + ); + + // Selecting nothing is an error, not an empty bundle. + let out = run("3", &[&v4_only]); + assert!(!out.status.success()); + assert!(out.stdout.is_empty()); + assert!(String::from_utf8_lossy(&out.stderr).contains("chose no file")); + + // A malformed directive or a missing file fails, naming the file. + let bad = root.0.join("bad.sql"); + std::fs::write(&bad, "-- ONLY EQL VERSION: five\n").unwrap(); + for file in [bad.as_path(), root.0.join("missing.sql").as_path()] { + let out = run("4", &[&shared, file]); + assert!(!out.status.success(), "{}", file.display()); + assert!(out.stdout.is_empty()); + assert!(String::from_utf8_lossy(&out.stderr).contains(&*file.to_string_lossy())); + } + + let out = run("5", &[&shared]); + assert_eq!(out.status.code(), Some(2)); +} + /// An unrecognised argument prints usage and exits 2 (the `ExitCode::from(2)` /// fall-through in `main.rs`). #[test] diff --git a/packages/eql/crates/eql-domains/src/lib.rs b/packages/eql/crates/eql-domains/src/lib.rs index a4507ad6b..a7bf33a20 100644 --- a/packages/eql/crates/eql-domains/src/lib.rs +++ b/packages/eql/crates/eql-domains/src/lib.rs @@ -44,6 +44,18 @@ pub use fixtures::{ /// drift between the SQL and the canonical types. pub const ENVELOPE_KEYS: &[&str] = &["v", "i", "c"]; +/// The keys a range query domain carries its two bounds under, low then +/// high, beside the `v` and `i` every query operand carries: +/// `{"v": 4, "i": {…}, "lo": , "hi": }`. Each bound is the +/// value the family's single-bound query domain for the same term carries +/// under its term key (`ob` or `op`): a range is Stack Encrypt's +/// `RangeQuery { lo, hi }` with nothing added. Inclusive at both ends. +/// +/// Cross-schema contract data like [`ENVELOPE_KEYS`]: `eql-codegen` builds +/// the range domain CHECKs and accessors from it, and the bindings name +/// their fields from it. Range domains exist in EQL v4 only. +pub const RANGE_BOUND_KEYS: [&str; 2] = ["lo", "hi"]; + /// The version prefix every PUBLIC-SCHEMA EQL type name carries: /// `public.eql_v3_integer_eq`, `public.eql_v3_json`, …. The prefix /// keeps EQL domains from shadowing PostgreSQL built-in type names diff --git a/packages/eql/crates/eql-domains/src/spec.rs b/packages/eql/crates/eql-domains/src/spec.rs index d48ad53de..da5f10e53 100644 --- a/packages/eql/crates/eql-domains/src/spec.rs +++ b/packages/eql/crates/eql-domains/src/spec.rs @@ -4,7 +4,7 @@ //! (no `ord` domain). //! Definitions for [`DomainFamily`] and [`Domain`] live in `lib.rs`. -use crate::{Domain, DomainFamily}; +use crate::{Domain, DomainFamily, Term}; impl Domain { /// The full (unqualified) domain name for this domain under `family_name`: @@ -166,6 +166,79 @@ impl DomainFamily { pub fn is_scalar(&self) -> bool { self.domains.iter().all(|d| d.is_scalar()) } + + /// The ordering terms this family stores, each once, in catalog order: + /// one range query domain per entry (ADR-0009 decision 8, one query + /// domain per (family, term)). `[Ore, Ope]` for every ordered family + /// today (`_ord_ore` precedes `_ord`), empty for a family with no + /// ordering domain (`boolean`, `json`). + /// + /// Range domains are an EQL v4 surface: `eql-codegen` renders them into + /// the v4 bundle only. The catalog names them; it does not decide which + /// bundle carries them. + pub fn range_terms(&self) -> Vec { + let mut out: Vec = Vec::new(); + for d in self.domains.iter().filter(|d| d.is_scalar()) { + for &t in d.terms { + if t.range_suffix().is_some() && !out.contains(&t) { + out.push(t); + } + } + } + out + } + + /// The unqualified name of this family's range query domain for the + /// ordering term `term`: `query_integer_range_ore`. Lives beside the + /// family's single-bound query domains (`query_integer_ord_ore`), in the + /// versioned schema. + /// + /// # Panics + /// + /// For a term with no ordering ([`Term::range_suffix`] is `None`): no + /// range domain carries it. + pub fn range_query_name(&self, term: Term) -> String { + format!("query_{}_{}", self.name, range_suffix(term)) + } + + /// The PascalCase binding identifier of the range query domain for + /// `term`: `IntegerRangeOreQuery`, the query-twin convention + /// (`IntegerOrdOreQuery`) applied to the range domain. + /// + /// # Panics + /// + /// As [`Self::range_query_name`]. + pub fn range_struct_ident(&self, term: Term) -> String { + let stem: String = format!("{}_{}", self.name, range_suffix(term)) + .split('_') + .filter(|s| !s.is_empty()) + .map(capitalize) + .collect(); + format!("{stem}Query") + } + + /// The stored scalar domains a range over `term` applies to, in catalog + /// order: every one that carries the term. `between(column, range)` has + /// one overload per domain listed. For `integer` and `Ope`, both `_ord` + /// and `_ord_ope`; for `text` and `Ore`, `_ord_ore` and `_search_ore`. + /// + /// Scalar domains only, deliberately for now: a JSON document's entry + /// (`json_entry`) carries the OPE term and compares against + /// `query__ord` with `<` and `>`, but has no `between` overload, so a + /// range over a JSON path is still two predicates. It waits for EQL v4's + /// SteVec bindings, since nothing produces a v4 document yet. + pub fn range_columns(&self, term: Term) -> Vec<&'static Domain> { + self.domains + .iter() + .filter(|d| d.is_scalar() && d.terms.contains(&term)) + .collect() + } +} + +/// [`Term::range_suffix`], refusing a term that has none. +fn range_suffix(term: Term) -> &'static str { + term.range_suffix() + .unwrap_or_else(|| panic!("{term:?} has no ordering, so no range query domain")) } #[cfg(test)] @@ -266,6 +339,73 @@ mod tests { assert_eq!(JSON.domains[3].rust_struct_name(JSON.name), "Json"); } + #[test] + fn every_ordered_family_has_one_range_domain_per_ordering_term() { + use crate::{BOOLEAN, CATALOG, JSON, TEXT}; + let integer = CATALOG.iter().find(|f| f.name == "integer").unwrap(); + assert_eq!(integer.range_terms(), [Term::Ore, Term::Ope]); + assert_eq!(TEXT.range_terms(), [Term::Ore, Term::Ope]); + assert!(BOOLEAN.range_terms().is_empty()); + assert!(JSON.range_terms().is_empty()); + // Every family with an ordering domain has both, and no family has + // a range term it does not store. + for f in CATALOG { + let stored: Vec = f + .domains + .iter() + .flat_map(|d| d.terms.iter().copied()) + .filter(|t| t.provides_ordering()) + .collect(); + for t in f.range_terms() { + assert!(stored.contains(&t), "{}: {t:?}", f.name); + assert!(!f.range_columns(t).is_empty(), "{}: {t:?}", f.name); + } + assert_eq!(f.range_terms().is_empty(), stored.is_empty(), "{}", f.name); + } + } + + #[test] + fn range_domains_follow_the_query_twin_naming() { + use crate::TEXT; + let integer = crate::CATALOG.iter().find(|f| f.name == "integer").unwrap(); + assert_eq!( + integer.range_query_name(Term::Ore), + "query_integer_range_ore" + ); + assert_eq!( + integer.range_query_name(Term::Ope), + "query_integer_range_ope" + ); + assert_eq!( + integer.range_struct_ident(Term::Ore), + "IntegerRangeOreQuery" + ); + assert_eq!(TEXT.range_struct_ident(Term::Ope), "TextRangeOpeQuery"); + } + + #[test] + fn a_range_applies_to_every_stored_domain_carrying_its_term() { + use crate::TEXT; + let names = |terms: Vec<&Domain>| terms.iter().map(|d| d.name).collect::>(); + let integer = crate::CATALOG.iter().find(|f| f.name == "integer").unwrap(); + assert_eq!(names(integer.range_columns(Term::Ore)), ["ord_ore"]); + assert_eq!(names(integer.range_columns(Term::Ope)), ["ord", "ord_ope"]); + assert_eq!( + names(TEXT.range_columns(Term::Ore)), + ["ord_ore", "search_ore"] + ); + assert_eq!( + names(TEXT.range_columns(Term::Ope)), + ["ord", "ord_ope", "search"] + ); + } + + #[test] + #[should_panic(expected = "has no ordering")] + fn a_term_with_no_ordering_has_no_range_domain() { + crate::TEXT.range_query_name(Term::Hm); + } + #[test] fn is_eq_only_is_false_for_the_non_scalar_json_family() { // `is_eq_only` describes the flat-scalar shape (storage + `_eq`, no diff --git a/packages/eql/crates/eql-domains/src/term.rs b/packages/eql/crates/eql-domains/src/term.rs index 64184cfc6..ea96331f8 100644 --- a/packages/eql/crates/eql-domains/src/term.rs +++ b/packages/eql/crates/eql-domains/src/term.rs @@ -109,6 +109,21 @@ impl Term { matches!(self, Term::Ore | Term::Ope) } + /// The suffix of the range query domain that carries a `Between` over + /// this term, or `None` for a term with no ordering: `range_ore` for + /// block ORE, `range_ope` for CLLW OPE. A family has one range domain + /// per ordering term it stores (ADR-0009 decisions 7 and 8), named + /// `query__` by + /// [`DomainFamily::range_query_name`](crate::DomainFamily::range_query_name). + /// `Some` exactly when [`Term::provides_ordering`] is true. + pub const fn range_suffix(self) -> Option<&'static str> { + match self { + Term::Ore => Some("range_ore"), + Term::Ope => Some("range_ope"), + Term::Hm | Term::Bloom => None, + } + } + /// JSON key whose payload must be a NON-EMPTY array for this term to be /// well-formed, or `None` if the term imposes no such structural rule. The /// ORE term (`ob`) is an array of block terms; an empty array (`ob: []`) is @@ -232,6 +247,17 @@ mod tests { assert_eq!(Term::Ope.binding_newtype(), "OpeCllw"); } + #[test] + fn range_suffix_names_each_ordering_term_and_nothing_else() { + assert_eq!(Term::Ore.range_suffix(), Some("range_ore")); + assert_eq!(Term::Ope.range_suffix(), Some("range_ope")); + assert_eq!(Term::Hm.range_suffix(), None); + assert_eq!(Term::Bloom.range_suffix(), None); + for t in [Term::Hm, Term::Ore, Term::Bloom, Term::Ope] { + assert_eq!(t.range_suffix().is_some(), t.provides_ordering(), "{t:?}"); + } + } + #[test] fn payload_terms_is_one_field_per_json_key_in_order() { let keys: Vec<&str> = Term::payload_terms(&[Term::Hm, Term::Ore]) diff --git a/packages/eql/docs/reference/eql-functions.md b/packages/eql/docs/reference/eql-functions.md index 4945843df..4cca37534 100644 --- a/packages/eql/docs/reference/eql-functions.md +++ b/packages/eql/docs/reference/eql-functions.md @@ -10,6 +10,7 @@ A reference for the functions and operators EQL exposes for querying encrypted d - [Function Equivalents](#function-equivalents) - [Index Term Extraction](#index-term-extraction) - [Encrypted JSON (`public.eql_v3_json_search`)](#encrypted-json-publiceql_v3_json_search) +- [Range queries in EQL v4: `eql_v4.between`](#range-queries-in-eql-v4-eql_v4between) - [Aggregate Functions](#aggregate-functions) - [Diagnostics](#diagnostics) @@ -131,6 +132,46 @@ The full encrypted-JSONB function surface — containment, `->` / `->>`, `eql_v3 --- +## Range queries in EQL v4: `eql_v4.between` + +EQL v4 is the same SQL installed as the `eql_v4` schema, for values written by Stack Encrypt (`release/cipherstash-encrypt-v4.sql`). Everything above applies to it with `eql_v4` in place of `eql_v3`. It also has one thing EQL v3 does not: a range query that is **one operand**, so a statement cannot pair the low bound of one query with the high bound of another. + +Every ordered family has a **range query domain** per ordering term, beside its single-bound query domains: + +| Range domain | Bounds | Column domains it applies to | +|---|---|---| +| `eql_v4.query__range_ore` | two block-ORE terms (`ob` arrays) | `public.eql_v4__ord_ore`, `public.eql_v4_text_search_ore` | +| `eql_v4.query__range_ope` | two CLLW-OPE terms (`op` hex strings) | `public.eql_v4__ord`, `public.eql_v4__ord_ope`, `public.eql_v4_text_search` | + +`` is any ordered family: `integer`, `smallint`, `bigint`, `date`, `timestamp`, `numeric`, `text`, `real`, `double`. A range value is `{"v": 4, "i": {...}, "lo": , "hi": }`, each bound the value the single-bound query domain holds under its term key, with no ciphertext `c`. + +```sql +-- true when lo <= column <= hi, both bounds included +eql_v4.between(a public.eql_v4_integer_ord_ore, r eql_v4.query_integer_range_ore) RETURNS boolean +eql_v4.between(a public.eql_v4_integer_ord, r eql_v4.query_integer_range_ope) RETURNS boolean +eql_v4.between(a public.eql_v4_integer_ord_ope, r eql_v4.query_integer_range_ope) RETURNS boolean + +``` + +`between` reads each bound with an internal accessor, `eql_v4_internal.lo(r)` and `eql_v4_internal.hi(r)`, which return the bound as the column's order term type. They are not part of the public surface. + +**Example**, on an OPE column, which indexes on every install: + +```sql +CREATE INDEX ON users USING btree (eql_v4.ord_term(age)); + +SELECT * FROM users WHERE eql_v4.between(age, $1::eql_v4.query_integer_range_ope); +``` + +- `eql_v4.between` is an inlinable SQL function, so the planner sees `ord_term(age) >= lo AND ord_term(age) <= hi`, and a btree on the column's order term serves it as one range scan: `eql_v4.ord_term(col)` for an OPE column (`_ord`, `_ord_ope`, `_search`), `eql_v4.ord_term_ore(col)` for an ORE one (`_ord_ore`, `_search_ore`). +- **An ORE column needs the `eql_v4_internal.ore_block_256` btree operator class**, for its index and so for an ORE range scan. The installer creates that class only when it runs with superuser privileges. Otherwise (on Supabase and other managed roles) it skips it and disables the ORE column domains: a value cast to `_ord_ore` or `_search_ore` raises `feature_not_supported`, naming the OPE and equality alternatives. On such an install, use an OPE column and its `_range_ope` domain, which need nothing more. +- Always write it schema-qualified. `BETWEEN` is an SQL keyword, so a bare `between(...)` does not parse. +- A range over the other term (an OPE range on an `_ord_ore` column) has no overload and fails when the query is planned. +- A `NULL` range matches no row. So does a range whose low bound is above its high bound; the client refuses to build one. +- The `eql_bindings::v4` Rust types (`IntegerRangeOreQuery`, …) and the Go `encrypt/eql` types of the same names hold a range value, so it binds as one parameter. + +--- + ## Aggregate Functions ### `eql_v3.min()` / `eql_v3.max()` (per-domain) diff --git a/packages/eql/mise.toml b/packages/eql/mise.toml index 98d1d10d4..78781df9a 100644 --- a/packages/eql/mise.toml +++ b/packages/eql/mise.toml @@ -322,7 +322,7 @@ cargo clippy --locked -p eql-bindings --features stack-encrypt --all-targets -- """ [tasks."test:encryption:postgres"] -description = "Install the EQL v4 bundle built from the tree into the database EQL_TEST_DATABASE_URL names and run the stack-encrypt PostgreSQL test against it (fresh encryption, fake KMS)" +description = "Install the EQL v4 bundle built from the tree into the database EQL_TEST_DATABASE_URL names and run the PostgreSQL tests against it: stack-encrypt values (fresh encryption, fake KMS), and the range query domains and eql_v4.between" # `build` writes release/cipherstash-encrypt-v4.sql from the SQL in the tree. The # copy under crates/eql-bindings/sql is the RELEASED bundle, rewritten only by # the lockstep version bump, so a test that installs it cannot see a SQL edit @@ -340,7 +340,7 @@ psql "$EQL_TEST_DATABASE_URL" -v ON_ERROR_STOP=1 -f release/cipherstash-encrypt- # `--ignored` selects by attribute, and cargo exits 0 on an empty selection, so # a test that loses its `#[ignore]` or is renamed would leave this task passing # on nothing. List what the filter selects before running it. -for suite in text_eq v4_types; do +for suite in text_eq v4_types v4_range; do listing=$(cargo test --locked -p eql-encryption-tests --test "$suite" -- --ignored --list | sed -n 's/: test$//p') [ -n "$listing" ] || { echo "error: '--ignored' selects no test in $suite; the PostgreSQL test has gone missing." >&2; exit 1; } cargo test --locked -p eql-encryption-tests --test "$suite" -- --ignored diff --git a/packages/eql/src/v3/scalars/bigint/query_bigint_range_functions.sql b/packages/eql/src/v3/scalars/bigint/query_bigint_range_functions.sql new file mode 100644 index 000000000..cfc95b91f --- /dev/null +++ b/packages/eql/src/v3/scalars/bigint/query_bigint_range_functions.sql @@ -0,0 +1,155 @@ +-- AUTOMATICALLY GENERATED FILE. +-- ONLY EQL VERSION: 4 +-- REQUIRE: src/v3/schema.sql +-- REQUIRE: src/v3/scalars/bigint/query_bigint_range_types.sql +-- REQUIRE: src/v3/scalars/bigint/bigint_ord_ore_functions.sql +-- REQUIRE: src/v3/scalars/bigint/bigint_ord_functions.sql +-- REQUIRE: src/v3/scalars/bigint/bigint_ord_ope_functions.sql +-- REQUIRE: src/v3/sem/ore_block_256/functions.sql +-- REQUIRE: src/v3/sem/ore_block_256/operators.sql +-- REQUIRE: src/v3/sem/ope_cllw/functions.sql + +--! @file v3/scalars/bigint/query_bigint_range_functions.sql +--! @brief The bound accessors and `{{prefix}}.between` for bigint's range query domains. +--! @note EQL version {{eql_version}} only, like the range domains themselves. + +--! @brief The low bound of a {{prefix}}.query_bigint_range_ore, as a block ORE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_bigint_range_ore Range query operand +--! @return {{prefix}}_internal.ore_block_256 The low bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.lo(r {{prefix}}.query_bigint_range_ore) +RETURNS {{prefix}}_internal.ore_block_256 +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ore_block_256(jsonb_build_object('ob', r::jsonb -> 'lo')) $$; + +--! @brief The high bound of a {{prefix}}.query_bigint_range_ore, as a block ORE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_bigint_range_ore Range query operand +--! @return {{prefix}}_internal.ore_block_256 The high bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.hi(r {{prefix}}.query_bigint_range_ore) +RETURNS {{prefix}}_internal.ore_block_256 +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ore_block_256(jsonb_build_object('ob', r::jsonb -> 'hi')) $$; + +--! @brief The low bound of a {{prefix}}.query_bigint_range_ope, as a CLLW OPE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_bigint_range_ope Range query operand +--! @return {{prefix}}_internal.ope_cllw The low bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.lo(r {{prefix}}.query_bigint_range_ope) +RETURNS {{prefix}}_internal.ope_cllw +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> 'lo')) $$; + +--! @brief The high bound of a {{prefix}}.query_bigint_range_ope, as a CLLW OPE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_bigint_range_ope Range query operand +--! @return {{prefix}}_internal.ope_cllw The high bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.hi(r {{prefix}}.query_bigint_range_ope) +RETURNS {{prefix}}_internal.ope_cllw +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> 'hi')) $$; + +--! @brief Whether a public.{{prefix}}_bigint_ord_ore value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term_ore(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_bigint_range_ore)`. +--! +--! @param a public.{{prefix}}_bigint_ord_ore Encrypted column value +--! @param r {{prefix}}.query_bigint_range_ore Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term_ore(a)`, which a btree index on that expression +--! serves as one range scan. For this ORE range that index needs the +--! default btree operator class on {{prefix}}_internal.ore_block_256, which the +--! installer creates only with superuser privileges. Without it (on Supabase, +--! for instance) the installer disables the ORE column domains, so use an OPE +--! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_bigint_ord_ore, r {{prefix}}.query_bigint_range_ore) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term_ore(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term_ore(a) <= {{prefix}}_internal.hi(r) $$; + +--! @brief Whether a public.{{prefix}}_bigint_ord value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_bigint_range_ope)`. +--! +--! @param a public.{{prefix}}_bigint_ord Encrypted column value +--! @param r {{prefix}}.query_bigint_range_ope Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term(a)`, which a btree index on that expression +--! serves as one range scan. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_bigint_ord, r {{prefix}}.query_bigint_range_ope) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r) $$; + +--! @brief Whether a public.{{prefix}}_bigint_ord_ope value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_bigint_range_ope)`. +--! +--! @param a public.{{prefix}}_bigint_ord_ope Encrypted column value +--! @param r {{prefix}}.query_bigint_range_ope Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term(a)`, which a btree index on that expression +--! serves as one range scan. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_bigint_ord_ope, r {{prefix}}.query_bigint_range_ope) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r) $$; diff --git a/packages/eql/src/v3/scalars/bigint/query_bigint_range_types.sql b/packages/eql/src/v3/scalars/bigint/query_bigint_range_types.sql new file mode 100644 index 000000000..283efaf20 --- /dev/null +++ b/packages/eql/src/v3/scalars/bigint/query_bigint_range_types.sql @@ -0,0 +1,66 @@ +-- AUTOMATICALLY GENERATED FILE. +-- ONLY EQL VERSION: 4 +-- REQUIRE: src/v3/schema.sql + +--! @file v3/scalars/bigint/query_bigint_range_types.sql +--! @brief Range query-operand domains for bigint: the operand of `{{prefix}}.between`. +--! @note A range holds both bounds of one `BETWEEN` as one value: +--! `{"v": {{eql_version}}, "i": {...}, "lo": , "hi": }`. Each bound is the +--! value the family's single-bound query domain for the same ordering +--! term holds under its term key (`ob` or `op`). Both bounds are +--! included. There is one range domain per ordering term the family +--! stores (ADR-0009, decisions 7 and 8). +--! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this +--! file out of every other version's installer. +--! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, +--! and holds no ciphertext `c`. The client refuses a range whose low +--! bound is above its high bound; one that reaches the database anyway +--! matches no row. + +DO $$ +BEGIN + --! @brief Range query-operand domain {{prefix}}.query_bigint_range_ore (two block ORE bounds; no `c`). + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'query_bigint_range_ore' AND typnamespace = '{{prefix}}'::regnamespace + ) THEN + CREATE DOMAIN {{prefix}}.query_bigint_range_ore AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' + AND VALUE ? 'i' + AND VALUE ? 'lo' + AND VALUE ? 'hi' + AND NOT (VALUE ? 'c') + AND jsonb_typeof(VALUE -> 'lo') = 'array' + AND jsonb_array_length(VALUE -> 'lo') > 0 + AND jsonb_typeof(VALUE -> 'hi') = 'array' + AND jsonb_array_length(VALUE -> 'hi') > 0 + AND VALUE->>'v' = '{{eql_version}}' + ); + END IF; + + COMMENT ON DOMAIN {{prefix}}.query_bigint_range_ore IS 'EQL bigint range query operand (block ORE)'; + + --! @brief Range query-operand domain {{prefix}}.query_bigint_range_ope (two CLLW OPE bounds; no `c`). + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'query_bigint_range_ope' AND typnamespace = '{{prefix}}'::regnamespace + ) THEN + CREATE DOMAIN {{prefix}}.query_bigint_range_ope AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' + AND VALUE ? 'i' + AND VALUE ? 'lo' + AND VALUE ? 'hi' + AND NOT (VALUE ? 'c') + AND jsonb_typeof(VALUE -> 'lo') = 'string' + AND jsonb_typeof(VALUE -> 'hi') = 'string' + AND VALUE->>'v' = '{{eql_version}}' + ); + END IF; + + COMMENT ON DOMAIN {{prefix}}.query_bigint_range_ope IS 'EQL bigint range query operand (CLLW OPE)'; +END +$$; diff --git a/packages/eql/src/v3/scalars/date/query_date_range_functions.sql b/packages/eql/src/v3/scalars/date/query_date_range_functions.sql new file mode 100644 index 000000000..3cd9cb971 --- /dev/null +++ b/packages/eql/src/v3/scalars/date/query_date_range_functions.sql @@ -0,0 +1,155 @@ +-- AUTOMATICALLY GENERATED FILE. +-- ONLY EQL VERSION: 4 +-- REQUIRE: src/v3/schema.sql +-- REQUIRE: src/v3/scalars/date/query_date_range_types.sql +-- REQUIRE: src/v3/scalars/date/date_ord_ore_functions.sql +-- REQUIRE: src/v3/scalars/date/date_ord_functions.sql +-- REQUIRE: src/v3/scalars/date/date_ord_ope_functions.sql +-- REQUIRE: src/v3/sem/ore_block_256/functions.sql +-- REQUIRE: src/v3/sem/ore_block_256/operators.sql +-- REQUIRE: src/v3/sem/ope_cllw/functions.sql + +--! @file v3/scalars/date/query_date_range_functions.sql +--! @brief The bound accessors and `{{prefix}}.between` for date's range query domains. +--! @note EQL version {{eql_version}} only, like the range domains themselves. + +--! @brief The low bound of a {{prefix}}.query_date_range_ore, as a block ORE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_date_range_ore Range query operand +--! @return {{prefix}}_internal.ore_block_256 The low bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.lo(r {{prefix}}.query_date_range_ore) +RETURNS {{prefix}}_internal.ore_block_256 +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ore_block_256(jsonb_build_object('ob', r::jsonb -> 'lo')) $$; + +--! @brief The high bound of a {{prefix}}.query_date_range_ore, as a block ORE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_date_range_ore Range query operand +--! @return {{prefix}}_internal.ore_block_256 The high bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.hi(r {{prefix}}.query_date_range_ore) +RETURNS {{prefix}}_internal.ore_block_256 +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ore_block_256(jsonb_build_object('ob', r::jsonb -> 'hi')) $$; + +--! @brief The low bound of a {{prefix}}.query_date_range_ope, as a CLLW OPE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_date_range_ope Range query operand +--! @return {{prefix}}_internal.ope_cllw The low bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.lo(r {{prefix}}.query_date_range_ope) +RETURNS {{prefix}}_internal.ope_cllw +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> 'lo')) $$; + +--! @brief The high bound of a {{prefix}}.query_date_range_ope, as a CLLW OPE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_date_range_ope Range query operand +--! @return {{prefix}}_internal.ope_cllw The high bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.hi(r {{prefix}}.query_date_range_ope) +RETURNS {{prefix}}_internal.ope_cllw +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> 'hi')) $$; + +--! @brief Whether a public.{{prefix}}_date_ord_ore value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term_ore(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_date_range_ore)`. +--! +--! @param a public.{{prefix}}_date_ord_ore Encrypted column value +--! @param r {{prefix}}.query_date_range_ore Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term_ore(a)`, which a btree index on that expression +--! serves as one range scan. For this ORE range that index needs the +--! default btree operator class on {{prefix}}_internal.ore_block_256, which the +--! installer creates only with superuser privileges. Without it (on Supabase, +--! for instance) the installer disables the ORE column domains, so use an OPE +--! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_date_ord_ore, r {{prefix}}.query_date_range_ore) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term_ore(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term_ore(a) <= {{prefix}}_internal.hi(r) $$; + +--! @brief Whether a public.{{prefix}}_date_ord value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_date_range_ope)`. +--! +--! @param a public.{{prefix}}_date_ord Encrypted column value +--! @param r {{prefix}}.query_date_range_ope Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term(a)`, which a btree index on that expression +--! serves as one range scan. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_date_ord, r {{prefix}}.query_date_range_ope) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r) $$; + +--! @brief Whether a public.{{prefix}}_date_ord_ope value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_date_range_ope)`. +--! +--! @param a public.{{prefix}}_date_ord_ope Encrypted column value +--! @param r {{prefix}}.query_date_range_ope Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term(a)`, which a btree index on that expression +--! serves as one range scan. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_date_ord_ope, r {{prefix}}.query_date_range_ope) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r) $$; diff --git a/packages/eql/src/v3/scalars/date/query_date_range_types.sql b/packages/eql/src/v3/scalars/date/query_date_range_types.sql new file mode 100644 index 000000000..f67a941c0 --- /dev/null +++ b/packages/eql/src/v3/scalars/date/query_date_range_types.sql @@ -0,0 +1,66 @@ +-- AUTOMATICALLY GENERATED FILE. +-- ONLY EQL VERSION: 4 +-- REQUIRE: src/v3/schema.sql + +--! @file v3/scalars/date/query_date_range_types.sql +--! @brief Range query-operand domains for date: the operand of `{{prefix}}.between`. +--! @note A range holds both bounds of one `BETWEEN` as one value: +--! `{"v": {{eql_version}}, "i": {...}, "lo": , "hi": }`. Each bound is the +--! value the family's single-bound query domain for the same ordering +--! term holds under its term key (`ob` or `op`). Both bounds are +--! included. There is one range domain per ordering term the family +--! stores (ADR-0009, decisions 7 and 8). +--! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this +--! file out of every other version's installer. +--! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, +--! and holds no ciphertext `c`. The client refuses a range whose low +--! bound is above its high bound; one that reaches the database anyway +--! matches no row. + +DO $$ +BEGIN + --! @brief Range query-operand domain {{prefix}}.query_date_range_ore (two block ORE bounds; no `c`). + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'query_date_range_ore' AND typnamespace = '{{prefix}}'::regnamespace + ) THEN + CREATE DOMAIN {{prefix}}.query_date_range_ore AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' + AND VALUE ? 'i' + AND VALUE ? 'lo' + AND VALUE ? 'hi' + AND NOT (VALUE ? 'c') + AND jsonb_typeof(VALUE -> 'lo') = 'array' + AND jsonb_array_length(VALUE -> 'lo') > 0 + AND jsonb_typeof(VALUE -> 'hi') = 'array' + AND jsonb_array_length(VALUE -> 'hi') > 0 + AND VALUE->>'v' = '{{eql_version}}' + ); + END IF; + + COMMENT ON DOMAIN {{prefix}}.query_date_range_ore IS 'EQL date range query operand (block ORE)'; + + --! @brief Range query-operand domain {{prefix}}.query_date_range_ope (two CLLW OPE bounds; no `c`). + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'query_date_range_ope' AND typnamespace = '{{prefix}}'::regnamespace + ) THEN + CREATE DOMAIN {{prefix}}.query_date_range_ope AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' + AND VALUE ? 'i' + AND VALUE ? 'lo' + AND VALUE ? 'hi' + AND NOT (VALUE ? 'c') + AND jsonb_typeof(VALUE -> 'lo') = 'string' + AND jsonb_typeof(VALUE -> 'hi') = 'string' + AND VALUE->>'v' = '{{eql_version}}' + ); + END IF; + + COMMENT ON DOMAIN {{prefix}}.query_date_range_ope IS 'EQL date range query operand (CLLW OPE)'; +END +$$; diff --git a/packages/eql/src/v3/scalars/double/query_double_range_functions.sql b/packages/eql/src/v3/scalars/double/query_double_range_functions.sql new file mode 100644 index 000000000..a95a5d6a5 --- /dev/null +++ b/packages/eql/src/v3/scalars/double/query_double_range_functions.sql @@ -0,0 +1,155 @@ +-- AUTOMATICALLY GENERATED FILE. +-- ONLY EQL VERSION: 4 +-- REQUIRE: src/v3/schema.sql +-- REQUIRE: src/v3/scalars/double/query_double_range_types.sql +-- REQUIRE: src/v3/scalars/double/double_ord_ore_functions.sql +-- REQUIRE: src/v3/scalars/double/double_ord_functions.sql +-- REQUIRE: src/v3/scalars/double/double_ord_ope_functions.sql +-- REQUIRE: src/v3/sem/ore_block_256/functions.sql +-- REQUIRE: src/v3/sem/ore_block_256/operators.sql +-- REQUIRE: src/v3/sem/ope_cllw/functions.sql + +--! @file v3/scalars/double/query_double_range_functions.sql +--! @brief The bound accessors and `{{prefix}}.between` for double's range query domains. +--! @note EQL version {{eql_version}} only, like the range domains themselves. + +--! @brief The low bound of a {{prefix}}.query_double_range_ore, as a block ORE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_double_range_ore Range query operand +--! @return {{prefix}}_internal.ore_block_256 The low bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.lo(r {{prefix}}.query_double_range_ore) +RETURNS {{prefix}}_internal.ore_block_256 +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ore_block_256(jsonb_build_object('ob', r::jsonb -> 'lo')) $$; + +--! @brief The high bound of a {{prefix}}.query_double_range_ore, as a block ORE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_double_range_ore Range query operand +--! @return {{prefix}}_internal.ore_block_256 The high bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.hi(r {{prefix}}.query_double_range_ore) +RETURNS {{prefix}}_internal.ore_block_256 +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ore_block_256(jsonb_build_object('ob', r::jsonb -> 'hi')) $$; + +--! @brief The low bound of a {{prefix}}.query_double_range_ope, as a CLLW OPE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_double_range_ope Range query operand +--! @return {{prefix}}_internal.ope_cllw The low bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.lo(r {{prefix}}.query_double_range_ope) +RETURNS {{prefix}}_internal.ope_cllw +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> 'lo')) $$; + +--! @brief The high bound of a {{prefix}}.query_double_range_ope, as a CLLW OPE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_double_range_ope Range query operand +--! @return {{prefix}}_internal.ope_cllw The high bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.hi(r {{prefix}}.query_double_range_ope) +RETURNS {{prefix}}_internal.ope_cllw +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> 'hi')) $$; + +--! @brief Whether a public.{{prefix}}_double_ord_ore value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term_ore(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_double_range_ore)`. +--! +--! @param a public.{{prefix}}_double_ord_ore Encrypted column value +--! @param r {{prefix}}.query_double_range_ore Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term_ore(a)`, which a btree index on that expression +--! serves as one range scan. For this ORE range that index needs the +--! default btree operator class on {{prefix}}_internal.ore_block_256, which the +--! installer creates only with superuser privileges. Without it (on Supabase, +--! for instance) the installer disables the ORE column domains, so use an OPE +--! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_double_ord_ore, r {{prefix}}.query_double_range_ore) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term_ore(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term_ore(a) <= {{prefix}}_internal.hi(r) $$; + +--! @brief Whether a public.{{prefix}}_double_ord value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_double_range_ope)`. +--! +--! @param a public.{{prefix}}_double_ord Encrypted column value +--! @param r {{prefix}}.query_double_range_ope Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term(a)`, which a btree index on that expression +--! serves as one range scan. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_double_ord, r {{prefix}}.query_double_range_ope) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r) $$; + +--! @brief Whether a public.{{prefix}}_double_ord_ope value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_double_range_ope)`. +--! +--! @param a public.{{prefix}}_double_ord_ope Encrypted column value +--! @param r {{prefix}}.query_double_range_ope Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term(a)`, which a btree index on that expression +--! serves as one range scan. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_double_ord_ope, r {{prefix}}.query_double_range_ope) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r) $$; diff --git a/packages/eql/src/v3/scalars/double/query_double_range_types.sql b/packages/eql/src/v3/scalars/double/query_double_range_types.sql new file mode 100644 index 000000000..9690a403f --- /dev/null +++ b/packages/eql/src/v3/scalars/double/query_double_range_types.sql @@ -0,0 +1,66 @@ +-- AUTOMATICALLY GENERATED FILE. +-- ONLY EQL VERSION: 4 +-- REQUIRE: src/v3/schema.sql + +--! @file v3/scalars/double/query_double_range_types.sql +--! @brief Range query-operand domains for double: the operand of `{{prefix}}.between`. +--! @note A range holds both bounds of one `BETWEEN` as one value: +--! `{"v": {{eql_version}}, "i": {...}, "lo": , "hi": }`. Each bound is the +--! value the family's single-bound query domain for the same ordering +--! term holds under its term key (`ob` or `op`). Both bounds are +--! included. There is one range domain per ordering term the family +--! stores (ADR-0009, decisions 7 and 8). +--! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this +--! file out of every other version's installer. +--! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, +--! and holds no ciphertext `c`. The client refuses a range whose low +--! bound is above its high bound; one that reaches the database anyway +--! matches no row. + +DO $$ +BEGIN + --! @brief Range query-operand domain {{prefix}}.query_double_range_ore (two block ORE bounds; no `c`). + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'query_double_range_ore' AND typnamespace = '{{prefix}}'::regnamespace + ) THEN + CREATE DOMAIN {{prefix}}.query_double_range_ore AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' + AND VALUE ? 'i' + AND VALUE ? 'lo' + AND VALUE ? 'hi' + AND NOT (VALUE ? 'c') + AND jsonb_typeof(VALUE -> 'lo') = 'array' + AND jsonb_array_length(VALUE -> 'lo') > 0 + AND jsonb_typeof(VALUE -> 'hi') = 'array' + AND jsonb_array_length(VALUE -> 'hi') > 0 + AND VALUE->>'v' = '{{eql_version}}' + ); + END IF; + + COMMENT ON DOMAIN {{prefix}}.query_double_range_ore IS 'EQL double range query operand (block ORE)'; + + --! @brief Range query-operand domain {{prefix}}.query_double_range_ope (two CLLW OPE bounds; no `c`). + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'query_double_range_ope' AND typnamespace = '{{prefix}}'::regnamespace + ) THEN + CREATE DOMAIN {{prefix}}.query_double_range_ope AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' + AND VALUE ? 'i' + AND VALUE ? 'lo' + AND VALUE ? 'hi' + AND NOT (VALUE ? 'c') + AND jsonb_typeof(VALUE -> 'lo') = 'string' + AND jsonb_typeof(VALUE -> 'hi') = 'string' + AND VALUE->>'v' = '{{eql_version}}' + ); + END IF; + + COMMENT ON DOMAIN {{prefix}}.query_double_range_ope IS 'EQL double range query operand (CLLW OPE)'; +END +$$; diff --git a/packages/eql/src/v3/scalars/integer/query_integer_range_functions.sql b/packages/eql/src/v3/scalars/integer/query_integer_range_functions.sql new file mode 100644 index 000000000..aa82c3741 --- /dev/null +++ b/packages/eql/src/v3/scalars/integer/query_integer_range_functions.sql @@ -0,0 +1,155 @@ +-- AUTOMATICALLY GENERATED FILE. +-- ONLY EQL VERSION: 4 +-- REQUIRE: src/v3/schema.sql +-- REQUIRE: src/v3/scalars/integer/query_integer_range_types.sql +-- REQUIRE: src/v3/scalars/integer/integer_ord_ore_functions.sql +-- REQUIRE: src/v3/scalars/integer/integer_ord_functions.sql +-- REQUIRE: src/v3/scalars/integer/integer_ord_ope_functions.sql +-- REQUIRE: src/v3/sem/ore_block_256/functions.sql +-- REQUIRE: src/v3/sem/ore_block_256/operators.sql +-- REQUIRE: src/v3/sem/ope_cllw/functions.sql + +--! @file v3/scalars/integer/query_integer_range_functions.sql +--! @brief The bound accessors and `{{prefix}}.between` for integer's range query domains. +--! @note EQL version {{eql_version}} only, like the range domains themselves. + +--! @brief The low bound of a {{prefix}}.query_integer_range_ore, as a block ORE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_integer_range_ore Range query operand +--! @return {{prefix}}_internal.ore_block_256 The low bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.lo(r {{prefix}}.query_integer_range_ore) +RETURNS {{prefix}}_internal.ore_block_256 +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ore_block_256(jsonb_build_object('ob', r::jsonb -> 'lo')) $$; + +--! @brief The high bound of a {{prefix}}.query_integer_range_ore, as a block ORE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_integer_range_ore Range query operand +--! @return {{prefix}}_internal.ore_block_256 The high bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.hi(r {{prefix}}.query_integer_range_ore) +RETURNS {{prefix}}_internal.ore_block_256 +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ore_block_256(jsonb_build_object('ob', r::jsonb -> 'hi')) $$; + +--! @brief The low bound of a {{prefix}}.query_integer_range_ope, as a CLLW OPE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_integer_range_ope Range query operand +--! @return {{prefix}}_internal.ope_cllw The low bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.lo(r {{prefix}}.query_integer_range_ope) +RETURNS {{prefix}}_internal.ope_cllw +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> 'lo')) $$; + +--! @brief The high bound of a {{prefix}}.query_integer_range_ope, as a CLLW OPE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_integer_range_ope Range query operand +--! @return {{prefix}}_internal.ope_cllw The high bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.hi(r {{prefix}}.query_integer_range_ope) +RETURNS {{prefix}}_internal.ope_cllw +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> 'hi')) $$; + +--! @brief Whether a public.{{prefix}}_integer_ord_ore value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term_ore(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_integer_range_ore)`. +--! +--! @param a public.{{prefix}}_integer_ord_ore Encrypted column value +--! @param r {{prefix}}.query_integer_range_ore Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term_ore(a)`, which a btree index on that expression +--! serves as one range scan. For this ORE range that index needs the +--! default btree operator class on {{prefix}}_internal.ore_block_256, which the +--! installer creates only with superuser privileges. Without it (on Supabase, +--! for instance) the installer disables the ORE column domains, so use an OPE +--! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_integer_ord_ore, r {{prefix}}.query_integer_range_ore) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term_ore(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term_ore(a) <= {{prefix}}_internal.hi(r) $$; + +--! @brief Whether a public.{{prefix}}_integer_ord value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_integer_range_ope)`. +--! +--! @param a public.{{prefix}}_integer_ord Encrypted column value +--! @param r {{prefix}}.query_integer_range_ope Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term(a)`, which a btree index on that expression +--! serves as one range scan. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_integer_ord, r {{prefix}}.query_integer_range_ope) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r) $$; + +--! @brief Whether a public.{{prefix}}_integer_ord_ope value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_integer_range_ope)`. +--! +--! @param a public.{{prefix}}_integer_ord_ope Encrypted column value +--! @param r {{prefix}}.query_integer_range_ope Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term(a)`, which a btree index on that expression +--! serves as one range scan. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_integer_ord_ope, r {{prefix}}.query_integer_range_ope) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r) $$; diff --git a/packages/eql/src/v3/scalars/integer/query_integer_range_types.sql b/packages/eql/src/v3/scalars/integer/query_integer_range_types.sql new file mode 100644 index 000000000..52246716b --- /dev/null +++ b/packages/eql/src/v3/scalars/integer/query_integer_range_types.sql @@ -0,0 +1,66 @@ +-- AUTOMATICALLY GENERATED FILE. +-- ONLY EQL VERSION: 4 +-- REQUIRE: src/v3/schema.sql + +--! @file v3/scalars/integer/query_integer_range_types.sql +--! @brief Range query-operand domains for integer: the operand of `{{prefix}}.between`. +--! @note A range holds both bounds of one `BETWEEN` as one value: +--! `{"v": {{eql_version}}, "i": {...}, "lo": , "hi": }`. Each bound is the +--! value the family's single-bound query domain for the same ordering +--! term holds under its term key (`ob` or `op`). Both bounds are +--! included. There is one range domain per ordering term the family +--! stores (ADR-0009, decisions 7 and 8). +--! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this +--! file out of every other version's installer. +--! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, +--! and holds no ciphertext `c`. The client refuses a range whose low +--! bound is above its high bound; one that reaches the database anyway +--! matches no row. + +DO $$ +BEGIN + --! @brief Range query-operand domain {{prefix}}.query_integer_range_ore (two block ORE bounds; no `c`). + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'query_integer_range_ore' AND typnamespace = '{{prefix}}'::regnamespace + ) THEN + CREATE DOMAIN {{prefix}}.query_integer_range_ore AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' + AND VALUE ? 'i' + AND VALUE ? 'lo' + AND VALUE ? 'hi' + AND NOT (VALUE ? 'c') + AND jsonb_typeof(VALUE -> 'lo') = 'array' + AND jsonb_array_length(VALUE -> 'lo') > 0 + AND jsonb_typeof(VALUE -> 'hi') = 'array' + AND jsonb_array_length(VALUE -> 'hi') > 0 + AND VALUE->>'v' = '{{eql_version}}' + ); + END IF; + + COMMENT ON DOMAIN {{prefix}}.query_integer_range_ore IS 'EQL integer range query operand (block ORE)'; + + --! @brief Range query-operand domain {{prefix}}.query_integer_range_ope (two CLLW OPE bounds; no `c`). + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'query_integer_range_ope' AND typnamespace = '{{prefix}}'::regnamespace + ) THEN + CREATE DOMAIN {{prefix}}.query_integer_range_ope AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' + AND VALUE ? 'i' + AND VALUE ? 'lo' + AND VALUE ? 'hi' + AND NOT (VALUE ? 'c') + AND jsonb_typeof(VALUE -> 'lo') = 'string' + AND jsonb_typeof(VALUE -> 'hi') = 'string' + AND VALUE->>'v' = '{{eql_version}}' + ); + END IF; + + COMMENT ON DOMAIN {{prefix}}.query_integer_range_ope IS 'EQL integer range query operand (CLLW OPE)'; +END +$$; diff --git a/packages/eql/src/v3/scalars/numeric/query_numeric_range_functions.sql b/packages/eql/src/v3/scalars/numeric/query_numeric_range_functions.sql new file mode 100644 index 000000000..83041cc93 --- /dev/null +++ b/packages/eql/src/v3/scalars/numeric/query_numeric_range_functions.sql @@ -0,0 +1,155 @@ +-- AUTOMATICALLY GENERATED FILE. +-- ONLY EQL VERSION: 4 +-- REQUIRE: src/v3/schema.sql +-- REQUIRE: src/v3/scalars/numeric/query_numeric_range_types.sql +-- REQUIRE: src/v3/scalars/numeric/numeric_ord_ore_functions.sql +-- REQUIRE: src/v3/scalars/numeric/numeric_ord_functions.sql +-- REQUIRE: src/v3/scalars/numeric/numeric_ord_ope_functions.sql +-- REQUIRE: src/v3/sem/ore_block_256/functions.sql +-- REQUIRE: src/v3/sem/ore_block_256/operators.sql +-- REQUIRE: src/v3/sem/ope_cllw/functions.sql + +--! @file v3/scalars/numeric/query_numeric_range_functions.sql +--! @brief The bound accessors and `{{prefix}}.between` for numeric's range query domains. +--! @note EQL version {{eql_version}} only, like the range domains themselves. + +--! @brief The low bound of a {{prefix}}.query_numeric_range_ore, as a block ORE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_numeric_range_ore Range query operand +--! @return {{prefix}}_internal.ore_block_256 The low bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.lo(r {{prefix}}.query_numeric_range_ore) +RETURNS {{prefix}}_internal.ore_block_256 +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ore_block_256(jsonb_build_object('ob', r::jsonb -> 'lo')) $$; + +--! @brief The high bound of a {{prefix}}.query_numeric_range_ore, as a block ORE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_numeric_range_ore Range query operand +--! @return {{prefix}}_internal.ore_block_256 The high bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.hi(r {{prefix}}.query_numeric_range_ore) +RETURNS {{prefix}}_internal.ore_block_256 +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ore_block_256(jsonb_build_object('ob', r::jsonb -> 'hi')) $$; + +--! @brief The low bound of a {{prefix}}.query_numeric_range_ope, as a CLLW OPE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_numeric_range_ope Range query operand +--! @return {{prefix}}_internal.ope_cllw The low bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.lo(r {{prefix}}.query_numeric_range_ope) +RETURNS {{prefix}}_internal.ope_cllw +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> 'lo')) $$; + +--! @brief The high bound of a {{prefix}}.query_numeric_range_ope, as a CLLW OPE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_numeric_range_ope Range query operand +--! @return {{prefix}}_internal.ope_cllw The high bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.hi(r {{prefix}}.query_numeric_range_ope) +RETURNS {{prefix}}_internal.ope_cllw +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> 'hi')) $$; + +--! @brief Whether a public.{{prefix}}_numeric_ord_ore value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term_ore(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_numeric_range_ore)`. +--! +--! @param a public.{{prefix}}_numeric_ord_ore Encrypted column value +--! @param r {{prefix}}.query_numeric_range_ore Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term_ore(a)`, which a btree index on that expression +--! serves as one range scan. For this ORE range that index needs the +--! default btree operator class on {{prefix}}_internal.ore_block_256, which the +--! installer creates only with superuser privileges. Without it (on Supabase, +--! for instance) the installer disables the ORE column domains, so use an OPE +--! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_numeric_ord_ore, r {{prefix}}.query_numeric_range_ore) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term_ore(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term_ore(a) <= {{prefix}}_internal.hi(r) $$; + +--! @brief Whether a public.{{prefix}}_numeric_ord value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_numeric_range_ope)`. +--! +--! @param a public.{{prefix}}_numeric_ord Encrypted column value +--! @param r {{prefix}}.query_numeric_range_ope Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term(a)`, which a btree index on that expression +--! serves as one range scan. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_numeric_ord, r {{prefix}}.query_numeric_range_ope) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r) $$; + +--! @brief Whether a public.{{prefix}}_numeric_ord_ope value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_numeric_range_ope)`. +--! +--! @param a public.{{prefix}}_numeric_ord_ope Encrypted column value +--! @param r {{prefix}}.query_numeric_range_ope Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term(a)`, which a btree index on that expression +--! serves as one range scan. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_numeric_ord_ope, r {{prefix}}.query_numeric_range_ope) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r) $$; diff --git a/packages/eql/src/v3/scalars/numeric/query_numeric_range_types.sql b/packages/eql/src/v3/scalars/numeric/query_numeric_range_types.sql new file mode 100644 index 000000000..acf68b489 --- /dev/null +++ b/packages/eql/src/v3/scalars/numeric/query_numeric_range_types.sql @@ -0,0 +1,66 @@ +-- AUTOMATICALLY GENERATED FILE. +-- ONLY EQL VERSION: 4 +-- REQUIRE: src/v3/schema.sql + +--! @file v3/scalars/numeric/query_numeric_range_types.sql +--! @brief Range query-operand domains for numeric: the operand of `{{prefix}}.between`. +--! @note A range holds both bounds of one `BETWEEN` as one value: +--! `{"v": {{eql_version}}, "i": {...}, "lo": , "hi": }`. Each bound is the +--! value the family's single-bound query domain for the same ordering +--! term holds under its term key (`ob` or `op`). Both bounds are +--! included. There is one range domain per ordering term the family +--! stores (ADR-0009, decisions 7 and 8). +--! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this +--! file out of every other version's installer. +--! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, +--! and holds no ciphertext `c`. The client refuses a range whose low +--! bound is above its high bound; one that reaches the database anyway +--! matches no row. + +DO $$ +BEGIN + --! @brief Range query-operand domain {{prefix}}.query_numeric_range_ore (two block ORE bounds; no `c`). + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'query_numeric_range_ore' AND typnamespace = '{{prefix}}'::regnamespace + ) THEN + CREATE DOMAIN {{prefix}}.query_numeric_range_ore AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' + AND VALUE ? 'i' + AND VALUE ? 'lo' + AND VALUE ? 'hi' + AND NOT (VALUE ? 'c') + AND jsonb_typeof(VALUE -> 'lo') = 'array' + AND jsonb_array_length(VALUE -> 'lo') > 0 + AND jsonb_typeof(VALUE -> 'hi') = 'array' + AND jsonb_array_length(VALUE -> 'hi') > 0 + AND VALUE->>'v' = '{{eql_version}}' + ); + END IF; + + COMMENT ON DOMAIN {{prefix}}.query_numeric_range_ore IS 'EQL numeric range query operand (block ORE)'; + + --! @brief Range query-operand domain {{prefix}}.query_numeric_range_ope (two CLLW OPE bounds; no `c`). + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'query_numeric_range_ope' AND typnamespace = '{{prefix}}'::regnamespace + ) THEN + CREATE DOMAIN {{prefix}}.query_numeric_range_ope AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' + AND VALUE ? 'i' + AND VALUE ? 'lo' + AND VALUE ? 'hi' + AND NOT (VALUE ? 'c') + AND jsonb_typeof(VALUE -> 'lo') = 'string' + AND jsonb_typeof(VALUE -> 'hi') = 'string' + AND VALUE->>'v' = '{{eql_version}}' + ); + END IF; + + COMMENT ON DOMAIN {{prefix}}.query_numeric_range_ope IS 'EQL numeric range query operand (CLLW OPE)'; +END +$$; diff --git a/packages/eql/src/v3/scalars/real/query_real_range_functions.sql b/packages/eql/src/v3/scalars/real/query_real_range_functions.sql new file mode 100644 index 000000000..e6924a547 --- /dev/null +++ b/packages/eql/src/v3/scalars/real/query_real_range_functions.sql @@ -0,0 +1,155 @@ +-- AUTOMATICALLY GENERATED FILE. +-- ONLY EQL VERSION: 4 +-- REQUIRE: src/v3/schema.sql +-- REQUIRE: src/v3/scalars/real/query_real_range_types.sql +-- REQUIRE: src/v3/scalars/real/real_ord_ore_functions.sql +-- REQUIRE: src/v3/scalars/real/real_ord_functions.sql +-- REQUIRE: src/v3/scalars/real/real_ord_ope_functions.sql +-- REQUIRE: src/v3/sem/ore_block_256/functions.sql +-- REQUIRE: src/v3/sem/ore_block_256/operators.sql +-- REQUIRE: src/v3/sem/ope_cllw/functions.sql + +--! @file v3/scalars/real/query_real_range_functions.sql +--! @brief The bound accessors and `{{prefix}}.between` for real's range query domains. +--! @note EQL version {{eql_version}} only, like the range domains themselves. + +--! @brief The low bound of a {{prefix}}.query_real_range_ore, as a block ORE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_real_range_ore Range query operand +--! @return {{prefix}}_internal.ore_block_256 The low bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.lo(r {{prefix}}.query_real_range_ore) +RETURNS {{prefix}}_internal.ore_block_256 +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ore_block_256(jsonb_build_object('ob', r::jsonb -> 'lo')) $$; + +--! @brief The high bound of a {{prefix}}.query_real_range_ore, as a block ORE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_real_range_ore Range query operand +--! @return {{prefix}}_internal.ore_block_256 The high bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.hi(r {{prefix}}.query_real_range_ore) +RETURNS {{prefix}}_internal.ore_block_256 +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ore_block_256(jsonb_build_object('ob', r::jsonb -> 'hi')) $$; + +--! @brief The low bound of a {{prefix}}.query_real_range_ope, as a CLLW OPE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_real_range_ope Range query operand +--! @return {{prefix}}_internal.ope_cllw The low bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.lo(r {{prefix}}.query_real_range_ope) +RETURNS {{prefix}}_internal.ope_cllw +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> 'lo')) $$; + +--! @brief The high bound of a {{prefix}}.query_real_range_ope, as a CLLW OPE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_real_range_ope Range query operand +--! @return {{prefix}}_internal.ope_cllw The high bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.hi(r {{prefix}}.query_real_range_ope) +RETURNS {{prefix}}_internal.ope_cllw +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> 'hi')) $$; + +--! @brief Whether a public.{{prefix}}_real_ord_ore value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term_ore(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_real_range_ore)`. +--! +--! @param a public.{{prefix}}_real_ord_ore Encrypted column value +--! @param r {{prefix}}.query_real_range_ore Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term_ore(a)`, which a btree index on that expression +--! serves as one range scan. For this ORE range that index needs the +--! default btree operator class on {{prefix}}_internal.ore_block_256, which the +--! installer creates only with superuser privileges. Without it (on Supabase, +--! for instance) the installer disables the ORE column domains, so use an OPE +--! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_real_ord_ore, r {{prefix}}.query_real_range_ore) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term_ore(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term_ore(a) <= {{prefix}}_internal.hi(r) $$; + +--! @brief Whether a public.{{prefix}}_real_ord value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_real_range_ope)`. +--! +--! @param a public.{{prefix}}_real_ord Encrypted column value +--! @param r {{prefix}}.query_real_range_ope Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term(a)`, which a btree index on that expression +--! serves as one range scan. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_real_ord, r {{prefix}}.query_real_range_ope) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r) $$; + +--! @brief Whether a public.{{prefix}}_real_ord_ope value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_real_range_ope)`. +--! +--! @param a public.{{prefix}}_real_ord_ope Encrypted column value +--! @param r {{prefix}}.query_real_range_ope Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term(a)`, which a btree index on that expression +--! serves as one range scan. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_real_ord_ope, r {{prefix}}.query_real_range_ope) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r) $$; diff --git a/packages/eql/src/v3/scalars/real/query_real_range_types.sql b/packages/eql/src/v3/scalars/real/query_real_range_types.sql new file mode 100644 index 000000000..4051e382c --- /dev/null +++ b/packages/eql/src/v3/scalars/real/query_real_range_types.sql @@ -0,0 +1,66 @@ +-- AUTOMATICALLY GENERATED FILE. +-- ONLY EQL VERSION: 4 +-- REQUIRE: src/v3/schema.sql + +--! @file v3/scalars/real/query_real_range_types.sql +--! @brief Range query-operand domains for real: the operand of `{{prefix}}.between`. +--! @note A range holds both bounds of one `BETWEEN` as one value: +--! `{"v": {{eql_version}}, "i": {...}, "lo": , "hi": }`. Each bound is the +--! value the family's single-bound query domain for the same ordering +--! term holds under its term key (`ob` or `op`). Both bounds are +--! included. There is one range domain per ordering term the family +--! stores (ADR-0009, decisions 7 and 8). +--! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this +--! file out of every other version's installer. +--! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, +--! and holds no ciphertext `c`. The client refuses a range whose low +--! bound is above its high bound; one that reaches the database anyway +--! matches no row. + +DO $$ +BEGIN + --! @brief Range query-operand domain {{prefix}}.query_real_range_ore (two block ORE bounds; no `c`). + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'query_real_range_ore' AND typnamespace = '{{prefix}}'::regnamespace + ) THEN + CREATE DOMAIN {{prefix}}.query_real_range_ore AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' + AND VALUE ? 'i' + AND VALUE ? 'lo' + AND VALUE ? 'hi' + AND NOT (VALUE ? 'c') + AND jsonb_typeof(VALUE -> 'lo') = 'array' + AND jsonb_array_length(VALUE -> 'lo') > 0 + AND jsonb_typeof(VALUE -> 'hi') = 'array' + AND jsonb_array_length(VALUE -> 'hi') > 0 + AND VALUE->>'v' = '{{eql_version}}' + ); + END IF; + + COMMENT ON DOMAIN {{prefix}}.query_real_range_ore IS 'EQL real range query operand (block ORE)'; + + --! @brief Range query-operand domain {{prefix}}.query_real_range_ope (two CLLW OPE bounds; no `c`). + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'query_real_range_ope' AND typnamespace = '{{prefix}}'::regnamespace + ) THEN + CREATE DOMAIN {{prefix}}.query_real_range_ope AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' + AND VALUE ? 'i' + AND VALUE ? 'lo' + AND VALUE ? 'hi' + AND NOT (VALUE ? 'c') + AND jsonb_typeof(VALUE -> 'lo') = 'string' + AND jsonb_typeof(VALUE -> 'hi') = 'string' + AND VALUE->>'v' = '{{eql_version}}' + ); + END IF; + + COMMENT ON DOMAIN {{prefix}}.query_real_range_ope IS 'EQL real range query operand (CLLW OPE)'; +END +$$; diff --git a/packages/eql/src/v3/scalars/smallint/query_smallint_range_functions.sql b/packages/eql/src/v3/scalars/smallint/query_smallint_range_functions.sql new file mode 100644 index 000000000..ee376ae2e --- /dev/null +++ b/packages/eql/src/v3/scalars/smallint/query_smallint_range_functions.sql @@ -0,0 +1,155 @@ +-- AUTOMATICALLY GENERATED FILE. +-- ONLY EQL VERSION: 4 +-- REQUIRE: src/v3/schema.sql +-- REQUIRE: src/v3/scalars/smallint/query_smallint_range_types.sql +-- REQUIRE: src/v3/scalars/smallint/smallint_ord_ore_functions.sql +-- REQUIRE: src/v3/scalars/smallint/smallint_ord_functions.sql +-- REQUIRE: src/v3/scalars/smallint/smallint_ord_ope_functions.sql +-- REQUIRE: src/v3/sem/ore_block_256/functions.sql +-- REQUIRE: src/v3/sem/ore_block_256/operators.sql +-- REQUIRE: src/v3/sem/ope_cllw/functions.sql + +--! @file v3/scalars/smallint/query_smallint_range_functions.sql +--! @brief The bound accessors and `{{prefix}}.between` for smallint's range query domains. +--! @note EQL version {{eql_version}} only, like the range domains themselves. + +--! @brief The low bound of a {{prefix}}.query_smallint_range_ore, as a block ORE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_smallint_range_ore Range query operand +--! @return {{prefix}}_internal.ore_block_256 The low bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.lo(r {{prefix}}.query_smallint_range_ore) +RETURNS {{prefix}}_internal.ore_block_256 +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ore_block_256(jsonb_build_object('ob', r::jsonb -> 'lo')) $$; + +--! @brief The high bound of a {{prefix}}.query_smallint_range_ore, as a block ORE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_smallint_range_ore Range query operand +--! @return {{prefix}}_internal.ore_block_256 The high bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.hi(r {{prefix}}.query_smallint_range_ore) +RETURNS {{prefix}}_internal.ore_block_256 +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ore_block_256(jsonb_build_object('ob', r::jsonb -> 'hi')) $$; + +--! @brief The low bound of a {{prefix}}.query_smallint_range_ope, as a CLLW OPE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_smallint_range_ope Range query operand +--! @return {{prefix}}_internal.ope_cllw The low bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.lo(r {{prefix}}.query_smallint_range_ope) +RETURNS {{prefix}}_internal.ope_cllw +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> 'lo')) $$; + +--! @brief The high bound of a {{prefix}}.query_smallint_range_ope, as a CLLW OPE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_smallint_range_ope Range query operand +--! @return {{prefix}}_internal.ope_cllw The high bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.hi(r {{prefix}}.query_smallint_range_ope) +RETURNS {{prefix}}_internal.ope_cllw +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> 'hi')) $$; + +--! @brief Whether a public.{{prefix}}_smallint_ord_ore value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term_ore(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_smallint_range_ore)`. +--! +--! @param a public.{{prefix}}_smallint_ord_ore Encrypted column value +--! @param r {{prefix}}.query_smallint_range_ore Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term_ore(a)`, which a btree index on that expression +--! serves as one range scan. For this ORE range that index needs the +--! default btree operator class on {{prefix}}_internal.ore_block_256, which the +--! installer creates only with superuser privileges. Without it (on Supabase, +--! for instance) the installer disables the ORE column domains, so use an OPE +--! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_smallint_ord_ore, r {{prefix}}.query_smallint_range_ore) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term_ore(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term_ore(a) <= {{prefix}}_internal.hi(r) $$; + +--! @brief Whether a public.{{prefix}}_smallint_ord value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_smallint_range_ope)`. +--! +--! @param a public.{{prefix}}_smallint_ord Encrypted column value +--! @param r {{prefix}}.query_smallint_range_ope Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term(a)`, which a btree index on that expression +--! serves as one range scan. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_smallint_ord, r {{prefix}}.query_smallint_range_ope) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r) $$; + +--! @brief Whether a public.{{prefix}}_smallint_ord_ope value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_smallint_range_ope)`. +--! +--! @param a public.{{prefix}}_smallint_ord_ope Encrypted column value +--! @param r {{prefix}}.query_smallint_range_ope Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term(a)`, which a btree index on that expression +--! serves as one range scan. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_smallint_ord_ope, r {{prefix}}.query_smallint_range_ope) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r) $$; diff --git a/packages/eql/src/v3/scalars/smallint/query_smallint_range_types.sql b/packages/eql/src/v3/scalars/smallint/query_smallint_range_types.sql new file mode 100644 index 000000000..116dc64f4 --- /dev/null +++ b/packages/eql/src/v3/scalars/smallint/query_smallint_range_types.sql @@ -0,0 +1,66 @@ +-- AUTOMATICALLY GENERATED FILE. +-- ONLY EQL VERSION: 4 +-- REQUIRE: src/v3/schema.sql + +--! @file v3/scalars/smallint/query_smallint_range_types.sql +--! @brief Range query-operand domains for smallint: the operand of `{{prefix}}.between`. +--! @note A range holds both bounds of one `BETWEEN` as one value: +--! `{"v": {{eql_version}}, "i": {...}, "lo": , "hi": }`. Each bound is the +--! value the family's single-bound query domain for the same ordering +--! term holds under its term key (`ob` or `op`). Both bounds are +--! included. There is one range domain per ordering term the family +--! stores (ADR-0009, decisions 7 and 8). +--! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this +--! file out of every other version's installer. +--! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, +--! and holds no ciphertext `c`. The client refuses a range whose low +--! bound is above its high bound; one that reaches the database anyway +--! matches no row. + +DO $$ +BEGIN + --! @brief Range query-operand domain {{prefix}}.query_smallint_range_ore (two block ORE bounds; no `c`). + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'query_smallint_range_ore' AND typnamespace = '{{prefix}}'::regnamespace + ) THEN + CREATE DOMAIN {{prefix}}.query_smallint_range_ore AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' + AND VALUE ? 'i' + AND VALUE ? 'lo' + AND VALUE ? 'hi' + AND NOT (VALUE ? 'c') + AND jsonb_typeof(VALUE -> 'lo') = 'array' + AND jsonb_array_length(VALUE -> 'lo') > 0 + AND jsonb_typeof(VALUE -> 'hi') = 'array' + AND jsonb_array_length(VALUE -> 'hi') > 0 + AND VALUE->>'v' = '{{eql_version}}' + ); + END IF; + + COMMENT ON DOMAIN {{prefix}}.query_smallint_range_ore IS 'EQL smallint range query operand (block ORE)'; + + --! @brief Range query-operand domain {{prefix}}.query_smallint_range_ope (two CLLW OPE bounds; no `c`). + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'query_smallint_range_ope' AND typnamespace = '{{prefix}}'::regnamespace + ) THEN + CREATE DOMAIN {{prefix}}.query_smallint_range_ope AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' + AND VALUE ? 'i' + AND VALUE ? 'lo' + AND VALUE ? 'hi' + AND NOT (VALUE ? 'c') + AND jsonb_typeof(VALUE -> 'lo') = 'string' + AND jsonb_typeof(VALUE -> 'hi') = 'string' + AND VALUE->>'v' = '{{eql_version}}' + ); + END IF; + + COMMENT ON DOMAIN {{prefix}}.query_smallint_range_ope IS 'EQL smallint range query operand (CLLW OPE)'; +END +$$; diff --git a/packages/eql/src/v3/scalars/text/query_text_range_functions.sql b/packages/eql/src/v3/scalars/text/query_text_range_functions.sql new file mode 100644 index 000000000..fe50bc6e9 --- /dev/null +++ b/packages/eql/src/v3/scalars/text/query_text_range_functions.sql @@ -0,0 +1,207 @@ +-- AUTOMATICALLY GENERATED FILE. +-- ONLY EQL VERSION: 4 +-- REQUIRE: src/v3/schema.sql +-- REQUIRE: src/v3/scalars/text/query_text_range_types.sql +-- REQUIRE: src/v3/scalars/text/text_ord_ore_functions.sql +-- REQUIRE: src/v3/scalars/text/text_search_ore_functions.sql +-- REQUIRE: src/v3/scalars/text/text_ord_functions.sql +-- REQUIRE: src/v3/scalars/text/text_ord_ope_functions.sql +-- REQUIRE: src/v3/scalars/text/text_search_functions.sql +-- REQUIRE: src/v3/sem/ore_block_256/functions.sql +-- REQUIRE: src/v3/sem/ore_block_256/operators.sql +-- REQUIRE: src/v3/sem/ope_cllw/functions.sql + +--! @file v3/scalars/text/query_text_range_functions.sql +--! @brief The bound accessors and `{{prefix}}.between` for text's range query domains. +--! @note EQL version {{eql_version}} only, like the range domains themselves. + +--! @brief The low bound of a {{prefix}}.query_text_range_ore, as a block ORE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_text_range_ore Range query operand +--! @return {{prefix}}_internal.ore_block_256 The low bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.lo(r {{prefix}}.query_text_range_ore) +RETURNS {{prefix}}_internal.ore_block_256 +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ore_block_256(jsonb_build_object('ob', r::jsonb -> 'lo')) $$; + +--! @brief The high bound of a {{prefix}}.query_text_range_ore, as a block ORE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_text_range_ore Range query operand +--! @return {{prefix}}_internal.ore_block_256 The high bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.hi(r {{prefix}}.query_text_range_ore) +RETURNS {{prefix}}_internal.ore_block_256 +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ore_block_256(jsonb_build_object('ob', r::jsonb -> 'hi')) $$; + +--! @brief The low bound of a {{prefix}}.query_text_range_ope, as a CLLW OPE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_text_range_ope Range query operand +--! @return {{prefix}}_internal.ope_cllw The low bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.lo(r {{prefix}}.query_text_range_ope) +RETURNS {{prefix}}_internal.ope_cllw +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> 'lo')) $$; + +--! @brief The high bound of a {{prefix}}.query_text_range_ope, as a CLLW OPE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_text_range_ope Range query operand +--! @return {{prefix}}_internal.ope_cllw The high bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.hi(r {{prefix}}.query_text_range_ope) +RETURNS {{prefix}}_internal.ope_cllw +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> 'hi')) $$; + +--! @brief Whether a public.{{prefix}}_text_ord_ore value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term_ore(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_text_range_ore)`. +--! +--! @param a public.{{prefix}}_text_ord_ore Encrypted column value +--! @param r {{prefix}}.query_text_range_ore Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term_ore(a)`, which a btree index on that expression +--! serves as one range scan. For this ORE range that index needs the +--! default btree operator class on {{prefix}}_internal.ore_block_256, which the +--! installer creates only with superuser privileges. Without it (on Supabase, +--! for instance) the installer disables the ORE column domains, so use an OPE +--! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_text_ord_ore, r {{prefix}}.query_text_range_ore) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term_ore(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term_ore(a) <= {{prefix}}_internal.hi(r) $$; + +--! @brief Whether a public.{{prefix}}_text_search_ore value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term_ore(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_text_range_ore)`. +--! +--! @param a public.{{prefix}}_text_search_ore Encrypted column value +--! @param r {{prefix}}.query_text_range_ore Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term_ore(a)`, which a btree index on that expression +--! serves as one range scan. For this ORE range that index needs the +--! default btree operator class on {{prefix}}_internal.ore_block_256, which the +--! installer creates only with superuser privileges. Without it (on Supabase, +--! for instance) the installer disables the ORE column domains, so use an OPE +--! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_text_search_ore, r {{prefix}}.query_text_range_ore) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term_ore(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term_ore(a) <= {{prefix}}_internal.hi(r) $$; + +--! @brief Whether a public.{{prefix}}_text_ord value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_text_range_ope)`. +--! +--! @param a public.{{prefix}}_text_ord Encrypted column value +--! @param r {{prefix}}.query_text_range_ope Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term(a)`, which a btree index on that expression +--! serves as one range scan. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_text_ord, r {{prefix}}.query_text_range_ope) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r) $$; + +--! @brief Whether a public.{{prefix}}_text_ord_ope value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_text_range_ope)`. +--! +--! @param a public.{{prefix}}_text_ord_ope Encrypted column value +--! @param r {{prefix}}.query_text_range_ope Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term(a)`, which a btree index on that expression +--! serves as one range scan. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_text_ord_ope, r {{prefix}}.query_text_range_ope) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r) $$; + +--! @brief Whether a public.{{prefix}}_text_search value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_text_range_ope)`. +--! +--! @param a public.{{prefix}}_text_search Encrypted column value +--! @param r {{prefix}}.query_text_range_ope Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term(a)`, which a btree index on that expression +--! serves as one range scan. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_text_search, r {{prefix}}.query_text_range_ope) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r) $$; diff --git a/packages/eql/src/v3/scalars/text/query_text_range_types.sql b/packages/eql/src/v3/scalars/text/query_text_range_types.sql new file mode 100644 index 000000000..43e6fe918 --- /dev/null +++ b/packages/eql/src/v3/scalars/text/query_text_range_types.sql @@ -0,0 +1,66 @@ +-- AUTOMATICALLY GENERATED FILE. +-- ONLY EQL VERSION: 4 +-- REQUIRE: src/v3/schema.sql + +--! @file v3/scalars/text/query_text_range_types.sql +--! @brief Range query-operand domains for text: the operand of `{{prefix}}.between`. +--! @note A range holds both bounds of one `BETWEEN` as one value: +--! `{"v": {{eql_version}}, "i": {...}, "lo": , "hi": }`. Each bound is the +--! value the family's single-bound query domain for the same ordering +--! term holds under its term key (`ob` or `op`). Both bounds are +--! included. There is one range domain per ordering term the family +--! stores (ADR-0009, decisions 7 and 8). +--! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this +--! file out of every other version's installer. +--! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, +--! and holds no ciphertext `c`. The client refuses a range whose low +--! bound is above its high bound; one that reaches the database anyway +--! matches no row. + +DO $$ +BEGIN + --! @brief Range query-operand domain {{prefix}}.query_text_range_ore (two block ORE bounds; no `c`). + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'query_text_range_ore' AND typnamespace = '{{prefix}}'::regnamespace + ) THEN + CREATE DOMAIN {{prefix}}.query_text_range_ore AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' + AND VALUE ? 'i' + AND VALUE ? 'lo' + AND VALUE ? 'hi' + AND NOT (VALUE ? 'c') + AND jsonb_typeof(VALUE -> 'lo') = 'array' + AND jsonb_array_length(VALUE -> 'lo') > 0 + AND jsonb_typeof(VALUE -> 'hi') = 'array' + AND jsonb_array_length(VALUE -> 'hi') > 0 + AND VALUE->>'v' = '{{eql_version}}' + ); + END IF; + + COMMENT ON DOMAIN {{prefix}}.query_text_range_ore IS 'EQL text range query operand (block ORE)'; + + --! @brief Range query-operand domain {{prefix}}.query_text_range_ope (two CLLW OPE bounds; no `c`). + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'query_text_range_ope' AND typnamespace = '{{prefix}}'::regnamespace + ) THEN + CREATE DOMAIN {{prefix}}.query_text_range_ope AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' + AND VALUE ? 'i' + AND VALUE ? 'lo' + AND VALUE ? 'hi' + AND NOT (VALUE ? 'c') + AND jsonb_typeof(VALUE -> 'lo') = 'string' + AND jsonb_typeof(VALUE -> 'hi') = 'string' + AND VALUE->>'v' = '{{eql_version}}' + ); + END IF; + + COMMENT ON DOMAIN {{prefix}}.query_text_range_ope IS 'EQL text range query operand (CLLW OPE)'; +END +$$; diff --git a/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_functions.sql b/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_functions.sql new file mode 100644 index 000000000..f06c114e1 --- /dev/null +++ b/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_functions.sql @@ -0,0 +1,155 @@ +-- AUTOMATICALLY GENERATED FILE. +-- ONLY EQL VERSION: 4 +-- REQUIRE: src/v3/schema.sql +-- REQUIRE: src/v3/scalars/timestamp/query_timestamp_range_types.sql +-- REQUIRE: src/v3/scalars/timestamp/timestamp_ord_ore_functions.sql +-- REQUIRE: src/v3/scalars/timestamp/timestamp_ord_functions.sql +-- REQUIRE: src/v3/scalars/timestamp/timestamp_ord_ope_functions.sql +-- REQUIRE: src/v3/sem/ore_block_256/functions.sql +-- REQUIRE: src/v3/sem/ore_block_256/operators.sql +-- REQUIRE: src/v3/sem/ope_cllw/functions.sql + +--! @file v3/scalars/timestamp/query_timestamp_range_functions.sql +--! @brief The bound accessors and `{{prefix}}.between` for timestamp's range query domains. +--! @note EQL version {{eql_version}} only, like the range domains themselves. + +--! @brief The low bound of a {{prefix}}.query_timestamp_range_ore, as a block ORE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_timestamp_range_ore Range query operand +--! @return {{prefix}}_internal.ore_block_256 The low bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.lo(r {{prefix}}.query_timestamp_range_ore) +RETURNS {{prefix}}_internal.ore_block_256 +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ore_block_256(jsonb_build_object('ob', r::jsonb -> 'lo')) $$; + +--! @brief The high bound of a {{prefix}}.query_timestamp_range_ore, as a block ORE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_timestamp_range_ore Range query operand +--! @return {{prefix}}_internal.ore_block_256 The high bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.hi(r {{prefix}}.query_timestamp_range_ore) +RETURNS {{prefix}}_internal.ore_block_256 +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ore_block_256(jsonb_build_object('ob', r::jsonb -> 'hi')) $$; + +--! @brief The low bound of a {{prefix}}.query_timestamp_range_ope, as a CLLW OPE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_timestamp_range_ope Range query operand +--! @return {{prefix}}_internal.ope_cllw The low bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.lo(r {{prefix}}.query_timestamp_range_ope) +RETURNS {{prefix}}_internal.ope_cllw +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> 'lo')) $$; + +--! @brief The high bound of a {{prefix}}.query_timestamp_range_ope, as a CLLW OPE term. +--! +--! Reads the bound with the term's own constructor, exactly as a single-bound +--! query operand's term is read, so a bound compares like that term would. +--! Internal: only `{{prefix}}.between`'s body calls it, so it is not part of +--! the public `{{prefix}}` surface. +--! +--! @param r {{prefix}}.query_timestamp_range_ope Range query operand +--! @return {{prefix}}_internal.ope_cllw The high bound's term (included in the range), or NULL for a NULL range +--! @note IMMUTABLE and STRICT. It is evaluated once per query when +--! `{{prefix}}.between` is served by an index, since its argument is the +--! query's parameter, not a column. +CREATE FUNCTION {{prefix}}_internal.hi(r {{prefix}}.query_timestamp_range_ope) +RETURNS {{prefix}}_internal.ope_cllw +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> 'hi')) $$; + +--! @brief Whether a public.{{prefix}}_timestamp_ord_ore value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term_ore(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_timestamp_range_ore)`. +--! +--! @param a public.{{prefix}}_timestamp_ord_ore Encrypted column value +--! @param r {{prefix}}.query_timestamp_range_ore Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term_ore(a)`, which a btree index on that expression +--! serves as one range scan. For this ORE range that index needs the +--! default btree operator class on {{prefix}}_internal.ore_block_256, which the +--! installer creates only with superuser privileges. Without it (on Supabase, +--! for instance) the installer disables the ORE column domains, so use an OPE +--! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_timestamp_ord_ore, r {{prefix}}.query_timestamp_range_ore) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term_ore(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term_ore(a) <= {{prefix}}_internal.hi(r) $$; + +--! @brief Whether a public.{{prefix}}_timestamp_ord value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_timestamp_range_ope)`. +--! +--! @param a public.{{prefix}}_timestamp_ord Encrypted column value +--! @param r {{prefix}}.query_timestamp_range_ope Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term(a)`, which a btree index on that expression +--! serves as one range scan. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_timestamp_ord, r {{prefix}}.query_timestamp_range_ope) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r) $$; + +--! @brief Whether a public.{{prefix}}_timestamp_ord_ope value lies in a range, both bounds included. +--! +--! True when `{{prefix}}_internal.lo(r) <= {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r)`. +--! The range is one operand, so a statement cannot pair the low bound of one +--! query with the high bound of another (ADR-0009, decision 7): +--! `WHERE {{prefix}}.between(col, $1::{{prefix}}.query_timestamp_range_ope)`. +--! +--! @param a public.{{prefix}}_timestamp_ord_ope Encrypted column value +--! @param r {{prefix}}.query_timestamp_range_ope Range query operand: both bounds of one `BETWEEN` +--! @return boolean True when the value is in the range; NULL when either argument is NULL +--! @note Inlinable on purpose: `LANGUAGE sql`, one expression, IMMUTABLE, no +--! `SET` clause, and not STRICT. PostgreSQL inlines a STRICT SQL function +--! only when its body is strict, and `x AND y` is not, so STRICT would +--! stop inlining. Inlined, the planner sees two comparisons on +--! `{{prefix}}.ord_term(a)`, which a btree index on that expression +--! serves as one range scan. +--! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: +--! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_timestamp_ord_ope, r {{prefix}}.query_timestamp_range_ope) +RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}.ord_term(a) <= {{prefix}}_internal.hi(r) $$; diff --git a/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_types.sql b/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_types.sql new file mode 100644 index 000000000..c872c0be7 --- /dev/null +++ b/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_types.sql @@ -0,0 +1,66 @@ +-- AUTOMATICALLY GENERATED FILE. +-- ONLY EQL VERSION: 4 +-- REQUIRE: src/v3/schema.sql + +--! @file v3/scalars/timestamp/query_timestamp_range_types.sql +--! @brief Range query-operand domains for timestamp: the operand of `{{prefix}}.between`. +--! @note A range holds both bounds of one `BETWEEN` as one value: +--! `{"v": {{eql_version}}, "i": {...}, "lo": , "hi": }`. Each bound is the +--! value the family's single-bound query domain for the same ordering +--! term holds under its term key (`ob` or `op`). Both bounds are +--! included. There is one range domain per ordering term the family +--! stores (ADR-0009, decisions 7 and 8). +--! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this +--! file out of every other version's installer. +--! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, +--! and holds no ciphertext `c`. The client refuses a range whose low +--! bound is above its high bound; one that reaches the database anyway +--! matches no row. + +DO $$ +BEGIN + --! @brief Range query-operand domain {{prefix}}.query_timestamp_range_ore (two block ORE bounds; no `c`). + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'query_timestamp_range_ore' AND typnamespace = '{{prefix}}'::regnamespace + ) THEN + CREATE DOMAIN {{prefix}}.query_timestamp_range_ore AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' + AND VALUE ? 'i' + AND VALUE ? 'lo' + AND VALUE ? 'hi' + AND NOT (VALUE ? 'c') + AND jsonb_typeof(VALUE -> 'lo') = 'array' + AND jsonb_array_length(VALUE -> 'lo') > 0 + AND jsonb_typeof(VALUE -> 'hi') = 'array' + AND jsonb_array_length(VALUE -> 'hi') > 0 + AND VALUE->>'v' = '{{eql_version}}' + ); + END IF; + + COMMENT ON DOMAIN {{prefix}}.query_timestamp_range_ore IS 'EQL timestamp range query operand (block ORE)'; + + --! @brief Range query-operand domain {{prefix}}.query_timestamp_range_ope (two CLLW OPE bounds; no `c`). + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'query_timestamp_range_ope' AND typnamespace = '{{prefix}}'::regnamespace + ) THEN + CREATE DOMAIN {{prefix}}.query_timestamp_range_ope AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' + AND VALUE ? 'i' + AND VALUE ? 'lo' + AND VALUE ? 'hi' + AND NOT (VALUE ? 'c') + AND jsonb_typeof(VALUE -> 'lo') = 'string' + AND jsonb_typeof(VALUE -> 'hi') = 'string' + AND VALUE->>'v' = '{{eql_version}}' + ); + END IF; + + COMMENT ON DOMAIN {{prefix}}.query_timestamp_range_ope IS 'EQL timestamp range query operand (CLLW OPE)'; +END +$$; diff --git a/packages/eql/tasks/build.sh b/packages/eql/tasks/build.sh index df286e9c6..de6edb8a8 100755 --- a/packages/eql/tasks/build.sh +++ b/packages/eql/tasks/build.sh @@ -89,23 +89,47 @@ mv src/deps-ordered-v3.txt.tmp src/deps-ordered-v3.txt # the gates and assemble that version's installer from the rendered tree. `eql-codegen render` fails # on an unknown placeholder and on a literal eql_v3/eql_v4 in the source, and # writes nothing unless every file renders. +# +# Not every file belongs to every version. A file with a `-- ONLY EQL VERSION: N` +# line belongs to version N alone: today the generated range query domains and +# `between` (query__range_*.sql, ADR-0009 decision 7) are EQL v4 only. +# `eql-codegen select` drops the files that belong to the other version from the +# install order, keeping the order of the rest, and `render` refuses to render a +# file as a version it does not belong to, so nothing confined to v4 can reach +# the v3 installer even if this filter is bypassed. assemble() { local version=$1 installer=$2 uninstaller=$3 local root="build/render/eql_v${version}" local ordered="$root/deps-ordered.txt" + local selected - rm -rf "$root" # shellcheck disable=SC2046 # one path per line, none with spaces + selected=$(cargo run -q -p eql-codegen -- select "$version" $(cat src/deps-ordered-v3.txt)) + rm -rf "$root" + # shellcheck disable=SC2086 # one path per line, none with spaces cargo run -q -p eql-codegen -- render "$version" "$root" \ - $(cat src/deps-ordered-v3.txt) tasks/pin_search_path_v3.sql tasks/uninstall-v3.sql - sed "s|^|$root/|" src/deps-ordered-v3.txt > "$ordered" - - # The symbol-order checker parses eql_v3 names. One run covers both - # versions: eql_v4 is the same files in the same order, every name renamed - # alike, so define-before-use holds for one exactly when it holds for the - # other. + $selected tasks/pin_search_path_v3.sql tasks/uninstall-v3.sql + printf '%s\n' "$selected" | sed "s|^|$root/|" > "$ordered" + + # The symbol-order checker parses eql_v3 names. For v3 it reads the rendered + # tree. For v4 it reads a copy of the rendered v4 tree with the names mapped + # back to eql_v3: the renderer guarantees the source names no schema + # literally, so the mapping is exact. Version 4 needs its own run because its + # order holds files version 3's does not (the range surface), and those must + # define before use too. if [[ "$version" == 3 ]]; then bash tasks/test/verify_symbol_order_v3.sh "$ordered" + else + # Under build/ (gitignored), so a failed check leaves nothing elsewhere. + local mapped="build/render/symbols-v${version}" + rm -rf "$mapped" + while IFS= read -r f; do + mkdir -p "$mapped/$(dirname "$f")" + sed 's/eql_v4/eql_v3/g' "$f" > "$mapped/$f" + done < "$ordered" + sed "s|^|$mapped/|" "$ordered" > "$mapped/deps-ordered.txt" + bash tasks/test/verify_symbol_order_v3.sh "$mapped/deps-ordered.txt" + rm -rf "$mapped" fi : > "$installer" diff --git a/packages/eql/tasks/docs/doxygen-filter.sh b/packages/eql/tasks/docs/doxygen-filter.sh index c0323a656..7ccfdf735 100755 --- a/packages/eql/tasks/docs/doxygen-filter.sh +++ b/packages/eql/tasks/docs/doxygen-filter.sh @@ -15,7 +15,8 @@ fi # 0. Read the RENDERED source. The SQL source names its schemas by # placeholder ({{prefix}}, {{eql_version}}; ADR-0002), and `mise run build` # renders it as EQL v3 into build/render/eql_v3/ with `eql-codegen render`, -# keeping relative paths. A file under src/ or tasks/ is read from there, +# keeping relative paths. A file under src/ or tasks/ is read from there +# (or, for a file confined to one version, from that version's tree), # so the reference names `eql_v3.eq_term`, and the renderer stays the one # place placeholders are substituted. `docs:generate` and this filter's # other callers depend on `build`; a missing rendered copy is an error, not @@ -60,7 +61,12 @@ rel="${abs#"$EQL_ROOT/"}" input="$1" case "$rel" in src/* | tasks/*) - input="$EQL_ROOT/build/render/eql_v3/$rel" + # A file confined to one EQL version (`-- ONLY EQL VERSION: N`, e.g. the + # v4-only range surface) is rendered for that version alone, so read that + # version's copy; it documents names that exist (eql_v4.between), not v3 + # names that do not. + only=$(sed -n 's/^-- ONLY EQL VERSION: \([0-9][0-9]*\)[[:space:]]*$/\1/p' "$abs" | head -n 1) + input="$EQL_ROOT/build/render/eql_v${only:-3}/$rel" if [[ ! -f "$input" ]]; then echo "$1: no rendered copy at $input; run 'mise run build' first" >&2 exit 1 diff --git a/packages/eql/tasks/test/verify_installer_complete.sh b/packages/eql/tasks/test/verify_installer_complete.sh index 2830aab1c..65cf6cfa6 100755 --- a/packages/eql/tasks/test/verify_installer_complete.sh +++ b/packages/eql/tasks/test/verify_installer_complete.sh @@ -32,6 +32,15 @@ INSTALLER="${2:-release/cipherstash-encrypt.sql}" # so overriding only $1 cannot pair one tree's order with another's files. ROOT="${3:-$(dirname "$ORDERED")}" PIN="$ROOT/tasks/pin_search_path_v3.sql" +# The EQL version the installer is for: the N of the rendered tree's +# build/render/eql_v name. A file confined to another version by a +# `-- ONLY EQL VERSION: M` line is not part of this installer. +if [[ "$(basename "$ROOT")" =~ ^eql_v([0-9]+)$ ]]; then + VERSION="${BASH_REMATCH[1]}" +else + echo "ERROR: cannot tell the EQL version from the rendered tree $ROOT (expected .../eql_v)" >&2 + exit 2 +fi for f in "$ORDERED" "$INSTALLER" "$PIN"; do if [[ ! -f "$f" ]]; then @@ -82,7 +91,18 @@ trap 'rm -rf "$work"' EXIT # exactly the files the order names, so the rendered tree always agrees with the # order and a comparison against it could never fail. Map the source paths into # the rendered namespace the order uses. -find src/v3 -type f -name '*.sql' ! -name '*_test.sql' | sed "s|^|$ROOT/|" | LC_ALL=C sort > "$work/disk" +# +# A file confined to another version (`-- ONLY EQL VERSION: M`, M != $VERSION) +# belongs to that version's installer, not this one. The test is a grep here, +# not `eql-codegen select`, which build.sh filtered the order with: the two must +# agree independently for this gate to mean anything. +find src/v3 -type f -name '*.sql' ! -name '*_test.sql' | LC_ALL=C sort > "$work/all" +while IFS= read -r f; do + only=$(sed -n 's/^-- ONLY EQL VERSION: \([0-9][0-9]*\)[[:space:]]*$/\1/p' "$f") + if [[ -z "$only" || "$only" == "$VERSION" ]]; then + printf '%s/%s\n' "$ROOT" "$f" + fi +done < "$work/all" > "$work/disk" grep -v '^[[:space:]]*$' "$ORDERED" | LC_ALL=C sort > "$work/order" || true n_disk=$(count_lines "$work/disk") diff --git a/packages/eql/tasks/test/verify_no_v3_in_v4.sh b/packages/eql/tasks/test/verify_no_v3_in_v4.sh index 90b545fea..a9f255218 100755 --- a/packages/eql/tasks/test/verify_no_v3_in_v4.sh +++ b/packages/eql/tasks/test/verify_no_v3_in_v4.sh @@ -3,7 +3,8 @@ # # ADR-0002 installs one SQL source under two names, so a column's domain says # which engine produced its terms. That holds only while the v4 bundle is v3's -# SQL with EVERY name and version changed. A stray eql_v3 reference installs +# SQL with EVERY name and version changed, plus the files confined to v4 by +# `-- ONLY EQL VERSION: 4`, which name only v4. A stray eql_v3 reference installs # cleanly and then quietly compares v4 data with v3 terms, or casts a v4 payload # to a v3 domain. # diff --git a/packages/eql/tests/encryption/tests/v4_range.rs b/packages/eql/tests/encryption/tests/v4_range.rs new file mode 100644 index 000000000..58b206408 --- /dev/null +++ b/packages/eql/tests/encryption/tests/v4_range.rs @@ -0,0 +1,658 @@ +//! The EQL v4 range query domains and `eql_v4.between` (ADR-0009, decision 7). +//! +//! A `Between` query is one term holding both bounds, and in SQL it is one +//! operand: `WHERE eql_v4.between(col, $1::eql_v4.query_integer_range_ore)`. +//! A range is `{"v": 4, "i": {…}, "lo": , "hi": }`, each bound the +//! value the single-bound query domain for the same term holds under its term +//! key (`ob` for block ORE, `op` for CLLW OPE). +//! +//! EQL v4's ordered types are not producible yet: EQL compares block-ORE and +//! CLLW-OPE terms, and Stack Encrypt does not derive those for EQL (#1132, +//! #1236). So the order terms here come from cipherstash-client's ORE and OPE +//! indexers, the code that writes the terms EQL's comparators are built for, +//! run offline over `FakeDataKeySource`'s index key as +//! `ste_vec_selectors.rs` does. The rest of each stored value (`v`, `i`, `c`, +//! and `hm` for text) is a real Stack Encrypt `Integer` or `TextEq`. Nothing +//! here derives an order term itself. +//! +//! The PostgreSQL tests need an EQL v4 installation (`mise run +//! test:encryption:postgres`); the others need no database. + +mod common; + +use cipherstash_client::encryption::{IndexTerm, OpeIndexer, OreIndexer, Plaintext}; +use cipherstash_client::zerokms; +use eql_bindings::v4::{self, integer, text, DomainType}; +use eql_bindings::Identifier; +use serde_json::{json, Value}; +use stack_encrypt::NonEmpty; +use stack_kms::{FakeDataKeySource, IndexKeySource}; + +fn column() -> NonEmpty { + Identifier::for_column("users", "age").unwrap() +} + +fn identifier() -> Value { + json!({"t": "users", "c": "age"}) +} + +/// `FakeDataKeySource`'s index key for the default keyset, as the key type +/// cipherstash-client's indexers take. +async fn index_key() -> zerokms::IndexKey { + let (_, key) = FakeDataKeySource::new().load_index_key(None).await.unwrap(); + zerokms::IndexKey::from(*key.key()) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// The `ob` value cipherstash-client writes for `plaintext`: one hex block +/// for a number, one per block for text. `query` asks for the query form. +fn ob(key: &zerokms::IndexKey, plaintext: Plaintext, query: bool) -> Value { + let term = if query { + OreIndexer.encrypt_for_query(&plaintext, key) + } else { + OreIndexer.encrypt(&plaintext, key) + }; + match term.unwrap() { + IndexTerm::OreFull(bytes) => json!([hex(&bytes)]), + IndexTerm::OreArray(blocks) => { + Value::Array(blocks.iter().map(|b| Value::String(hex(b))).collect()) + } + other => panic!("not an ORE term: {other:?}"), + } +} + +/// The `op` value cipherstash-client writes for `plaintext`. +fn op(key: &zerokms::IndexKey, plaintext: Plaintext) -> Value { + match OpeIndexer.encrypt(&plaintext, key).unwrap() { + IndexTerm::OpeFixed(bytes) | IndexTerm::OpeVariable(bytes) => Value::String(hex(&bytes)), + other => panic!("not an OPE term: {other:?}"), + } +} + +/// A range over the bounds `lo` and `hi`, read through the binding `T` (so +/// the binding accepts it) and written back as the JSON text a driver binds. +fn range( + lo: Value, + hi: Value, +) -> String { + let value: T = + serde_json::from_value(json!({"v": 4, "i": identifier(), "lo": lo, "hi": hi})).unwrap(); + serde_json::to_string(&value).unwrap() +} + +#[tokio::test] +async fn range_bindings_hold_two_bounds_of_their_term_and_refuse_anything_else() { + let key = index_key().await; + let (lo, hi) = (Plaintext::Int(Some(10)), Plaintext::Int(Some(30))); + + // Each bound is what the single-bound query operand holds as `ob`: the + // same value reads as either. (A block-ORE term carries a nonce, so two + // derivations of one value differ; one derivation is used for both.) + let lo_ob = ob(&key, lo.clone(), true); + let ore: integer::IntegerRangeOreQuery = serde_json::from_value(json!({ + "v": 4, "i": identifier(), "lo": lo_ob, "hi": ob(&key, hi.clone(), true), + })) + .unwrap(); + let single: integer::IntegerOrdOreQuery = + serde_json::from_value(json!({"v": 4, "i": identifier(), "ob": lo_ob})).unwrap(); + assert_eq!(ore.lo, single.ob); + assert_ne!(ore.lo, ore.hi); + let json = serde_json::to_value(&ore).unwrap(); + let mut keys: Vec<&str> = json + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + keys.sort_unstable(); + assert_eq!(keys, ["hi", "i", "lo", "v"]); + assert_eq!( + integer::IntegerRangeOreQuery::sql_domain_static(), + "eql_v4.query_integer_range_ore" + ); + assert_eq!( + integer::IntegerRangeOreQuery::term_json_keys_static(), + Some(&["lo", "hi"][..]) + ); + + let ope: integer::IntegerRangeOpeQuery = serde_json::from_value(json!({ + "v": 4, "i": identifier(), + "lo": op(&key, lo.clone()), "hi": op(&key, hi.clone()), + })) + .unwrap(); + let single: integer::IntegerOrdOpeQuery = serde_json::from_value(json!({ + "v": 4, "i": identifier(), "op": op(&key, lo.clone()), + })) + .unwrap(); + assert_eq!(ope.lo, single.op); + + let good = json!({"v": 4, "i": identifier(), "lo": op(&key, lo.clone()), "hi": op(&key, hi)}); + let refused = |change: &dyn Fn(&mut Value)| { + let mut value = good.clone(); + change(&mut value); + serde_json::from_value::(value).is_err() + }; + assert!( + refused(&|v| v["c"] = json!("ciphertext")), + "a range holds no ciphertext" + ); + assert!(refused(&|v| v["v"] = json!(3)), "a range is EQL v4 only"); + assert!(refused(&|v| { + v.as_object_mut().unwrap().remove("hi"); + })); + assert!( + refused(&|v| v["op"] = v["lo"].clone()), + "a range is not a single-bound operand" + ); + // An ORE bound is an `ob` array, not an `op` string. + assert!(serde_json::from_value::(good).is_err()); +} + +#[test] +fn every_ordered_family_has_an_ore_and_an_ope_range() { + let domains: Vec<&str> = v4::all_range_query() + .iter() + .map(|d| d.sql_domain()) + .collect(); + let families = [ + "integer", + "smallint", + "bigint", + "date", + "timestamp", + "numeric", + "text", + "real", + "double", + ]; + let expected: Vec = families + .iter() + .flat_map(|f| { + [ + format!("eql_v4.query_{f}_range_ore"), + format!("eql_v4.query_{f}_range_ope"), + ] + }) + .collect(); + assert_eq!(domains, expected); + for d in v4::all_range_query() { + assert_eq!( + d.term_json_keys(), + Some(&["lo", "hi"][..]), + "{}", + d.sql_domain() + ); + } +} + +/// Run `EXPLAIN (FORMAT JSON) query` and return the plan as JSON. The simple +/// query protocol hands back text, which spares a JSON column type. +fn explain(db: &mut postgres::Client, query: &str) -> Value { + let messages = db + .simple_query(&format!("EXPLAIN (FORMAT JSON) {query}")) + .unwrap(); + let text = messages + .iter() + .find_map(|m| match m { + postgres::SimpleQueryMessage::Row(row) => row.get(0).map(str::to_owned), + _ => None, + }) + .expect("EXPLAIN returns one row"); + serde_json::from_str(&text).unwrap() +} + +/// The index-scan nodes of a plan, as `(Index Name, Index Cond)`. +fn index_scans(plan: &Value, found: &mut Vec<(String, String)>) { + match plan { + Value::Object(node) => { + if matches!( + node.get("Node Type").and_then(Value::as_str), + Some("Index Scan" | "Index Only Scan" | "Bitmap Index Scan") + ) { + found.push(( + node["Index Name"].as_str().unwrap_or_default().to_owned(), + node.get("Index Cond") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + )); + } + node.values().for_each(|v| index_scans(v, found)); + } + Value::Array(items) => items.iter().for_each(|v| index_scans(v, found)), + _ => {} + } +} + +fn runtime() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() +} + +fn connect() -> postgres::Client { + postgres::Client::connect( + &std::env::var("EQL_TEST_DATABASE_URL").expect("EQL_TEST_DATABASE_URL"), + postgres::NoTls, + ) + .unwrap() +} + +fn sqlstate(error: &postgres::Error) -> String { + error + .code() + .map(|c| c.code().to_owned()) + .unwrap_or_default() +} + +const INTEGERS: [i32; 12] = [-1000, -5, -1, 0, 1, 7, 10, 20, 29, 30, 31, 1000]; + +/// Integer rows in all three ordered column domains, each value's real +/// Stack Encrypt `Integer` (`v`, `i`, `c`) with cipherstash-client's order +/// term added: `ob` for `_ord_ore`, `op` for `_ord` and `_ord_ope`. +fn integer_table( + runtime: &tokio::runtime::Runtime, + db: &mut postgres::Client, + key: &zerokms::IndexKey, +) { + let (cipher, _) = runtime.block_on(common::cipher()); + let keyset = cipher.default_keyset(); + db.batch_execute( + "CREATE TEMP TABLE v4_int_range ( + n int PRIMARY KEY, + ore public.eql_v4_integer_ord_ore, + ord public.eql_v4_integer_ord, + ope public.eql_v4_integer_ord_ope + );", + ) + .unwrap(); + for n in INTEGERS { + let stored: integer::Integer = runtime + .block_on(async { keyset.encrypt_as(&n, column()).await }) + .unwrap(); + let base = serde_json::to_value(&stored).unwrap(); + let with = |k: &str, term: Value| { + let mut v = base.clone(); + v[k] = term; + v.to_string() + }; + db.execute( + "INSERT INTO v4_int_range VALUES ($1, + $2::text::jsonb::public.eql_v4_integer_ord_ore, + $3::text::jsonb::public.eql_v4_integer_ord, + $4::text::jsonb::public.eql_v4_integer_ord_ope)", + &[ + &n, + &with("ob", ob(key, Plaintext::Int(Some(n)), false)), + &with("op", op(key, Plaintext::Int(Some(n)))), + &with("op", op(key, Plaintext::Int(Some(n)))), + ], + ) + .unwrap(); + } +} + +/// Needs an EQL v4 installation. Rows inside, outside and exactly at both +/// bounds, for ORE and OPE, over integers. +#[test] +#[ignore = "requires EQL_TEST_DATABASE_URL pointing to an EQL v4 database"] +fn postgres_between_finds_integers_in_an_inclusive_range() { + let runtime = runtime(); + let key = runtime.block_on(index_key()); + { + let mut db = connect(); + integer_table(&runtime, &mut db, &key); + + let ore = |lo: i32, hi: i32| { + range::( + ob(&key, Plaintext::Int(Some(lo)), true), + ob(&key, Plaintext::Int(Some(hi)), true), + ) + }; + let ope = |lo: i32, hi: i32| { + range::( + op(&key, Plaintext::Int(Some(lo))), + op(&key, Plaintext::Int(Some(hi))), + ) + }; + let mut found = |column: &str, domain: &str, range: &str| -> Vec { + db.query( + &format!( + "SELECT n FROM v4_int_range \ + WHERE eql_v4.between({column}, $1::text::jsonb::eql_v4.{domain}) ORDER BY n" + ), + &[&range], + ) + .unwrap() + .iter() + .map(|r| r.get(0)) + .collect() + }; + // (lo, hi) and the rows the plaintext range holds, bounds included. + let cases: [(i32, i32, &[i32]); 6] = [ + // Both bounds are rows: both are in. + (10, 30, &[10, 20, 29, 30]), + // Across zero, where a sign-blind order would break. + (-5, 1, &[-5, -1, 0, 1]), + // Neither bound is a row. + (8, 28, &[10, 20]), + // A range of one value. + (31, 31, &[31]), + // Between two rows: nothing. + (2, 6, &[]), + // The whole of i32. + (i32::MIN, i32::MAX, &INTEGERS), + ]; + for (lo, hi, want) in cases { + assert_eq!( + found("ore", "query_integer_range_ore", &ore(lo, hi)), + want, + "ORE [{lo}, {hi}]" + ); + for column in ["ord", "ope"] { + assert_eq!( + found(column, "query_integer_range_ope", &ope(lo, hi)), + want, + "OPE {column} [{lo}, {hi}]" + ); + } + } + + // A range whose low bound is above its high bound matches nothing: + // the client refuses one, and the SQL does not reorder it. + assert_eq!( + found("ore", "query_integer_range_ore", &ore(30, 10)), + Vec::::new() + ); + assert_eq!( + found("ope", "query_integer_range_ope", &ope(30, 10)), + Vec::::new() + ); + + // A NULL range is NULL, so no row, not an error. + let none: Vec = db + .query( + "SELECT n FROM v4_int_range WHERE eql_v4.between(ore, NULL::eql_v4.query_integer_range_ore)", + &[], + ) + .unwrap() + .iter() + .map(|r| r.get(0)) + .collect(); + assert!(none.is_empty()); + + // A range over the other term has no `between` for the column, so the + // call fails at plan time. PostgreSQL reports it as ambiguous: with no + // exact match it treats both domains as `jsonb`, which every + // `between` overload would take. + let err = db + .query( + "SELECT n FROM v4_int_range WHERE eql_v4.between(ore, $1::text::jsonb::eql_v4.query_integer_range_ope)", + &[&ope(10, 30)], + ) + .unwrap_err(); + assert_eq!(sqlstate(&err), "42725", "{err}"); + } +} + +/// Needs an EQL v4 installation. The same over text, whose ordered domains +/// also carry `hm`. +#[test] +#[ignore = "requires EQL_TEST_DATABASE_URL pointing to an EQL v4 database"] +fn postgres_between_finds_text_in_an_inclusive_range() { + let runtime = runtime(); + let key = runtime.block_on(index_key()); + let (cipher, _) = runtime.block_on(common::cipher()); + let keyset = cipher.default_keyset(); + let words = [ + "apple", + "banana", + "blueberry", + "cherry", + "date", + "elderberry", + "fig", + ]; + let mut rows = Vec::new(); + for (id, word) in words.iter().enumerate() { + let stored: text::TextEq = runtime + .block_on(async { keyset.encrypt_as(&word.to_string(), column()).await }) + .unwrap(); + let base = serde_json::to_value(&stored).unwrap(); + let mut ore = base.clone(); + ore["ob"] = ob(&key, Plaintext::Text(Some(word.to_string())), false); + let mut ope = base; + ope["op"] = op(&key, Plaintext::Text(Some(word.to_string()))); + rows.push((id as i32, ore.to_string(), ope.to_string())); + } + { + let mut db = connect(); + db.batch_execute( + "CREATE TEMP TABLE v4_text_range ( + id int PRIMARY KEY, + ore public.eql_v4_text_ord_ore, + ope public.eql_v4_text_ord_ope + );", + ) + .unwrap(); + for (id, ore, ope) in &rows { + db.execute( + "INSERT INTO v4_text_range VALUES ($1, + $2::text::jsonb::public.eql_v4_text_ord_ore, + $3::text::jsonb::public.eql_v4_text_ord_ope)", + &[id, ore, ope], + ) + .unwrap(); + } + let text = |s: &str| Plaintext::Text(Some(s.to_owned())); + let mut found = |column: &str, domain: &str, range: &str| -> Vec { + db.query( + &format!( + "SELECT id FROM v4_text_range \ + WHERE eql_v4.between({column}, $1::text::jsonb::eql_v4.{domain}) ORDER BY id" + ), + &[&range], + ) + .unwrap() + .iter() + .map(|r| words[r.get::<_, i32>(0) as usize].to_owned()) + .collect() + }; + let cases: [(&str, &str, &[&str]); 4] = [ + // Both bounds are rows: both are in. + ("banana", "date", &["banana", "blueberry", "cherry", "date"]), + // Neither bound is a row. + ("b", "c", &["banana", "blueberry"]), + ("fig", "fig", &["fig"]), + ("dog", "eel", &[]), + ]; + for (lo, hi, want) in cases { + let ore = range::( + ob(&key, text(lo), true), + ob(&key, text(hi), true), + ); + assert_eq!( + found("ore", "query_text_range_ore", &ore), + want, + "ORE [{lo}, {hi}]" + ); + let ope = range::(op(&key, text(lo)), op(&key, text(hi))); + assert_eq!( + found("ope", "query_text_range_ope", &ope), + want, + "OPE [{lo}, {hi}]" + ); + } + } +} + +/// Needs an EQL v4 installation. `eql_v4.between` inlines, so a btree on the +/// column's order term serves it as one index scan with both bounds in the +/// index condition. A `between` the planner could not inline would be a +/// filter on every row, and no index scan would appear. +/// +/// Validity, not preference: like the SQLx suite's index-engagement tests, +/// this turns sequential scans off on a small table, so it proves the index +/// can serve the range, not that the planner prefers it at scale. +/// +/// Checked twice: with the range as a literal, and as a driver sends it, a +/// `$1` of the range domain under a generic plan. With a literal the planner +/// folds both bounds to constants, so only the generic plan shows that +/// `between` still inlines when its range is a parameter used twice. +#[test] +#[ignore = "requires EQL_TEST_DATABASE_URL pointing to an EQL v4 database"] +fn postgres_between_uses_the_order_term_index() { + let runtime = runtime(); + let key = runtime.block_on(index_key()); + { + let mut db = connect(); + integer_table(&runtime, &mut db, &key); + db.batch_execute( + "CREATE INDEX v4_int_range_ore ON v4_int_range USING btree (eql_v4.ord_term_ore(ore)); + CREATE INDEX v4_int_range_ope ON v4_int_range USING btree (eql_v4.ord_term(ope)); + ANALYZE v4_int_range; + SET enable_seqscan = off;", + ) + .unwrap(); + let ore = range::( + ob(&key, Plaintext::Int(Some(10)), true), + ob(&key, Plaintext::Int(Some(30)), true), + ); + let ope = range::( + op(&key, Plaintext::Int(Some(10))), + op(&key, Plaintext::Int(Some(30))), + ); + for (column, domain, index, range) in [ + ("ore", "query_integer_range_ore", "v4_int_range_ore", &ore), + ("ope", "query_integer_range_ope", "v4_int_range_ope", &ope), + ] { + let query = format!( + "SELECT n FROM v4_int_range WHERE eql_v4.between({column}, '{}'::jsonb::eql_v4.{domain})", + range.replace('\'', "''") + ); + let plan = explain(&mut db, &query); + let mut scans = Vec::new(); + index_scans(&plan, &mut scans); + let (_, cond) = scans + .iter() + .find(|(name, _)| name == index) + .unwrap_or_else(|| panic!("{column}: no index scan on {index}:\n{plan:#}")); + assert!( + cond.contains(">=") && cond.contains("<="), + "{column}: both bounds are index conditions, one range scan: {cond}" + ); + assert!( + !cond.contains("between"), + "{column}: between was inlined: {cond}" + ); + // The plan answers the same rows the function does. + let n: Vec = db + .query(&format!("{query} ORDER BY n"), &[]) + .unwrap() + .iter() + .map(|r| r.get(0)) + .collect(); + assert_eq!(n, [10, 20, 29, 30], "{column}"); + + // The range as a parameter, under a generic plan. + db.batch_execute(&format!( + "SET plan_cache_mode = force_generic_plan; + PREPARE generic_{column}(eql_v4.{domain}) AS + SELECT n FROM v4_int_range WHERE eql_v4.between({column}, $1);" + )) + .unwrap(); + let execute = format!("EXECUTE generic_{column}('{}')", range.replace('\'', "''")); + let plan = explain(&mut db, &execute); + let mut scans = Vec::new(); + index_scans(&plan, &mut scans); + let (_, cond) = scans + .iter() + .find(|(name, _)| name == index) + .unwrap_or_else(|| { + panic!("{column}: generic: no index scan on {index}:\n{plan:#}") + }); + assert!( + cond.contains(">=") && cond.contains("<=") && cond.contains("$1"), + "{column}: generic: both bounds, from the parameter, are index conditions: {cond}" + ); + assert!( + !cond.contains("between"), + "{column}: generic: between was inlined: {cond}" + ); + db.batch_execute(&format!( + "DEALLOCATE generic_{column}; RESET plan_cache_mode;" + )) + .unwrap(); + } + db.batch_execute("RESET enable_seqscan;").unwrap(); + } +} + +/// Needs an EQL v4 installation. The range domains' CHECKs admit exactly the +/// shape the bindings write. +#[test] +#[ignore = "requires EQL_TEST_DATABASE_URL pointing to an EQL v4 database"] +fn postgres_range_domains_check_their_shape() { + let key = runtime().block_on(index_key()); + let (lo, hi) = (Plaintext::Int(Some(1)), Plaintext::Int(Some(2))); + let ore = json!({"v": 4, "i": identifier(), "lo": ob(&key, lo.clone(), true), "hi": ob(&key, hi.clone(), true)}); + let ope = json!({"v": 4, "i": identifier(), "lo": op(&key, lo), "hi": op(&key, hi)}); + { + let mut db = connect(); + let cast = |db: &mut postgres::Client, value: &Value, domain: &str| { + db.query_one( + &format!("SELECT $1::text::jsonb::eql_v4.{domain}::text"), + &[&value.to_string()], + ) + .map(|_| ()) + }; + cast(&mut db, &ore, "query_integer_range_ore").unwrap(); + cast(&mut db, &ope, "query_integer_range_ope").unwrap(); + let with = |value: &Value, change: &dyn Fn(&mut Value)| { + let mut v = value.clone(); + change(&mut v); + v + }; + for (value, domain, why) in [ + ( + with(&ore, &|v| v["c"] = json!("x")), + "query_integer_range_ore", + "no ciphertext", + ), + ( + with(&ore, &|v| v["v"] = json!(3)), + "query_integer_range_ore", + "EQL v4 only", + ), + ( + with(&ore, &|v| { + v.as_object_mut().unwrap().remove("hi"); + }), + "query_integer_range_ore", + "both bounds", + ), + ( + with(&ore, &|v| v["lo"] = json!([])), + "query_integer_range_ore", + "a non-empty ORE bound", + ), + ( + ope.clone(), + "query_integer_range_ore", + "an ORE bound is an array", + ), + ( + ore.clone(), + "query_integer_range_ope", + "an OPE bound is a string", + ), + ] { + let err = cast(&mut db, &value, domain).unwrap_err(); + assert_eq!(sqlstate(&err), "23514", "{why}: {err}"); + } + } +} From 319a954dabfa319d18ad7f3dafbbb90c7e9bff72 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 17 Oct 2026 23:24:28 +1100 Subject: [PATCH 2/6] docs(eql): say a range must be cast to its exact domain, and what its envelope is The docs said a range of the wrong term fails "when the query is planned". It fails when the statement is parsed, with 42725 ("function eql_v4.between(...) is not unique"), and so do an uncast $1, a jsonb parameter and a range of the wrong family: PostgreSQL resolves a function's domain arguments as their base type, jsonb, which every between overload takes. The single-bound operators resolve an untyped $1; between does not. The function reference, the generated SQL doc comments, the eql-bindings README and changelog, the Go README and changelog and the changeset now say the range parameter must be cast to its exact range domain. The 42725 behaviour stays; there is no catch-all overload. They also said a range is Stack Encrypt's RangeQuery { lo, hi } "with nothing added". It is not: RangeQuery has no serialized form, Operand::Range carries raw bytes, the wire adds v and i, and query_term refuses a range on an EQL target today. Every description now says each bound is the single-bound domain's ob (ORE) or op (OPE) value, in a {v, i, lo, hi} envelope, which the engine will need to build when the ordered types become producible. The changeset no longer implies the npm package or the crate ships the v4 SQL: the v4 bundle reaches users as the eql-v4-install.sql attached to each Go release. --- .changeset/eql-v4-bindings.md | 2 +- languages/golang/CHANGELOG.md | 11 +++--- languages/golang/encrypt/README.md | 9 +++-- packages/eql/crates/eql-bindings/CHANGELOG.md | 9 ++--- packages/eql/crates/eql-bindings/README.md | 12 ++++--- .../eql/crates/eql-bindings/src/v4/mod.rs | 5 +-- .../eql/crates/eql-codegen/src/bindings.rs | 9 ++--- packages/eql/crates/eql-codegen/src/range.rs | 20 +++++++---- .../templates/range_functions.sql.j2 | 7 ++++ .../eql-codegen/templates/range_types.sql.j2 | 12 ++++--- packages/eql/crates/eql-domains/src/lib.rs | 14 +++++--- packages/eql/docs/reference/eql-functions.md | 4 +-- .../bigint/query_bigint_range_functions.sql | 21 +++++++++++ .../bigint/query_bigint_range_types.sql | 12 ++++--- .../date/query_date_range_functions.sql | 21 +++++++++++ .../scalars/date/query_date_range_types.sql | 12 ++++--- .../double/query_double_range_functions.sql | 21 +++++++++++ .../double/query_double_range_types.sql | 12 ++++--- .../integer/query_integer_range_functions.sql | 21 +++++++++++ .../integer/query_integer_range_types.sql | 12 ++++--- .../numeric/query_numeric_range_functions.sql | 21 +++++++++++ .../numeric/query_numeric_range_types.sql | 12 ++++--- .../real/query_real_range_functions.sql | 21 +++++++++++ .../scalars/real/query_real_range_types.sql | 12 ++++--- .../query_smallint_range_functions.sql | 21 +++++++++++ .../smallint/query_smallint_range_types.sql | 12 ++++--- .../text/query_text_range_functions.sql | 35 +++++++++++++++++++ .../scalars/text/query_text_range_types.sql | 12 ++++--- .../query_timestamp_range_functions.sql | 21 +++++++++++ .../timestamp/query_timestamp_range_types.sql | 12 ++++--- 30 files changed, 350 insertions(+), 75 deletions(-) diff --git a/.changeset/eql-v4-bindings.md b/.changeset/eql-v4-bindings.md index a7bb2cc0a..a671930ad 100644 --- a/.changeset/eql-v4-bindings.md +++ b/.changeset/eql-v4-bindings.md @@ -4,6 +4,6 @@ **The `eql-bindings` crate has an `eql_bindings::v4` module, with the `stack-encrypt` feature: payload types for the EQL v4 domains.** EQL v4 is the v3 SQL installed under a second name (ADR-0002), for values written by Stack Encrypt, so the module exists only with that feature and its types derive Stack Encrypt's traits without a further gate. The module is generated from the same catalog as `v3`, with `eql_v4_*` domain names and `"v": 4`. `SchemaVersion` is now generic over the version it admits: `v3::SchemaVersion` is `SchemaVersion<3>` and `v4::SchemaVersion` is `SchemaVersion<4>`, each refusing the other's payloads, and plain `SchemaVersion` still means v3. Only v4 types derive Stack Encrypt's `EncryptFrom` and `DecryptInto`, and the Stack Encrypt target table (now `eql_bindings::v4::targets`) names v4 domains. Besides `TextEq`, Stack Encrypt now produces the storage-only types `Text`, `Integer`, `Bigint`, `Boolean`, `Real` and `Double`, and `TextMatch` (3-grams, `k = 6`, a 2048-bit filter, pinned as `eql_bindings::encryption::EqlMatch`; text shorter than three characters is refused, stored or queried, and only `stored @@ query` matches until #1171 is fixed), from the Rust primitives `i32`, `i64`, `bool`, `f32` and `f64`; the Go SDK's `encrypt/eql` table marks them producible. Stack Encrypt also produces the number equality types `IntegerEq`, `BigintEq`, `RealEq` and `DoubleEq`, whose `=` compares as PostgreSQL compares the plain types: an integer has one term whatever its width, and for floats `-0.0 = 0.0` and `NaN = NaN` (ADR-0002's semantic baseline). Every type it cannot produce yet names the issue that tracks its blocker. The by-name target entry points (`eql_bindings::encryption::targets::encrypt`, `decrypt` and `query`) exchange the plaintext as Stack Encrypt's `dynamic::ValueFrame`, one value as its transport frame, rather than a value tree. They, and the generated `v4::targets` table, need the new `targets` feature, which turns on `stack-encrypt` and Stack Encrypt's `dynamic` feature. `stack-encrypt` alone no longer builds Stack Encrypt's `dynamic` layer, so a Rust program using the typed v4 types through `encrypt_as` doesn't compile it. -**EQL v4 has range query domains and `eql_v4.between`, so a `BETWEEN` query is one operand.** Every ordered family has one range query domain per order term, beside its single-bound ones: `eql_v4.query_integer_range_ore` beside `eql_v4.query_integer_ord_ore`, `eql_v4.query_integer_range_ope` beside `eql_v4.query_integer_ord_ope`, and the same for `smallint`, `bigint`, `date`, `timestamp`, `numeric`, `text`, `real` and `double`. A range holds both bounds as `{"v": 4, "i": …, "lo": , "hi": }`, each bound the term the single-bound domain holds under `ob` or `op`. `eql_v4.between(column, range)` is true when the column's order term lies between the bounds, both included, and has an overload for every ordered column domain with the range of the term it stores (`_ord_ore` and `_search_ore` take the ORE range; `_ord`, `_ord_ope` and `_search` the OPE one). It is an inlinable SQL function, so a btree on the column's order term (`eql_v4.ord_term_ore(col)` or `eql_v4.ord_term(col)`) serves it as one index range scan. Write it schema-qualified: `BETWEEN` is an SQL keyword, so a bare `between(...)` does not parse. `eql_bindings::v4` has a type for each range domain (`IntegerRangeOreQuery`, `TextRangeOpeQuery`, …, listed by `v4::all_range_query()`), so a range binds as one parameter. EQL v3 has no range domains: its SQL and types are unchanged (ADR-0009, decision 7). +**The EQL v4 SQL bundle has range query domains and `eql_v4.between`, so a `BETWEEN` query is one operand.** The v4 bundle (`cipherstash-encrypt-v4.sql`) is built from this source and reaches users as the `eql-v4-install.sql` attached to each Go release; neither the npm package nor the crate ships it, and the v3 SQL they ship does not change. Every ordered family has one range query domain per order term, beside its single-bound ones: `eql_v4.query_integer_range_ore` beside `eql_v4.query_integer_ord_ore`, `eql_v4.query_integer_range_ope` beside `eql_v4.query_integer_ord_ope`, and the same for `smallint`, `bigint`, `date`, `timestamp`, `numeric`, `text`, `real` and `double`. A range is `{"v": 4, "i": …, "lo": , "hi": }`: each bound is the single-bound domain's `ob` (ORE) or `op` (OPE) value, in a `{v, i, lo, hi}` envelope. Stack Encrypt does not build that envelope yet; it will need to when the ordered types become producible. `eql_v4.between(column, range)` is true when the column's order term lies between the bounds, both included, and has an overload for every ordered column domain with the range of the term it stores (`_ord_ore` and `_search_ore` take the ORE range; `_ord`, `_ord_ope` and `_search` the OPE one). It is an inlinable SQL function, so a btree on the column's order term (`eql_v4.ord_term_ore(col)` or `eql_v4.ord_term(col)`) serves it as one index range scan. Write it schema-qualified (`BETWEEN` is an SQL keyword, so a bare `between(...)` does not parse), and cast the range to its exact domain (`$1::eql_v4.query_integer_range_ore`): an uncast `$1`, a `jsonb` parameter, or a range of another term or family fails with SQLSTATE 42725 ("function … is not unique"), unlike the single-bound operators, which resolve an untyped `$1`. In the crate, `eql_bindings::v4` (`stack-encrypt` feature) has a type for each range domain (`IntegerRangeOreQuery`, `TextRangeOpeQuery`, …, listed by `v4::all_range_query()`). EQL v3 has no range domains: its SQL and types are unchanged (ADR-0009, decision 7). Nothing a released version shipped has changed. The v3 types, the v3 SQL and the TypeScript package are as they were; the `stack-encrypt` derives on the v3 text types and the `v3::targets` module were added after the last release and are moved to v4 here, not removed from a release. diff --git a/languages/golang/CHANGELOG.md b/languages/golang/CHANGELOG.md index 9f367a66b..ad3034a70 100644 --- a/languages/golang/CHANGELOG.md +++ b/languages/golang/CHANGELOG.md @@ -60,10 +60,13 @@ listed under **Breaking**. Releases are tagged `languages/golang/vX.Y.Z`. index: `eql.IntegerRangeOreQuery`, `eql.IntegerRangeOpeQuery`, `eql.TextRangeOreQuery`, `eql.TextRangeOpeQuery`, and the same for `smallint`, `bigint`, `date`, `timestamp`, `numeric`, `real` and `double`. - Each holds both bounds of one `BETWEEN` as the JSON value - `eql_v4.between(column, $1::eql_v4.query__range_)` takes, - so a range binds as one parameter, and implements `driver.Valuer`, - `sql.Scanner` and JSON like the other query types. They are not in + Each holds both bounds of one `BETWEEN` (each bound the single-bound + domain's `ob` or `op` value, in a `{v, i, lo, hi}` envelope) as the JSON + value `eql_v4.between(column, $1::eql_v4.query__range_)` + takes, so a range binds as one parameter. The cast to the exact range + domain is required, or the statement fails with SQLSTATE 42725. Each type + implements `driver.Valuer`, `sql.Scanner` and JSON like the other query + types. They are not in `eql.Types`, which lists stored types, and no generated method returns one yet: the ordered EQL types are not producible. - **`encrypt.MatchQuery` and `encrypt.EQLMatchQuery`**: what a `Matches` diff --git a/languages/golang/encrypt/README.md b/languages/golang/encrypt/README.md index a803cb097..7432b5f68 100644 --- a/languages/golang/encrypt/README.md +++ b/languages/golang/encrypt/README.md @@ -299,9 +299,12 @@ any library built on `database/sql` or pgx. A `Range` binds as two parameters, `Low` and `High`. On an EQL column a range is one value instead: package `encrypt/eql` has a range query type per ordered family and order index (`eql.IntegerRangeOreQuery`, `eql.TextRangeOpeQuery`, ...), the one -operand of `eql_v4.between(column, $1::eql_v4.query_integer_range_ore)`. No -generated method returns one yet, because the ordered EQL types are not -producible. +operand of `eql_v4.between(column, $1::eql_v4.query_integer_range_ore)`. Keep +the cast to the exact range domain: an uncast `$1`, a `jsonb` parameter, or a +range of another term or family fails with SQLSTATE 42725 ("function ... is +not unique"), unlike the single-bound operators, which resolve an untyped +`$1`. No generated method returns one yet, because the ordered EQL types are +not producible. `Eq`, the orderings and `Between` are exact: the server's answer is the answer. `Matches` is probabilistic: the server answers with every row whose diff --git a/packages/eql/crates/eql-bindings/CHANGELOG.md b/packages/eql/crates/eql-bindings/CHANGELOG.md index fac229ccd..92f5b06ce 100644 --- a/packages/eql/crates/eql-bindings/CHANGELOG.md +++ b/packages/eql/crates/eql-bindings/CHANGELOG.md @@ -14,10 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `IntegerRangeOpeQuery`, `TextRangeOreQuery` and `TextRangeOpeQuery`, and so on for `smallint`, `bigint`, `date`, `timestamp`, `numeric`, `real` and `double`, listed by `v4::all_range_query()`. Each holds both bounds of one - `BETWEEN` as `{v, i, lo, hi}`, each bound the term the family's - single-bound query operand holds (`OreBlock256` for ORE, `OpeCllw` for - OPE), so a range binds as one parameter - (`$1::eql_v4.query_integer_range_ore`). They bind the + `BETWEEN`: each bound is the single-bound domain's `ob` (ORE, + `OreBlock256`) or `op` (OPE, `OpeCllw`) value, in a `{v, i, lo, hi}` + envelope, so a range binds as one parameter. The parameter must be cast to + its exact range domain (`$1::eql_v4.query_integer_range_ore`); anything else + fails with SQLSTATE 42725. They bind the `eql_v4.query__range_` domains in the v4 SQL bundle. No range type derives Stack Encrypt's traits yet, because the ordered types they range over are not producible. There are no v3 range types. diff --git a/packages/eql/crates/eql-bindings/README.md b/packages/eql/crates/eql-bindings/README.md index 0530326e8..397028d04 100644 --- a/packages/eql/crates/eql-bindings/README.md +++ b/packages/eql/crates/eql-bindings/README.md @@ -140,11 +140,15 @@ document as one leaf. Each ordered v4 family also has two range query types, one per order term: `IntegerRangeOreQuery` and `IntegerRangeOpeQuery`, and so on (`v4::all_range_query()` lists them). A range holds both bounds of one -`BETWEEN` as `{v, i, lo, hi}` and binds as one parameter to +`BETWEEN`: each bound is the single-bound domain's `ob` (ORE) or `op` (OPE) +value, in a `{v, i, lo, hi}` envelope. It binds as one parameter to `eql_v4.between(column, $1::eql_v4.query_integer_range_ore)`, which includes -both bounds. Nothing produces one yet, since the ordered types are not -producible; the types and the SQL are in place for when they are. EQL v3 has -no range types or `between`. +both bounds. Cast the parameter to its exact range domain: an uncast `$1`, a +`jsonb` parameter, or a range of another term or family fails with SQLSTATE +42725 ("function ... is not unique"), unlike the single-bound operators, which +resolve an untyped `$1`. Nothing produces a range yet, since the ordered types +are not producible, and Stack Encrypt will need to build the envelope when +they are. EQL v3 has no range types or `between`. The generated API docs include a complete, executable example in the `eql_bindings::encryption` module, from cipher setup through decryption. Its diff --git a/packages/eql/crates/eql-bindings/src/v4/mod.rs b/packages/eql/crates/eql-bindings/src/v4/mod.rs index 2122e5504..f1458763e 100644 --- a/packages/eql/crates/eql-bindings/src/v4/mod.rs +++ b/packages/eql/crates/eql-bindings/src/v4/mod.rs @@ -15,8 +15,9 @@ //! //! Beyond v3's types, every ordered family has a range query operand per //! order term (`IntegerRangeOreQuery`, `IntegerRangeOpeQuery`, …, listed by -//! [`all_range_query`]): both bounds of one `BETWEEN`, the one operand of -//! `eql_v4.between(column, range)`. EQL v3 has no range domains. +//! [`all_range_query`]): both bounds of one `BETWEEN`, each the single-bound +//! domain's `ob` or `op` value, in a `{v, i, lo, hi}` envelope, and the one +//! operand of `eql_v4.between(column, range)`. EQL v3 has no range domains. //! //! v4 has no SteVec bindings, payload enums, TypeScript export or //! published JSON Schema yet; those follow in `@cipherstash/eql` 4.0. The diff --git a/packages/eql/crates/eql-codegen/src/bindings.rs b/packages/eql/crates/eql-codegen/src/bindings.rs index 6fdc8d2a2..1efd13f4e 100644 --- a/packages/eql/crates/eql-codegen/src/bindings.rs +++ b/packages/eql/crates/eql-codegen/src/bindings.rs @@ -340,10 +340,11 @@ fn render_query_struct(eql: EqlVersion, family: &DomainFamily, domain: &Domain) /// /// The fields are the envelope's `v` and `i`, then the bounds under /// [`RANGE_BOUND_KEYS`], each the term newtype the family's single-bound query -/// struct for the same term holds (`OreBlock256` for `ob`, `OpeCllw` for `op`), -/// so a range is Stack Encrypt's `RangeQuery { lo, hi }` with nothing added. -/// No struct derives Stack Encrypt's traits yet: the ordered types it ranges -/// over are not producible. +/// struct for the same term holds (`OreBlock256` for `ob`, `OpeCllw` for `op`): +/// the single-bound domain's `ob` or `op` value, in a `{v, i, lo, hi}` +/// envelope. No struct derives Stack Encrypt's traits yet: the ordered types +/// it ranges over are not producible, and the engine does not build the +/// envelope (see [`RANGE_BOUND_KEYS`]). fn render_range_query_struct(family: &DomainFamily, term: Term) -> TokenStream { let eql = crate::range::RANGE_VERSION; let ident = format_ident!("{}", family.range_struct_ident(term)); diff --git a/packages/eql/crates/eql-codegen/src/range.rs b/packages/eql/crates/eql-codegen/src/range.rs index d48aa914c..9cc0eec79 100644 --- a/packages/eql/crates/eql-codegen/src/range.rs +++ b/packages/eql/crates/eql-codegen/src/range.rs @@ -3,17 +3,25 @@ //! stored domain that carries the term (ADR-0009, decisions 7 and 8). //! //! A `Between` query is one term holding both bounds (Stack Encrypt's -//! `RangeQuery { lo, hi }`), and in SQL it stays one operand: +//! `RangeQuery`), and in SQL it stays one operand: //! //! ```sql //! WHERE eql_v4.between(age, $1::eql_v4.query_integer_range_ore) //! ``` //! -//! The wire shape is `{v, i, lo, hi}` -//! ([`RANGE_BOUND_KEYS`]): each bound is the -//! value the single-bound query domain for the same term holds under its term -//! key, so a bound of an ORE range is an `ob` array and a bound of an OPE range -//! an `op` hex string. +//! Each bound is the single-bound domain's `ob` (ORE) or `op` (OPE) value, in +//! a `{v, i, lo, hi}` envelope ([`RANGE_BOUND_KEYS`]): a bound of an ORE range +//! is an `ob` array, a bound of an OPE range an `op` hex string. The engine +//! does not build that envelope yet; it will need to when the ordered EQL +//! types become producible. +//! +//! The range parameter must be cast to its exact range domain. `between` has +//! many overloads over jsonb-backed domains, and PostgreSQL resolves a +//! function's domain arguments as their base type, so an uncast `$1`, a +//! `jsonb` parameter, or a range of the wrong term or family matches several +//! overloads and fails with 42725 ("function ... is not unique") before the +//! query runs. The single-bound operators resolve an untyped `$1`; +//! `between` does not. //! //! EQL v3 has none of this. v3 is what cipherstash-client writes, and nothing //! there produces a range. Both files carry the `-- ONLY EQL VERSION: 4` line diff --git a/packages/eql/crates/eql-codegen/templates/range_functions.sql.j2 b/packages/eql/crates/eql-codegen/templates/range_functions.sql.j2 index 714ae845f..c4cd5c8bc 100644 --- a/packages/eql/crates/eql-codegen/templates/range_functions.sql.j2 +++ b/packages/eql/crates/eql-codegen/templates/range_functions.sql.j2 @@ -47,6 +47,13 @@ AS $$ SELECT {{ a.body }} $$; --! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere.{% endif %} --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{ schema }}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{ schema }}.{{ b.range }}`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{ schema }}.between(a {{ b.column }}, r {{ schema }}.{{ b.range }}) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE diff --git a/packages/eql/crates/eql-codegen/templates/range_types.sql.j2 b/packages/eql/crates/eql-codegen/templates/range_types.sql.j2 index c1b4d9f6a..060205abf 100644 --- a/packages/eql/crates/eql-codegen/templates/range_types.sql.j2 +++ b/packages/eql/crates/eql-codegen/templates/range_types.sql.j2 @@ -6,10 +6,14 @@ --! @brief Range query-operand domains for {{ family_name }}: the operand of `{{ schema }}.between`. --! @note A range holds both bounds of one `BETWEEN` as one value: --! `{"v": {{ eql_version }}, "i": {...}, "lo": , "hi": }`. Each bound is the ---! value the family's single-bound query domain for the same ordering ---! term holds under its term key (`ob` or `op`). Both bounds are ---! included. There is one range domain per ordering term the family ---! stores (ADR-0009, decisions 7 and 8). +--! single-bound query domain's `ob` (ORE) or `op` (OPE) value, in a +--! `{v, i, lo, hi}` envelope. Both bounds are included. There is one +--! range domain per ordering term the family stores (ADR-0009, +--! decisions 7 and 8). +--! @note Cast the range parameter to its exact range domain +--! (`$1::{{ schema }}.query_{{ family_name }}_range_ore`). An uncast `$1`, a `jsonb` +--! parameter, or a range of another term or family fails with 42725 +--! ("function ... is not unique"); see `{{ schema }}.between`. --! @note EQL version {{ eql_version }} only: the `-- ONLY EQL VERSION:` line above keeps this --! file out of every other version's installer. --! @note Like every query operand, a range lives in `{{ schema }}`, not `public`, diff --git a/packages/eql/crates/eql-domains/src/lib.rs b/packages/eql/crates/eql-domains/src/lib.rs index a7bf33a20..8f3f67ee1 100644 --- a/packages/eql/crates/eql-domains/src/lib.rs +++ b/packages/eql/crates/eql-domains/src/lib.rs @@ -45,11 +45,15 @@ pub use fixtures::{ pub const ENVELOPE_KEYS: &[&str] = &["v", "i", "c"]; /// The keys a range query domain carries its two bounds under, low then -/// high, beside the `v` and `i` every query operand carries: -/// `{"v": 4, "i": {…}, "lo": , "hi": }`. Each bound is the -/// value the family's single-bound query domain for the same term carries -/// under its term key (`ob` or `op`): a range is Stack Encrypt's -/// `RangeQuery { lo, hi }` with nothing added. Inclusive at both ends. +/// high: `{"v": 4, "i": {…}, "lo": , "hi": }`. Each bound is the +/// single-bound query domain's `ob` (ORE) or `op` (OPE) value, in a +/// `{v, i, lo, hi}` envelope. Inclusive at both ends. +/// +/// Stack Encrypt does not write this envelope yet. Its `RangeQuery` has no +/// serialized form, the data path's `Operand::Range { lo, hi }` carries raw +/// term bytes, and `query_term` refuses a range on an EQL target. When the +/// ordered EQL types become producible, the engine will need to build this +/// envelope from the two bounds' terms. /// /// Cross-schema contract data like [`ENVELOPE_KEYS`]: `eql-codegen` builds /// the range domain CHECKs and accessors from it, and the bindings name diff --git a/packages/eql/docs/reference/eql-functions.md b/packages/eql/docs/reference/eql-functions.md index 4cca37534..853bdb0b1 100644 --- a/packages/eql/docs/reference/eql-functions.md +++ b/packages/eql/docs/reference/eql-functions.md @@ -143,7 +143,7 @@ Every ordered family has a **range query domain** per ordering term, beside its | `eql_v4.query__range_ore` | two block-ORE terms (`ob` arrays) | `public.eql_v4__ord_ore`, `public.eql_v4_text_search_ore` | | `eql_v4.query__range_ope` | two CLLW-OPE terms (`op` hex strings) | `public.eql_v4__ord`, `public.eql_v4__ord_ope`, `public.eql_v4_text_search` | -`` is any ordered family: `integer`, `smallint`, `bigint`, `date`, `timestamp`, `numeric`, `text`, `real`, `double`. A range value is `{"v": 4, "i": {...}, "lo": , "hi": }`, each bound the value the single-bound query domain holds under its term key, with no ciphertext `c`. +`` is any ordered family: `integer`, `smallint`, `bigint`, `date`, `timestamp`, `numeric`, `text`, `real`, `double`. A range value is `{"v": 4, "i": {...}, "lo": , "hi": }`: each bound is the single-bound query domain's `ob` (ORE) or `op` (OPE) value, in a `{v, i, lo, hi}` envelope, with no ciphertext `c`. Stack Encrypt does not build this envelope yet; it will when the ordered EQL types become producible. ```sql -- true when lo <= column <= hi, both bounds included @@ -166,7 +166,7 @@ SELECT * FROM users WHERE eql_v4.between(age, $1::eql_v4.query_integer_range_ope - `eql_v4.between` is an inlinable SQL function, so the planner sees `ord_term(age) >= lo AND ord_term(age) <= hi`, and a btree on the column's order term serves it as one range scan: `eql_v4.ord_term(col)` for an OPE column (`_ord`, `_ord_ope`, `_search`), `eql_v4.ord_term_ore(col)` for an ORE one (`_ord_ore`, `_search_ore`). - **An ORE column needs the `eql_v4_internal.ore_block_256` btree operator class**, for its index and so for an ORE range scan. The installer creates that class only when it runs with superuser privileges. Otherwise (on Supabase and other managed roles) it skips it and disables the ORE column domains: a value cast to `_ord_ore` or `_search_ore` raises `feature_not_supported`, naming the OPE and equality alternatives. On such an install, use an OPE column and its `_range_ope` domain, which need nothing more. - Always write it schema-qualified. `BETWEEN` is an SQL keyword, so a bare `between(...)` does not parse. -- A range over the other term (an OPE range on an `_ord_ore` column) has no overload and fails when the query is planned. +- **Always cast the range parameter to its exact range domain**, as in `$1::eql_v4.query_integer_range_ore`. PostgreSQL resolves a function's domain arguments as their base type, `jsonb`, and every `between` overload takes jsonb domains. So an uncast `$1`, a `jsonb` parameter, or a range of the wrong term (an OPE range on an `_ord_ore` column) or the wrong family matches several overloads, and the statement fails with SQLSTATE 42725 (`function eql_v4.between(...) is not unique`) when it is parsed, before it runs. This differs from the single-bound operators: `col < $1` resolves an untyped `$1`. - A `NULL` range matches no row. So does a range whose low bound is above its high bound; the client refuses to build one. - The `eql_bindings::v4` Rust types (`IntegerRangeOreQuery`, …) and the Go `encrypt/eql` types of the same names hold a range value, so it binds as one parameter. diff --git a/packages/eql/src/v3/scalars/bigint/query_bigint_range_functions.sql b/packages/eql/src/v3/scalars/bigint/query_bigint_range_functions.sql index cfc95b91f..ad9fe55a0 100644 --- a/packages/eql/src/v3/scalars/bigint/query_bigint_range_functions.sql +++ b/packages/eql/src/v3/scalars/bigint/query_bigint_range_functions.sql @@ -103,6 +103,13 @@ AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> ' --! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_bigint_range_ore`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_bigint_ord_ore, r {{prefix}}.query_bigint_range_ore) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE @@ -126,6 +133,13 @@ AS $$ SELECT {{prefix}}.ord_term_ore(a) >= {{prefix}}_internal.lo(r) AND {{prefi --! serves as one range scan. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_bigint_range_ope`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_bigint_ord, r {{prefix}}.query_bigint_range_ope) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE @@ -149,6 +163,13 @@ AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}. --! serves as one range scan. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_bigint_range_ope`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_bigint_ord_ope, r {{prefix}}.query_bigint_range_ope) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE diff --git a/packages/eql/src/v3/scalars/bigint/query_bigint_range_types.sql b/packages/eql/src/v3/scalars/bigint/query_bigint_range_types.sql index 283efaf20..9e70f5117 100644 --- a/packages/eql/src/v3/scalars/bigint/query_bigint_range_types.sql +++ b/packages/eql/src/v3/scalars/bigint/query_bigint_range_types.sql @@ -6,10 +6,14 @@ --! @brief Range query-operand domains for bigint: the operand of `{{prefix}}.between`. --! @note A range holds both bounds of one `BETWEEN` as one value: --! `{"v": {{eql_version}}, "i": {...}, "lo": , "hi": }`. Each bound is the ---! value the family's single-bound query domain for the same ordering ---! term holds under its term key (`ob` or `op`). Both bounds are ---! included. There is one range domain per ordering term the family ---! stores (ADR-0009, decisions 7 and 8). +--! single-bound query domain's `ob` (ORE) or `op` (OPE) value, in a +--! `{v, i, lo, hi}` envelope. Both bounds are included. There is one +--! range domain per ordering term the family stores (ADR-0009, +--! decisions 7 and 8). +--! @note Cast the range parameter to its exact range domain +--! (`$1::{{prefix}}.query_bigint_range_ore`). An uncast `$1`, a `jsonb` +--! parameter, or a range of another term or family fails with 42725 +--! ("function ... is not unique"); see `{{prefix}}.between`. --! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this --! file out of every other version's installer. --! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, diff --git a/packages/eql/src/v3/scalars/date/query_date_range_functions.sql b/packages/eql/src/v3/scalars/date/query_date_range_functions.sql index 3cd9cb971..be33d8dda 100644 --- a/packages/eql/src/v3/scalars/date/query_date_range_functions.sql +++ b/packages/eql/src/v3/scalars/date/query_date_range_functions.sql @@ -103,6 +103,13 @@ AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> ' --! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_date_range_ore`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_date_ord_ore, r {{prefix}}.query_date_range_ore) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE @@ -126,6 +133,13 @@ AS $$ SELECT {{prefix}}.ord_term_ore(a) >= {{prefix}}_internal.lo(r) AND {{prefi --! serves as one range scan. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_date_range_ope`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_date_ord, r {{prefix}}.query_date_range_ope) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE @@ -149,6 +163,13 @@ AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}. --! serves as one range scan. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_date_range_ope`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_date_ord_ope, r {{prefix}}.query_date_range_ope) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE diff --git a/packages/eql/src/v3/scalars/date/query_date_range_types.sql b/packages/eql/src/v3/scalars/date/query_date_range_types.sql index f67a941c0..21c6f1de2 100644 --- a/packages/eql/src/v3/scalars/date/query_date_range_types.sql +++ b/packages/eql/src/v3/scalars/date/query_date_range_types.sql @@ -6,10 +6,14 @@ --! @brief Range query-operand domains for date: the operand of `{{prefix}}.between`. --! @note A range holds both bounds of one `BETWEEN` as one value: --! `{"v": {{eql_version}}, "i": {...}, "lo": , "hi": }`. Each bound is the ---! value the family's single-bound query domain for the same ordering ---! term holds under its term key (`ob` or `op`). Both bounds are ---! included. There is one range domain per ordering term the family ---! stores (ADR-0009, decisions 7 and 8). +--! single-bound query domain's `ob` (ORE) or `op` (OPE) value, in a +--! `{v, i, lo, hi}` envelope. Both bounds are included. There is one +--! range domain per ordering term the family stores (ADR-0009, +--! decisions 7 and 8). +--! @note Cast the range parameter to its exact range domain +--! (`$1::{{prefix}}.query_date_range_ore`). An uncast `$1`, a `jsonb` +--! parameter, or a range of another term or family fails with 42725 +--! ("function ... is not unique"); see `{{prefix}}.between`. --! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this --! file out of every other version's installer. --! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, diff --git a/packages/eql/src/v3/scalars/double/query_double_range_functions.sql b/packages/eql/src/v3/scalars/double/query_double_range_functions.sql index a95a5d6a5..e28773ac6 100644 --- a/packages/eql/src/v3/scalars/double/query_double_range_functions.sql +++ b/packages/eql/src/v3/scalars/double/query_double_range_functions.sql @@ -103,6 +103,13 @@ AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> ' --! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_double_range_ore`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_double_ord_ore, r {{prefix}}.query_double_range_ore) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE @@ -126,6 +133,13 @@ AS $$ SELECT {{prefix}}.ord_term_ore(a) >= {{prefix}}_internal.lo(r) AND {{prefi --! serves as one range scan. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_double_range_ope`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_double_ord, r {{prefix}}.query_double_range_ope) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE @@ -149,6 +163,13 @@ AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}. --! serves as one range scan. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_double_range_ope`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_double_ord_ope, r {{prefix}}.query_double_range_ope) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE diff --git a/packages/eql/src/v3/scalars/double/query_double_range_types.sql b/packages/eql/src/v3/scalars/double/query_double_range_types.sql index 9690a403f..4c227acda 100644 --- a/packages/eql/src/v3/scalars/double/query_double_range_types.sql +++ b/packages/eql/src/v3/scalars/double/query_double_range_types.sql @@ -6,10 +6,14 @@ --! @brief Range query-operand domains for double: the operand of `{{prefix}}.between`. --! @note A range holds both bounds of one `BETWEEN` as one value: --! `{"v": {{eql_version}}, "i": {...}, "lo": , "hi": }`. Each bound is the ---! value the family's single-bound query domain for the same ordering ---! term holds under its term key (`ob` or `op`). Both bounds are ---! included. There is one range domain per ordering term the family ---! stores (ADR-0009, decisions 7 and 8). +--! single-bound query domain's `ob` (ORE) or `op` (OPE) value, in a +--! `{v, i, lo, hi}` envelope. Both bounds are included. There is one +--! range domain per ordering term the family stores (ADR-0009, +--! decisions 7 and 8). +--! @note Cast the range parameter to its exact range domain +--! (`$1::{{prefix}}.query_double_range_ore`). An uncast `$1`, a `jsonb` +--! parameter, or a range of another term or family fails with 42725 +--! ("function ... is not unique"); see `{{prefix}}.between`. --! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this --! file out of every other version's installer. --! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, diff --git a/packages/eql/src/v3/scalars/integer/query_integer_range_functions.sql b/packages/eql/src/v3/scalars/integer/query_integer_range_functions.sql index aa82c3741..dce2eb8a1 100644 --- a/packages/eql/src/v3/scalars/integer/query_integer_range_functions.sql +++ b/packages/eql/src/v3/scalars/integer/query_integer_range_functions.sql @@ -103,6 +103,13 @@ AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> ' --! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_integer_range_ore`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_integer_ord_ore, r {{prefix}}.query_integer_range_ore) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE @@ -126,6 +133,13 @@ AS $$ SELECT {{prefix}}.ord_term_ore(a) >= {{prefix}}_internal.lo(r) AND {{prefi --! serves as one range scan. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_integer_range_ope`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_integer_ord, r {{prefix}}.query_integer_range_ope) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE @@ -149,6 +163,13 @@ AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}. --! serves as one range scan. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_integer_range_ope`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_integer_ord_ope, r {{prefix}}.query_integer_range_ope) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE diff --git a/packages/eql/src/v3/scalars/integer/query_integer_range_types.sql b/packages/eql/src/v3/scalars/integer/query_integer_range_types.sql index 52246716b..93fd7526d 100644 --- a/packages/eql/src/v3/scalars/integer/query_integer_range_types.sql +++ b/packages/eql/src/v3/scalars/integer/query_integer_range_types.sql @@ -6,10 +6,14 @@ --! @brief Range query-operand domains for integer: the operand of `{{prefix}}.between`. --! @note A range holds both bounds of one `BETWEEN` as one value: --! `{"v": {{eql_version}}, "i": {...}, "lo": , "hi": }`. Each bound is the ---! value the family's single-bound query domain for the same ordering ---! term holds under its term key (`ob` or `op`). Both bounds are ---! included. There is one range domain per ordering term the family ---! stores (ADR-0009, decisions 7 and 8). +--! single-bound query domain's `ob` (ORE) or `op` (OPE) value, in a +--! `{v, i, lo, hi}` envelope. Both bounds are included. There is one +--! range domain per ordering term the family stores (ADR-0009, +--! decisions 7 and 8). +--! @note Cast the range parameter to its exact range domain +--! (`$1::{{prefix}}.query_integer_range_ore`). An uncast `$1`, a `jsonb` +--! parameter, or a range of another term or family fails with 42725 +--! ("function ... is not unique"); see `{{prefix}}.between`. --! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this --! file out of every other version's installer. --! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, diff --git a/packages/eql/src/v3/scalars/numeric/query_numeric_range_functions.sql b/packages/eql/src/v3/scalars/numeric/query_numeric_range_functions.sql index 83041cc93..792b51ee0 100644 --- a/packages/eql/src/v3/scalars/numeric/query_numeric_range_functions.sql +++ b/packages/eql/src/v3/scalars/numeric/query_numeric_range_functions.sql @@ -103,6 +103,13 @@ AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> ' --! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_numeric_range_ore`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_numeric_ord_ore, r {{prefix}}.query_numeric_range_ore) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE @@ -126,6 +133,13 @@ AS $$ SELECT {{prefix}}.ord_term_ore(a) >= {{prefix}}_internal.lo(r) AND {{prefi --! serves as one range scan. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_numeric_range_ope`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_numeric_ord, r {{prefix}}.query_numeric_range_ope) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE @@ -149,6 +163,13 @@ AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}. --! serves as one range scan. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_numeric_range_ope`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_numeric_ord_ope, r {{prefix}}.query_numeric_range_ope) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE diff --git a/packages/eql/src/v3/scalars/numeric/query_numeric_range_types.sql b/packages/eql/src/v3/scalars/numeric/query_numeric_range_types.sql index acf68b489..31396e9ed 100644 --- a/packages/eql/src/v3/scalars/numeric/query_numeric_range_types.sql +++ b/packages/eql/src/v3/scalars/numeric/query_numeric_range_types.sql @@ -6,10 +6,14 @@ --! @brief Range query-operand domains for numeric: the operand of `{{prefix}}.between`. --! @note A range holds both bounds of one `BETWEEN` as one value: --! `{"v": {{eql_version}}, "i": {...}, "lo": , "hi": }`. Each bound is the ---! value the family's single-bound query domain for the same ordering ---! term holds under its term key (`ob` or `op`). Both bounds are ---! included. There is one range domain per ordering term the family ---! stores (ADR-0009, decisions 7 and 8). +--! single-bound query domain's `ob` (ORE) or `op` (OPE) value, in a +--! `{v, i, lo, hi}` envelope. Both bounds are included. There is one +--! range domain per ordering term the family stores (ADR-0009, +--! decisions 7 and 8). +--! @note Cast the range parameter to its exact range domain +--! (`$1::{{prefix}}.query_numeric_range_ore`). An uncast `$1`, a `jsonb` +--! parameter, or a range of another term or family fails with 42725 +--! ("function ... is not unique"); see `{{prefix}}.between`. --! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this --! file out of every other version's installer. --! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, diff --git a/packages/eql/src/v3/scalars/real/query_real_range_functions.sql b/packages/eql/src/v3/scalars/real/query_real_range_functions.sql index e6924a547..27a3b6146 100644 --- a/packages/eql/src/v3/scalars/real/query_real_range_functions.sql +++ b/packages/eql/src/v3/scalars/real/query_real_range_functions.sql @@ -103,6 +103,13 @@ AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> ' --! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_real_range_ore`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_real_ord_ore, r {{prefix}}.query_real_range_ore) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE @@ -126,6 +133,13 @@ AS $$ SELECT {{prefix}}.ord_term_ore(a) >= {{prefix}}_internal.lo(r) AND {{prefi --! serves as one range scan. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_real_range_ope`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_real_ord, r {{prefix}}.query_real_range_ope) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE @@ -149,6 +163,13 @@ AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}. --! serves as one range scan. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_real_range_ope`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_real_ord_ope, r {{prefix}}.query_real_range_ope) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE diff --git a/packages/eql/src/v3/scalars/real/query_real_range_types.sql b/packages/eql/src/v3/scalars/real/query_real_range_types.sql index 4051e382c..95e7c1b94 100644 --- a/packages/eql/src/v3/scalars/real/query_real_range_types.sql +++ b/packages/eql/src/v3/scalars/real/query_real_range_types.sql @@ -6,10 +6,14 @@ --! @brief Range query-operand domains for real: the operand of `{{prefix}}.between`. --! @note A range holds both bounds of one `BETWEEN` as one value: --! `{"v": {{eql_version}}, "i": {...}, "lo": , "hi": }`. Each bound is the ---! value the family's single-bound query domain for the same ordering ---! term holds under its term key (`ob` or `op`). Both bounds are ---! included. There is one range domain per ordering term the family ---! stores (ADR-0009, decisions 7 and 8). +--! single-bound query domain's `ob` (ORE) or `op` (OPE) value, in a +--! `{v, i, lo, hi}` envelope. Both bounds are included. There is one +--! range domain per ordering term the family stores (ADR-0009, +--! decisions 7 and 8). +--! @note Cast the range parameter to its exact range domain +--! (`$1::{{prefix}}.query_real_range_ore`). An uncast `$1`, a `jsonb` +--! parameter, or a range of another term or family fails with 42725 +--! ("function ... is not unique"); see `{{prefix}}.between`. --! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this --! file out of every other version's installer. --! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, diff --git a/packages/eql/src/v3/scalars/smallint/query_smallint_range_functions.sql b/packages/eql/src/v3/scalars/smallint/query_smallint_range_functions.sql index ee376ae2e..0f4cd9315 100644 --- a/packages/eql/src/v3/scalars/smallint/query_smallint_range_functions.sql +++ b/packages/eql/src/v3/scalars/smallint/query_smallint_range_functions.sql @@ -103,6 +103,13 @@ AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> ' --! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_smallint_range_ore`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_smallint_ord_ore, r {{prefix}}.query_smallint_range_ore) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE @@ -126,6 +133,13 @@ AS $$ SELECT {{prefix}}.ord_term_ore(a) >= {{prefix}}_internal.lo(r) AND {{prefi --! serves as one range scan. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_smallint_range_ope`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_smallint_ord, r {{prefix}}.query_smallint_range_ope) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE @@ -149,6 +163,13 @@ AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}. --! serves as one range scan. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_smallint_range_ope`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_smallint_ord_ope, r {{prefix}}.query_smallint_range_ope) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE diff --git a/packages/eql/src/v3/scalars/smallint/query_smallint_range_types.sql b/packages/eql/src/v3/scalars/smallint/query_smallint_range_types.sql index 116dc64f4..100d7b3e4 100644 --- a/packages/eql/src/v3/scalars/smallint/query_smallint_range_types.sql +++ b/packages/eql/src/v3/scalars/smallint/query_smallint_range_types.sql @@ -6,10 +6,14 @@ --! @brief Range query-operand domains for smallint: the operand of `{{prefix}}.between`. --! @note A range holds both bounds of one `BETWEEN` as one value: --! `{"v": {{eql_version}}, "i": {...}, "lo": , "hi": }`. Each bound is the ---! value the family's single-bound query domain for the same ordering ---! term holds under its term key (`ob` or `op`). Both bounds are ---! included. There is one range domain per ordering term the family ---! stores (ADR-0009, decisions 7 and 8). +--! single-bound query domain's `ob` (ORE) or `op` (OPE) value, in a +--! `{v, i, lo, hi}` envelope. Both bounds are included. There is one +--! range domain per ordering term the family stores (ADR-0009, +--! decisions 7 and 8). +--! @note Cast the range parameter to its exact range domain +--! (`$1::{{prefix}}.query_smallint_range_ore`). An uncast `$1`, a `jsonb` +--! parameter, or a range of another term or family fails with 42725 +--! ("function ... is not unique"); see `{{prefix}}.between`. --! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this --! file out of every other version's installer. --! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, diff --git a/packages/eql/src/v3/scalars/text/query_text_range_functions.sql b/packages/eql/src/v3/scalars/text/query_text_range_functions.sql index fe50bc6e9..6687365b7 100644 --- a/packages/eql/src/v3/scalars/text/query_text_range_functions.sql +++ b/packages/eql/src/v3/scalars/text/query_text_range_functions.sql @@ -105,6 +105,13 @@ AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> ' --! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_text_range_ore`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_text_ord_ore, r {{prefix}}.query_text_range_ore) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE @@ -132,6 +139,13 @@ AS $$ SELECT {{prefix}}.ord_term_ore(a) >= {{prefix}}_internal.lo(r) AND {{prefi --! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_text_range_ore`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_text_search_ore, r {{prefix}}.query_text_range_ore) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE @@ -155,6 +169,13 @@ AS $$ SELECT {{prefix}}.ord_term_ore(a) >= {{prefix}}_internal.lo(r) AND {{prefi --! serves as one range scan. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_text_range_ope`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_text_ord, r {{prefix}}.query_text_range_ope) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE @@ -178,6 +199,13 @@ AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}. --! serves as one range scan. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_text_range_ope`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_text_ord_ope, r {{prefix}}.query_text_range_ope) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE @@ -201,6 +229,13 @@ AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}. --! serves as one range scan. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_text_range_ope`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_text_search, r {{prefix}}.query_text_range_ope) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE diff --git a/packages/eql/src/v3/scalars/text/query_text_range_types.sql b/packages/eql/src/v3/scalars/text/query_text_range_types.sql index 43e6fe918..f6caf4689 100644 --- a/packages/eql/src/v3/scalars/text/query_text_range_types.sql +++ b/packages/eql/src/v3/scalars/text/query_text_range_types.sql @@ -6,10 +6,14 @@ --! @brief Range query-operand domains for text: the operand of `{{prefix}}.between`. --! @note A range holds both bounds of one `BETWEEN` as one value: --! `{"v": {{eql_version}}, "i": {...}, "lo": , "hi": }`. Each bound is the ---! value the family's single-bound query domain for the same ordering ---! term holds under its term key (`ob` or `op`). Both bounds are ---! included. There is one range domain per ordering term the family ---! stores (ADR-0009, decisions 7 and 8). +--! single-bound query domain's `ob` (ORE) or `op` (OPE) value, in a +--! `{v, i, lo, hi}` envelope. Both bounds are included. There is one +--! range domain per ordering term the family stores (ADR-0009, +--! decisions 7 and 8). +--! @note Cast the range parameter to its exact range domain +--! (`$1::{{prefix}}.query_text_range_ore`). An uncast `$1`, a `jsonb` +--! parameter, or a range of another term or family fails with 42725 +--! ("function ... is not unique"); see `{{prefix}}.between`. --! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this --! file out of every other version's installer. --! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, diff --git a/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_functions.sql b/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_functions.sql index f06c114e1..6b905a49d 100644 --- a/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_functions.sql +++ b/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_functions.sql @@ -103,6 +103,13 @@ AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> ' --! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_timestamp_range_ore`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_timestamp_ord_ore, r {{prefix}}.query_timestamp_range_ore) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE @@ -126,6 +133,13 @@ AS $$ SELECT {{prefix}}.ord_term_ore(a) >= {{prefix}}_internal.lo(r) AND {{prefi --! serves as one range scan. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_timestamp_range_ope`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_timestamp_ord, r {{prefix}}.query_timestamp_range_ope) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE @@ -149,6 +163,13 @@ AS $$ SELECT {{prefix}}.ord_term(a) >= {{prefix}}_internal.lo(r) AND {{prefix}}. --! serves as one range scan. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. +--! @note Cast the range to its exact domain, `$1::{{prefix}}.query_timestamp_range_ope`. +--! PostgreSQL resolves a function's domain arguments as their base type, +--! `jsonb`, and every `between` overload takes jsonb domains, so an +--! uncast `$1`, a `jsonb` parameter, or a range of another term or +--! family matches several overloads and the statement fails with 42725 +--! ("function ... is not unique") before it runs. The single-bound +--! operators resolve an untyped `$1`; `between` does not. CREATE FUNCTION {{prefix}}.between(a public.{{prefix}}_timestamp_ord_ope, r {{prefix}}.query_timestamp_range_ope) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE diff --git a/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_types.sql b/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_types.sql index c872c0be7..cd9fdc934 100644 --- a/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_types.sql +++ b/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_types.sql @@ -6,10 +6,14 @@ --! @brief Range query-operand domains for timestamp: the operand of `{{prefix}}.between`. --! @note A range holds both bounds of one `BETWEEN` as one value: --! `{"v": {{eql_version}}, "i": {...}, "lo": , "hi": }`. Each bound is the ---! value the family's single-bound query domain for the same ordering ---! term holds under its term key (`ob` or `op`). Both bounds are ---! included. There is one range domain per ordering term the family ---! stores (ADR-0009, decisions 7 and 8). +--! single-bound query domain's `ob` (ORE) or `op` (OPE) value, in a +--! `{v, i, lo, hi}` envelope. Both bounds are included. There is one +--! range domain per ordering term the family stores (ADR-0009, +--! decisions 7 and 8). +--! @note Cast the range parameter to its exact range domain +--! (`$1::{{prefix}}.query_timestamp_range_ore`). An uncast `$1`, a `jsonb` +--! parameter, or a range of another term or family fails with 42725 +--! ("function ... is not unique"); see `{{prefix}}.between`. --! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this --! file out of every other version's installer. --! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, From 9ef1e17f2e30641f207248199bff42dcfa5e3bf2 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 17 Oct 2026 23:24:28 +1100 Subject: [PATCH 3/6] test(eql): smoke-test between over every ordered family, and pin the exact cast postgres_between_smoke_test_over_every_ordered_family loops over the catalog: for each of the nine ordered families it stores five ascending values in every column domain that carries an ordering term, and checks that a range from the second value to the fourth finds the rows at both bounds and the one inside, and neither row outside, through every one of the 29 between overloads (the _search and _search_ore ones included). For text it also checks that a btree on the order term serves between, for ORE on _ord_ore and for OPE on _search. Every family gets its terms from cipherstash-client's indexers, as the other range tests do; c, hm and bf are real Stack Encrypt values sealed from the value's text, since between never reads them and most of these families have no producible type. postgres_between_needs_the_exact_range_domain prepares between with an uncast $1, a jsonb parameter, a range of another family and one of another term, and pins 42725 for each, beside the exact cast and the single-bound operator, which both prepare. The cipherstash-client comment in the test crate's manifest now names v4_range.rs, and chrono and rust_decimal (already in the lockfile) name the date, timestamp and numeric plaintexts. --- packages/eql/Cargo.lock | 2 + packages/eql/tests/encryption/Cargo.toml | 10 + .../eql/tests/encryption/tests/v4_range.rs | 291 +++++++++++++++++- 3 files changed, 299 insertions(+), 4 deletions(-) diff --git a/packages/eql/Cargo.lock b/packages/eql/Cargo.lock index 993b20bbb..a9e8b9b52 100644 --- a/packages/eql/Cargo.lock +++ b/packages/eql/Cargo.lock @@ -1234,10 +1234,12 @@ name = "eql-encryption-tests" version = "0.1.0" dependencies = [ "base64", + "chrono", "cipherstash-client", "eql-bindings", "eql-domains", "postgres", + "rust_decimal", "serde", "serde_json", "stack-encrypt", diff --git a/packages/eql/tests/encryption/Cargo.toml b/packages/eql/tests/encryption/Cargo.toml index bd5ec44ea..6cd08a28f 100644 --- a/packages/eql/tests/encryption/Cargo.toml +++ b/packages/eql/tests/encryption/Cargo.toml @@ -36,7 +36,17 @@ postgres = "0.19" # protect-ffi resolves cipherstash-client from its own workspace and lockfile, # so the pin covers the version named here, not necessarily the one protect-ffi # ships: a protect-ffi bump is covered once this line moves to match it. +# +# The EQL v4 range tests (tests/v4_range.rs) use it too: EQL compares +# block-ORE and CLLW-OPE terms, which Stack Encrypt does not derive for EQL +# yet, so cipherstash-client's `OreIndexer` / `OpeIndexer` write the order +# terms of their rows and ranges, offline over the same index key. cipherstash-client = "=0.42.0" +# The plaintexts of the date, timestamp and numeric rows in tests/v4_range.rs, +# as cipherstash-client's `Plaintext` names them. Both are already in the +# lockfile through cipherstash-client. +chrono = { version = "0.4", default-features = false } +rust_decimal = "1" # The tampering test decodes a stored body, changes one byte and re-encodes # it. The same line eql-bindings uses, so the lockfile gains no second copy. base64 = "0.22" diff --git a/packages/eql/tests/encryption/tests/v4_range.rs b/packages/eql/tests/encryption/tests/v4_range.rs index 58b206408..6dcf6ebc3 100644 --- a/packages/eql/tests/encryption/tests/v4_range.rs +++ b/packages/eql/tests/encryption/tests/v4_range.rs @@ -385,10 +385,10 @@ fn postgres_between_finds_integers_in_an_inclusive_range() { .collect(); assert!(none.is_empty()); - // A range over the other term has no `between` for the column, so the - // call fails at plan time. PostgreSQL reports it as ambiguous: with no - // exact match it treats both domains as `jsonb`, which every - // `between` overload would take. + // A range over the other term has no `between` for the column. The + // statement fails when it is parsed, with 42725 ("not unique"): + // PostgreSQL resolves a function's domain arguments as their base + // type, `jsonb`, which every `between` overload takes. let err = db .query( "SELECT n FROM v4_int_range WHERE eql_v4.between(ore, $1::text::jsonb::eql_v4.query_integer_range_ope)", @@ -656,3 +656,286 @@ fn postgres_range_domains_check_their_shape() { } } } + +/// Five plaintexts of `family`, strictly ascending, as cipherstash-client's +/// indexers take them: what the smoke test stores and ranges over. A new +/// ordered family must add a row here, or the test fails naming it. +fn ascending(family: &str) -> [Plaintext; 5] { + use chrono::{DateTime, NaiveDate}; + use rust_decimal::Decimal; + let date = |y, m, d| Plaintext::NaiveDate(NaiveDate::from_ymd_opt(y, m, d)); + let at = |secs| Plaintext::Timestamp(DateTime::from_timestamp(secs, 0)); + let dec = |n, scale| Plaintext::Decimal(Some(Decimal::new(n, scale))); + let text = |s: &str| Plaintext::Text(Some(s.to_owned())); + match family { + "integer" => [-20, -1, 0, 7, 100].map(|n| Plaintext::Int(Some(n))), + "smallint" => [-300, -1, 0, 5, 300].map(|n| Plaintext::SmallInt(Some(n))), + "bigint" => [i64::MIN + 1, -1, 0, 1 << 40, i64::MAX].map(|n| Plaintext::BigInt(Some(n))), + "date" => [ + date(1969, 12, 31), + date(1970, 1, 1), + date(2000, 2, 29), + date(2024, 6, 1), + date(2099, 12, 31), + ], + "timestamp" => [-86_400, 0, 951_782_400, 1_717_200_000, 4_102_444_800].map(at), + "numeric" => [ + dec(-15, 1), + dec(0, 0), + dec(25, 2), + dec(314_159, 5), + dec(1000, 0), + ], + "text" => ["apple", "banana", "cherry", "damson", "elderberry"].map(text), + // `real` values are exact in f32; both float widths encrypt as f64. + "real" => [-2.5, -0.5, 0.0, 1.25, 1e10].map(|f| Plaintext::Float(Some(f))), + "double" => [-1e300, -1.0, 0.0, 2.5, 1e300].map(|f| Plaintext::Float(Some(f))), + other => panic!("no smoke-test values for the ordered family {other:?}: add them here"), + } +} + +/// The text a smoke-test value seals as for its `c` (and, for text, its +/// `hm` and `bf`): `between` never reads them, and most ordered families +/// have no producible type to seal the value itself. +fn display(plaintext: &Plaintext) -> String { + match plaintext { + Plaintext::Text(Some(s)) => s.clone(), + other => format!("{other:?}"), + } +} + +/// Needs an EQL v4 installation. A smoke test over every ordered family +/// and every `between` overload, generated from the catalog: five ascending +/// rows per family in each column domain that carries an ordering term, and +/// a range from the second value to the fourth, which must find exactly the +/// rows at both bounds and the one inside, and neither row outside. For text +/// it also checks that an order-term index serves `between`, for ORE on +/// `_ord_ore` and for OPE on `_search`. +#[test] +#[ignore = "requires EQL_TEST_DATABASE_URL pointing to an EQL v4 database"] +fn postgres_between_smoke_test_over_every_ordered_family() { + let runtime = runtime(); + let key = runtime.block_on(index_key()); + let (cipher, _) = runtime.block_on(common::cipher()); + let keyset = cipher.default_keyset(); + let mut db = connect(); + let ranges = v4::all_range_query(); + let mut checked = 0; + + for family in eql_domains::CATALOG + .iter() + .filter(|f| !f.range_terms().is_empty()) + { + let values = ascending(family.name); + let table = format!("v4_smoke_{}", family.name); + let columns: Vec<&eql_domains::Domain> = family + .domains + .iter() + .filter(|d| d.terms.iter().any(|t| t.range_suffix().is_some())) + .collect(); + db.batch_execute(&format!( + "CREATE TEMP TABLE {table} (id int PRIMARY KEY, {})", + columns + .iter() + .map(|d| format!("c_{} public.eql_v4_{}", d.name, d.full_name(family.name))) + .collect::>() + .join(", ") + )) + .unwrap(); + + for (id, value) in values.iter().enumerate() { + let text = display(value); + let (eq, matched) = runtime.block_on(async { + let eq: text::TextEq = keyset.encrypt_as(&text, column()).await.unwrap(); + let matched = match family.name { + "text" => Some( + keyset + .encrypt_as::<_, text::TextMatch>(&text, column()) + .await + .unwrap(), + ), + _ => None, + }; + (eq, matched) + }); + let eq = serde_json::to_value(&eq).unwrap(); + let payloads: Vec = columns + .iter() + .map(|d| { + let mut p = json!({"v": eq["v"], "i": eq["i"], "c": eq["c"]}); + for term in d.terms { + match term { + eql_domains::Term::Hm => p["hm"] = eq["hm"].clone(), + eql_domains::Term::Bloom => { + let m = matched.as_ref().expect("only text has a match term"); + p["bf"] = serde_json::to_value(m).unwrap()["bf"].clone(); + } + eql_domains::Term::Ore => p["ob"] = ob(&key, value.clone(), false), + eql_domains::Term::Ope => p["op"] = op(&key, value.clone()), + } + } + p.to_string() + }) + .collect(); + let placeholders: Vec = columns + .iter() + .enumerate() + .map(|(i, d)| { + format!( + "${}::text::jsonb::public.eql_v4_{}", + i + 2, + d.full_name(family.name) + ) + }) + .collect(); + let id = id as i32; + let mut params: Vec<&(dyn postgres::types::ToSql + Sync)> = vec![&id]; + params.extend( + payloads + .iter() + .map(|p| p as &(dyn postgres::types::ToSql + Sync)), + ); + db.execute( + &format!( + "INSERT INTO {table} VALUES ($1, {})", + placeholders.join(", ") + ), + ¶ms, + ) + .unwrap(); + } + + for term in family.range_terms() { + let bound = |v: &Plaintext| match term { + eql_domains::Term::Ore => ob(&key, v.clone(), true), + eql_domains::Term::Ope => op(&key, v.clone()), + other => unreachable!("{other:?} has no range"), + }; + let domain = family.range_query_name(term); + let range = json!({"v": 4, "i": identifier(), "lo": bound(&values[1]), "hi": bound(&values[3])}); + // The binding for the domain accepts the range. + ranges + .iter() + .find(|r| r.sql_domain() == format!("eql_v4.{domain}")) + .unwrap_or_else(|| panic!("no binding for {domain}")) + .parse_value(&range) + .unwrap(); + for d in family.range_columns(term) { + let found: Vec = db + .query( + &format!( + "SELECT id FROM {table} \ + WHERE eql_v4.between(c_{}, $1::text::jsonb::eql_v4.{domain}) ORDER BY id", + d.name + ), + &[&range.to_string()], + ) + .unwrap() + .iter() + .map(|r| r.get(0)) + .collect(); + assert_eq!( + found, + [1, 2, 3], + "{}: between(_{}, {domain})", + family.name, + d.name + ); + checked += 1; + } + } + + if family.name == "text" { + let range = |term: eql_domains::Term| { + let bound = |v: &Plaintext| match term { + eql_domains::Term::Ore => ob(&key, v.clone(), true), + _ => op(&key, v.clone()), + }; + json!({"v": 4, "i": identifier(), "lo": bound(&values[1]), "hi": bound(&values[3])}) + .to_string() + }; + db.batch_execute(&format!( + "CREATE INDEX {table}_ore ON {table} USING btree (eql_v4.ord_term_ore(c_ord_ore)); + CREATE INDEX {table}_search ON {table} USING btree (eql_v4.ord_term(c_search)); + ANALYZE {table}; + SET enable_seqscan = off;" + )) + .unwrap(); + for (column, domain, index, value) in [ + ( + "c_ord_ore", + "query_text_range_ore", + format!("{table}_ore"), + range(eql_domains::Term::Ore), + ), + ( + "c_search", + "query_text_range_ope", + format!("{table}_search"), + range(eql_domains::Term::Ope), + ), + ] { + let plan = explain( + &mut db, + &format!( + "SELECT id FROM {table} WHERE eql_v4.between({column}, '{}'::jsonb::eql_v4.{domain})", + value.replace('\'', "''") + ), + ); + let mut scans = Vec::new(); + index_scans(&plan, &mut scans); + let (_, cond) = scans + .iter() + .find(|(name, _)| *name == index) + .unwrap_or_else(|| { + panic!("text {column}: no index scan on {index}:\n{plan:#}") + }); + assert!( + cond.contains(">=") && cond.contains("<="), + "text {column}: {cond}" + ); + } + db.batch_execute("RESET enable_seqscan;").unwrap(); + } + } + // 8 numeric and temporal families with 3 overloads each, and text's 5. + assert_eq!(checked, 29, "every between overload was exercised"); +} + +/// Needs an EQL v4 installation. The range parameter must be cast to its +/// exact range domain. An uncast `$1`, a `jsonb` parameter, or a range of +/// another family matches several `between` overloads (PostgreSQL resolves +/// a function's domain arguments as their base type, `jsonb`), so preparing +/// the statement fails with 42725, before any value is bound. The +/// single-bound operators resolve an uncast `$1` on the same column. +#[test] +#[ignore = "requires EQL_TEST_DATABASE_URL pointing to an EQL v4 database"] +fn postgres_between_needs_the_exact_range_domain() { + let mut db = connect(); + db.batch_execute("CREATE TEMP TABLE v4_range_cast (ore public.eql_v4_integer_ord_ore);") + .unwrap(); + for range in [ + "$1", + "$1::jsonb", + "$1::eql_v4.query_bigint_range_ore", + "$1::eql_v4.query_integer_range_ope", + ] { + let err = db + .prepare(&format!( + "SELECT 1 FROM v4_range_cast WHERE eql_v4.between(ore, {range})" + )) + .unwrap_err(); + assert_eq!(sqlstate(&err), "42725", "{range}: {err}"); + assert!( + err.as_db_error() + .is_some_and(|e| e.message().contains("is not unique")), + "{range}: {err}" + ); + } + db.prepare( + "SELECT 1 FROM v4_range_cast WHERE eql_v4.between(ore, $1::eql_v4.query_integer_range_ore)", + ) + .unwrap(); + db.prepare("SELECT 1 FROM v4_range_cast WHERE ore < $1") + .unwrap(); +} From b7a793c4947af7249d03a5c5d921a207959134b2 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 17 Oct 2026 23:24:28 +1100 Subject: [PATCH 4/6] build(eql): validate the v4-only range files in docs:validate:documented-sql The task ran psql over build/render/eql_v3/src only, so the 18 files confined to EQL v4 (the range domains and between) were never validated: they are not rendered into the v3 tree. Run in isolation like the v3 files, each would stop at its first statement with "schema eql_v4 does not exist", which the task counts as an expected dependency error. So it now runs the whole v4 install order in one transaction that is rolled back: every statement of the confined files runs after what it requires, and any error fails the task. It also fails if it finds no confined file, or one missing from the v4 order. The other v4 files are the v3 ones under another schema name and are not run twice. The AGENTS paragraph on confined files says so. --- packages/eql/AGENTS.md | 4 +- .../eql/tasks/docs/validate/documented-sql.sh | 63 ++++++++++++++++++- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/eql/AGENTS.md b/packages/eql/AGENTS.md index 91138de6b..a4477f0df 100644 --- a/packages/eql/AGENTS.md +++ b/packages/eql/AGENTS.md @@ -58,7 +58,7 @@ The SQL source never names its schemas or its payload version literally. It writ #### Files confined to one EQL version -A source file belongs to every version unless it carries the line `-- ONLY EQL VERSION: N` (the payload version, not the schema name, which the source may not write). Today only the generated range surface does: `src/v3/scalars//query__range_types.sql` and `query__range_functions.sql` hold the range query domains (`eql_v4.query__range_ore` / `_range_ope`), their `lo` / `hi` accessors and `eql_v4.between(column, range)` (ADR-0009 decision 7), and are `-- ONLY EQL VERSION: 4`. EQL v3 is what cipherstash-client writes, nothing there produces a range, and its SQL must not change. `tasks/build.sh` runs the install order through `eql-codegen select <3|4>` before rendering each version, `eql-codegen render` refuses to render a confined file as the other version, `verify_installer_complete.sh` filters its disk side the same way with its own `grep`, the symbol-order checker runs on the v4 order too (on a copy mapped back to `eql_v3` names), and the Doxygen filter reads a confined file from its own version's rendered tree. `eql-codegen order` still orders every file, so `src/deps-ordered-v3.txt` lists the v4-only files; the per-version lists are `build/render/eql_v/deps-ordered.txt`. +A source file belongs to every version unless it carries the line `-- ONLY EQL VERSION: N` (the payload version, not the schema name, which the source may not write). Today only the generated range surface does: `src/v3/scalars//query__range_types.sql` and `query__range_functions.sql` hold the range query domains (`eql_v4.query__range_ore` / `_range_ope`), their `lo` / `hi` accessors and `eql_v4.between(column, range)` (ADR-0009 decision 7), and are `-- ONLY EQL VERSION: 4`. EQL v3 is what cipherstash-client writes, nothing there produces a range, and its SQL must not change. `tasks/build.sh` runs the install order through `eql-codegen select <3|4>` before rendering each version, `eql-codegen render` refuses to render a confined file as the other version, `verify_installer_complete.sh` filters its disk side the same way with its own `grep`, the symbol-order checker runs on the v4 order too (on a copy mapped back to `eql_v3` names), the Doxygen filter reads a confined file from its own version's rendered tree, and `docs:validate:documented-sql` runs the whole v4 install order (`build/render/eql_v4/deps-ordered.txt`) in one rolled-back transaction, so every statement of the confined files runs after what it requires, besides running each file of the v3 render on its own. `eql-codegen order` still orders every file, so `src/deps-ordered-v3.txt` lists the v4-only files; the per-version lists are `build/render/eql_v/deps-ordered.txt`. Anything that needs the source as real SQL reads the rendered tree, not `src/`: `docs:validate:documented-sql` runs psql over `build/render/eql_v3/src`, and the Doxygen input filter (`tasks/docs/doxygen-filter.sh`) reads each file's rendered copy from `build/render/eql_v3/`. A new tool that parses `src/v3` must do the same, and depend on `build`; do not substitute the placeholders a second way. @@ -92,7 +92,7 @@ This is the **Encrypt Query Language (EQL)** - a PostgreSQL extension for search Adding a scalar encrypted-domain type is one row in the Rust catalog `eql-domains::CATALOG` (`crates/eql-domains/src/lib.rs`): a `DomainFamily` giving the type `name` (e.g. `bigint`), its `ScalarKind` (the `kind` field), the `Domain`s mapping each generated (bare) domain name to its fixed index `Term`s (`eq => [Hm]`, `ord`/`ord_ore => [Ore]`), and the `Fixture` value list. Term capabilities are fixed in the `Term` enum's `impl` methods (with unit tests): `Hm` provides equality, and `Ore` provides equality plus ordering. There is no TOML manifest and no Python — the catalog is the source of truth, validated by the compiler (an undefined term or unknown scalar is a compile error) plus catalog `#[test]`s. `mise run build` runs `cargo run -p eql-codegen`, which regenerates the scalar SQL surface into `src/v3/scalars//` from `CATALOG` at the start of every build; that surface includes supported comparison wrappers plus blockers for native `json` operators that would otherwise be reachable through domain fallback. `cargo run -p eql-codegen` regenerates every type at once (the same call `mise run build` uses; there is no per-type codegen task). The generated `*_types.sql` / `*_functions.sql` / `*_operators.sql` / `*_aggregates.sql` files are **committed in place** under `src/v3/scalars//` and drift-gated by `mise run codegen:parity` (regenerate in place + `git diff` + untracked check — the same regenerate-and-diff pattern `types:check` uses for the committed bindings). They are still machine-generated: change the catalog and rebuild, never hand-edit (CI fails on drift). The per-type plaintext fixture lists the SQLx matrix consumes are **not** a generated file — they are materialised from each `CATALOG` row at compile time as `eql_domains::INT4_VALUES` / `INT2_VALUES` (the `int_values!` macro) and read directly by `ScalarType::FIXTURE_VALUES`; a Rust source of truth no longer round-trips through a committed generated `.rs`. Generated SQL carries a `-- AUTOMATICALLY GENERATED FILE` header (the project-wide marker `docs:validate` greps on); change the catalog and rebuild, never hand-edit. Hand-written SQL beyond the fixed surface goes in `src/v3/scalars//_extensions.sql` with no auto-generated header and explicit `-- REQUIRE:` edges — that file IS committed. `json`'s **SteVec** domains never enter this pipeline, but its **scalar storage domain does**: `generate_all` iterates `eql_domains::families_with_scalar_domains()` and renders each family's scalar domains (so `public.eql_v3_json` is generated into `src/v3/scalars/json/`), while `list-types` and the SQLx matrix still iterate `eql_domains::scalar_families()`, which excludes the whole non-scalar `json` family — so `json` stays out of the operator/coverage matrix by construction (a storage-only domain carries no operator matrix), even though its ciphertext-only storage domain now has a generated SQL surface. -**EQL v4 bindings:** the generator writes the scalar domains a second time into `crates/eql-bindings/src/v4/` (`eql_v4_*` names, `v: 4` through `SchemaVersion<4>`, an `inventory.rs`), plus the Stack Encrypt target table `v4/targets.rs`. **Only v4 structs derive Stack Encrypt's `EncryptFrom`/`DecryptInto`**: Stack Encrypt's output is not byte-for-byte compatible with cipherstash-client's, so `encryption_gap` refuses every v3 domain and a codegen test asserts no v3 struct derives either. v4 also has one range query struct per ordered family and order term (`IntegerRangeOreQuery`, `TextRangeOpeQuery`, …: `{v, i, lo, hi}`, each bound the single-bound query twin's term newtype), listed by `v4::all_range_query()`; v3 has none, matching the SQL. v4 has no SteVec bindings, payload enums, TypeScript export or published JSON Schema yet. The hand-written `v4/mod.rs` re-exports v3's term newtypes and `DomainType`, so a change to a term's validation in `v3/terms.rs` changes v4 too; the version boundary is the envelope's `SchemaVersion` (one generic type in `lib.rs`; `v3::SchemaVersion` and `v4::SchemaVersion` are `SchemaVersion<3>` and `SchemaVersion<4>`). +**EQL v4 bindings:** the generator writes the scalar domains a second time into `crates/eql-bindings/src/v4/` (`eql_v4_*` names, `v: 4` through `SchemaVersion<4>`, an `inventory.rs`), plus the Stack Encrypt target table `v4/targets.rs`. **Only v4 structs derive Stack Encrypt's `EncryptFrom`/`DecryptInto`**: Stack Encrypt's output is not byte-for-byte compatible with cipherstash-client's, so `encryption_gap` refuses every v3 domain and a codegen test asserts no v3 struct derives either. v4 also has one range query struct per ordered family and order term (`IntegerRangeOreQuery`, `TextRangeOpeQuery`, …: each bound the single-bound domain's `ob` or `op` value, typed as that twin's term newtype, in a `{v, i, lo, hi}` envelope that Stack Encrypt will need to build once the ordered types are producible), listed by `v4::all_range_query()`; v3 has none, matching the SQL. v4 has no SteVec bindings, payload enums, TypeScript export or published JSON Schema yet. The hand-written `v4/mod.rs` re-exports v3's term newtypes and `DomainType`, so a change to a term's validation in `v3/terms.rs` changes v4 too; the version boundary is the envelope's `SchemaVersion` (one generic type in `lib.rs`; `v3::SchemaVersion` and `v4::SchemaVersion` are `SchemaVersion<3>` and `SchemaVersion<4>`). The same generator also emits the **Rust payload bindings** under `crates/eql-bindings/src/v3/.rs` (structs + `DomainType` impls) and the `inventory.rs` `all()` list, from the same `CATALOG` — committed with a `// @generated` header (the bindings must exist on a clean clone because `ts-rs`/`schemars` derive the committed TypeScript/JSON Schema off them; the SQL surface is now committed too, so both generated targets are handled consistently — committed in place, drift-gated by regenerate-and-`git diff`). The hand-written `DomainType` trait, the shared newtypes (`SchemaVersion`/`Identifier`/`Ciphertext`/`Hmac256`/`OreBlock256`/`BloomFilter`), the `PhantomData` plumbing, and the architectural module doc (including the non-derivable float-NaN and bool storage-only caveats) stay hand-written in `crates/eql-bindings/src/v3/{mod,domain_type,terms}.rs`. Generated structs carry a catalog-derived struct doc — a summary line (`` `eql_v3.` — . ``) plus a detail line listing the supported operators and required payload keys, all derived from data the catalog already holds (the capability label, `Term::operators_for_terms`, and `ENVELOPE_KEYS` ++ `Term::term_json_keys` — see `struct_doc_lines` in `crates/eql-codegen/src/bindings.rs`). The required-key list makes structural distinctions visible — e.g. `text_ord` lists `` `v` `i` `c` `hm` `ob` `` (dual-term) versus an integer `integer_ord`'s `` `v` `i` `c` `ob` ``. There are **no per-field docs**: per-field/term semantics live on the shared term newtypes (`terms.rs`, flowing into the TS term files and JSON Schema `$defs`), and non-derivable per-family caveats (float-NaN, bool storage-only) in `mod.rs`. Free-form prose belongs at the **struct level** (a future optional catalog `doc` field emitted as extra `#[doc]` lines), never as per-field docs. JSON Schemas are emitted by **schemars 1.x** as JSON Schema 2020-12. `mise run types:generate` regenerates the Rust bindings (via `eql-codegen bindings`) then the TS/JSON; `mise run types:check` is the committed-reference drift gate — it regenerates and `git diff`s the Rust bindings (`crates/eql-bindings/src/v3` and `src/v4`), `bindings/` and `schema/`, the same regenerate-and-diff pattern `codegen:parity` now uses for the committed SQL surface. Both gates run in CI; `mise run install-hooks` wires a pre-commit hook that runs both locally whenever a commit touches the catalog, the generator, or a committed generated surface. The `json` family's **SteVec** structs (`SteVecDocument`, `SteVecEntry`, `SteVecQuery`, and `SteVecQueryEntry`) are hand-written for the same reason as their SQL — in `crates/eql-bindings/src/v3/json.rs`, symmetric with `src/v3/json/`. Their direct optional fields and `deny_unknown_fields` validation keep the Rust, TypeScript, JSON Schema, and PostgreSQL entry contracts aligned. The family's scalar storage struct **is** generated (a flat `{v,i,c}` struct, `Json`), via `render_scalar_only_bindings(&JSON)` into `crates/eql-bindings/src/v3/json_storage.rs`; the hand-written `json.rs` re-exports it (`pub use crate::v3::json_storage::Json;`) so `super::json::Json` resolves for the generated inventory/payload. Only inventory membership and ordering are catalog-driven: `inventory.rs::all()` iterates the full `CATALOG` (not `scalar_families()`), branching on `Shape` to include the three SteVec structs plus the generated `Json` alongside the other scalar structs. diff --git a/packages/eql/tasks/docs/validate/documented-sql.sh b/packages/eql/tasks/docs/validate/documented-sql.sh index 60d954768..59db73422 100755 --- a/packages/eql/tasks/docs/validate/documented-sql.sh +++ b/packages/eql/tasks/docs/validate/documented-sql.sh @@ -14,6 +14,20 @@ PGDATABASE=${PGDATABASE:-postgres} # version as {{prefix}} / {{eql_version}} placeholders (ADR-0002), which psql # cannot parse. `mise run build` renders it. source_directory="build/render/eql_v3/src" +# Files confined to EQL v4 (`-- ONLY EQL VERSION: 4`: the range query domains +# and eql_v4.between) are rendered only into the eql_v4 tree, so they are read +# from there: every file of the v4 tree with no counterpart in the v3 tree. The +# other v4 files are the v3 ones under another name, already validated above. +# +# They are not run in isolation like the v3 files. Every statement in them +# needs the eql_v4 schema and the domains they range over, so in isolation +# their first statement fails with "does not exist", which the loop below +# counts as a dependency error, and nothing after it is ever parsed. Instead +# the whole v4 install order runs in one transaction that is rolled back, +# so each confined file runs after what it requires and every statement in +# it is executed, and any error fails the task. +v4_directory="build/render/eql_v4/src" +v4_order="build/render/eql_v4/deps-ordered.txt" echo "Validating SQL syntax for all documented files..." echo "" @@ -28,8 +42,21 @@ echo "" errors=0 validated=0 -if [ ! -d $source_directory ]; then - echo "error: source directory does not exist: ${source_directory}" +for dir in "$source_directory" "$v4_directory"; do + if [ ! -d "$dir" ]; then + echo "error: source directory does not exist: ${dir}" + exit 2 + fi +done + +v4_only=() +while IFS= read -r file; do + if [ ! -f "$source_directory/${file#"$v4_directory"/}" ]; then + v4_only+=("$file") + fi +done < <(find "$v4_directory" -name "*.sql" -not -name "*_test.sql" | sort) +if [ ${#v4_only[@]} -eq 0 ]; then + echo "error: found no file confined to EQL v4 under ${v4_directory}; expected the range surface" exit 2 fi @@ -60,6 +87,38 @@ for file in $(find $source_directory -name "*.sql" -not -name "*_test.sql" | sor exit_code=0 done +echo "" +echo "Validating the ${#v4_only[@]} files confined to EQL v4, in the v4 install order..." +if [ ! -f "$v4_order" ]; then + echo "error: the v4 install order does not exist: ${v4_order}" + exit 2 +fi +for file in "${v4_only[@]}"; do + if ! grep -qxF "$file" "$v4_order"; then + echo "error: ${file} is not in the v4 install order ${v4_order}" + exit 2 + fi +done +v4_script=$(mktemp) +trap 'rm -f "$v4_script"' EXIT +{ + echo 'BEGIN;' + while IFS= read -r f; do + printf '\\i %s\n' "$f" + done < "$v4_order" + echo 'ROLLBACK;' +} > "$v4_script" +if v4_output=$(PGPASSWORD="$PGPASSWORD" psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" \ + -f "$v4_script" --set ON_ERROR_STOP=1 -q 2>&1); then + echo "✓ the v4 install order, rolled back" + validated=$((validated + ${#v4_only[@]})) +else + echo "✗ the v4 install order failed" + echo "$v4_output" | grep -v '^NOTICE' | tail -10 | sed 's/^/ /' + echo "" + errors=$((errors + ${#v4_only[@]})) +fi + echo "" echo "Validation complete:" echo " Validated: $validated" From f3539d64e78f1b3b58f6cc7481538afa0caa8070 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 19 Oct 2026 17:27:57 +1100 Subject: [PATCH 5/6] fix(eql): disable the ORE range domains where the ORE opclass is absent A non-superuser install skips the ore_block_256 btree operator class, and ore_fallback.sql then poisons every single-bound ORE domain so a value cast to one fails loudly with an OPE hint. It could not reach the nine query__range_ore domains: it is shared with EQL v3 and they are v4-only. So on such an install a range operand was accepted, and eql_v4.between ran as an unindexed seq scan with no hint. Each v4-only range types file now adds the same NOT VALID poison CHECK to its own ORE range, behind the same opclass check, calling the function ore_fallback.sql creates, and names the family's OPE range in the hint. A new PostgreSQL test installs the built v4 bundle as a NOSUPERUSER role in a database of its own and checks every ORE range is refused with that hint while its OPE range is accepted. Stripping the new ALTER DOMAINs from the bundle makes it fail. Also: the generated ORE between note now lists the family's own OPE column domains and OPE range instead of a fixed `_ord`, `_ord_ope`, `_search` (only text has `_search`), and the inverted-range wording no longer says the client refuses such a range, which #1235 makes untrue; it says what holds either way: such a range matches no row and the SQL does not reorder it. --- .changeset/eql-v4-bindings.md | 2 +- packages/eql/crates/eql-codegen/src/range.rs | 148 +++++++++++++++++- .../templates/range_functions.sql.j2 | 5 +- .../eql-codegen/templates/range_types.sql.j2 | 42 ++++- packages/eql/docs/reference/eql-functions.md | 6 +- .../bigint/query_bigint_range_functions.sql | 5 +- .../bigint/query_bigint_range_types.sql | 35 ++++- .../date/query_date_range_functions.sql | 5 +- .../scalars/date/query_date_range_types.sql | 35 ++++- .../double/query_double_range_functions.sql | 5 +- .../double/query_double_range_types.sql | 35 ++++- .../integer/query_integer_range_functions.sql | 5 +- .../integer/query_integer_range_types.sql | 35 ++++- .../numeric/query_numeric_range_functions.sql | 5 +- .../numeric/query_numeric_range_types.sql | 35 ++++- .../real/query_real_range_functions.sql | 5 +- .../scalars/real/query_real_range_types.sql | 35 ++++- .../query_smallint_range_functions.sql | 5 +- .../smallint/query_smallint_range_types.sql | 35 ++++- .../text/query_text_range_functions.sql | 10 +- .../scalars/text/query_text_range_types.sql | 35 ++++- .../query_timestamp_range_functions.sql | 5 +- .../timestamp/query_timestamp_range_types.sql | 35 ++++- .../eql/tests/encryption/tests/v4_range.rs | 110 ++++++++++++- 24 files changed, 615 insertions(+), 63 deletions(-) diff --git a/.changeset/eql-v4-bindings.md b/.changeset/eql-v4-bindings.md index a671930ad..7f0d61385 100644 --- a/.changeset/eql-v4-bindings.md +++ b/.changeset/eql-v4-bindings.md @@ -4,6 +4,6 @@ **The `eql-bindings` crate has an `eql_bindings::v4` module, with the `stack-encrypt` feature: payload types for the EQL v4 domains.** EQL v4 is the v3 SQL installed under a second name (ADR-0002), for values written by Stack Encrypt, so the module exists only with that feature and its types derive Stack Encrypt's traits without a further gate. The module is generated from the same catalog as `v3`, with `eql_v4_*` domain names and `"v": 4`. `SchemaVersion` is now generic over the version it admits: `v3::SchemaVersion` is `SchemaVersion<3>` and `v4::SchemaVersion` is `SchemaVersion<4>`, each refusing the other's payloads, and plain `SchemaVersion` still means v3. Only v4 types derive Stack Encrypt's `EncryptFrom` and `DecryptInto`, and the Stack Encrypt target table (now `eql_bindings::v4::targets`) names v4 domains. Besides `TextEq`, Stack Encrypt now produces the storage-only types `Text`, `Integer`, `Bigint`, `Boolean`, `Real` and `Double`, and `TextMatch` (3-grams, `k = 6`, a 2048-bit filter, pinned as `eql_bindings::encryption::EqlMatch`; text shorter than three characters is refused, stored or queried, and only `stored @@ query` matches until #1171 is fixed), from the Rust primitives `i32`, `i64`, `bool`, `f32` and `f64`; the Go SDK's `encrypt/eql` table marks them producible. Stack Encrypt also produces the number equality types `IntegerEq`, `BigintEq`, `RealEq` and `DoubleEq`, whose `=` compares as PostgreSQL compares the plain types: an integer has one term whatever its width, and for floats `-0.0 = 0.0` and `NaN = NaN` (ADR-0002's semantic baseline). Every type it cannot produce yet names the issue that tracks its blocker. The by-name target entry points (`eql_bindings::encryption::targets::encrypt`, `decrypt` and `query`) exchange the plaintext as Stack Encrypt's `dynamic::ValueFrame`, one value as its transport frame, rather than a value tree. They, and the generated `v4::targets` table, need the new `targets` feature, which turns on `stack-encrypt` and Stack Encrypt's `dynamic` feature. `stack-encrypt` alone no longer builds Stack Encrypt's `dynamic` layer, so a Rust program using the typed v4 types through `encrypt_as` doesn't compile it. -**The EQL v4 SQL bundle has range query domains and `eql_v4.between`, so a `BETWEEN` query is one operand.** The v4 bundle (`cipherstash-encrypt-v4.sql`) is built from this source and reaches users as the `eql-v4-install.sql` attached to each Go release; neither the npm package nor the crate ships it, and the v3 SQL they ship does not change. Every ordered family has one range query domain per order term, beside its single-bound ones: `eql_v4.query_integer_range_ore` beside `eql_v4.query_integer_ord_ore`, `eql_v4.query_integer_range_ope` beside `eql_v4.query_integer_ord_ope`, and the same for `smallint`, `bigint`, `date`, `timestamp`, `numeric`, `text`, `real` and `double`. A range is `{"v": 4, "i": …, "lo": , "hi": }`: each bound is the single-bound domain's `ob` (ORE) or `op` (OPE) value, in a `{v, i, lo, hi}` envelope. Stack Encrypt does not build that envelope yet; it will need to when the ordered types become producible. `eql_v4.between(column, range)` is true when the column's order term lies between the bounds, both included, and has an overload for every ordered column domain with the range of the term it stores (`_ord_ore` and `_search_ore` take the ORE range; `_ord`, `_ord_ope` and `_search` the OPE one). It is an inlinable SQL function, so a btree on the column's order term (`eql_v4.ord_term_ore(col)` or `eql_v4.ord_term(col)`) serves it as one index range scan. Write it schema-qualified (`BETWEEN` is an SQL keyword, so a bare `between(...)` does not parse), and cast the range to its exact domain (`$1::eql_v4.query_integer_range_ore`): an uncast `$1`, a `jsonb` parameter, or a range of another term or family fails with SQLSTATE 42725 ("function … is not unique"), unlike the single-bound operators, which resolve an untyped `$1`. In the crate, `eql_bindings::v4` (`stack-encrypt` feature) has a type for each range domain (`IntegerRangeOreQuery`, `TextRangeOpeQuery`, …, listed by `v4::all_range_query()`). EQL v3 has no range domains: its SQL and types are unchanged (ADR-0009, decision 7). +**The EQL v4 SQL bundle has range query domains and `eql_v4.between`, so a `BETWEEN` query is one operand.** The v4 bundle (`cipherstash-encrypt-v4.sql`) is built from this source and reaches users as the `eql-v4-install.sql` attached to each Go release; neither the npm package nor the crate ships it, and the v3 SQL they ship does not change. Every ordered family has one range query domain per order term, beside its single-bound ones: `eql_v4.query_integer_range_ore` beside `eql_v4.query_integer_ord_ore`, `eql_v4.query_integer_range_ope` beside `eql_v4.query_integer_ord_ope`, and the same for `smallint`, `bigint`, `date`, `timestamp`, `numeric`, `text`, `real` and `double`. A range is `{"v": 4, "i": …, "lo": , "hi": }`: each bound is the single-bound domain's `ob` (ORE) or `op` (OPE) value, in a `{v, i, lo, hi}` envelope. Stack Encrypt does not build that envelope yet; it will need to when the ordered types become producible. `eql_v4.between(column, range)` is true when the column's order term lies between the bounds, both included, and has an overload for every ordered column domain with the range of the term it stores (`_ord_ore` and `_search_ore` take the ORE range; `_ord`, `_ord_ope` and `_search` the OPE one). It is an inlinable SQL function, so a btree on the column's order term (`eql_v4.ord_term_ore(col)` or `eql_v4.ord_term(col)`) serves it as one index range scan. Write it schema-qualified (`BETWEEN` is an SQL keyword, so a bare `between(...)` does not parse), and cast the range to its exact domain (`$1::eql_v4.query_integer_range_ore`): an uncast `$1`, a `jsonb` parameter, or a range of another term or family fails with SQLSTATE 42725 ("function … is not unique"), unlike the single-bound operators, which resolve an untyped `$1`. An ORE range scan needs the `ore_block_256` btree operator class, which the installer creates only as a superuser; on an install without it (Supabase and other non-superuser roles), the ORE range domains are disabled like the single-bound ORE domains, and a value cast to one raises `feature_not_supported` naming the family's OPE range. In the crate, `eql_bindings::v4` (`stack-encrypt` feature) has a type for each range domain (`IntegerRangeOreQuery`, `TextRangeOpeQuery`, …, listed by `v4::all_range_query()`). EQL v3 has no range domains: its SQL and types are unchanged (ADR-0009, decision 7). Nothing a released version shipped has changed. The v3 types, the v3 SQL and the TypeScript package are as they were; the `stack-encrypt` derives on the v3 text types and the `v3::targets` module were added after the last release and are moved to v4 here, not removed from a release. diff --git a/packages/eql/crates/eql-codegen/src/range.rs b/packages/eql/crates/eql-codegen/src/range.rs index 9cc0eec79..14a67e809 100644 --- a/packages/eql/crates/eql-codegen/src/range.rs +++ b/packages/eql/crates/eql-codegen/src/range.rs @@ -31,7 +31,7 @@ use eql_domains::{DomainFamily, Term, RANGE_BOUND_KEYS}; use crate::consts::{sql_str, INTERNAL_SCHEMA}; -use crate::context::{domain_name, environment}; +use crate::context::{domain_name, environment, query_domain_name}; use crate::render::{EqlVersion, ONLY_DIRECTIVE}; /// The version the range surface belongs to. @@ -94,23 +94,59 @@ struct RangeDomainBlock { comment: String, } +/// An ORE range domain the file disables when the install has no ORE +/// operator class, as `ore_fallback.sql` disables the single-bound ORE +/// domains: `name` for the identifier position, `name_literal` sql_str-escaped +/// for the poison error's message, `alternatives` for its hint. +#[derive(serde::Serialize)] +struct OreRangeFallback { + name: String, + name_literal: String, + alternatives: String, +} + #[derive(serde::Serialize)] struct RangeTypesContext { only_directive: String, family_name: String, + requires: Vec, domains: Vec, + ore_ranges: Vec, +} + +/// The file whose poison function an ORE range's fallback calls. +const ORE_FALLBACK: &str = "src/v3/scalars/ore_fallback.sql"; + +/// The poison hint for an ORE range: the family's OPE range, which needs no +/// operator class. Every family with an ORE range has one today; a future +/// family without must still render a sentence. +fn ore_range_alternatives(spec: &DomainFamily) -> String { + if spec.range_terms().contains(&Term::Ope) { + format!( + "{} (range)", + query_domain_name(&spec.range_query_name(Term::Ope)) + ) + } else { + "a non-ORE encrypted domain".to_string() + } } /// Body of `query__range_types.sql`, or `None` for a family with no /// ordering term. +/// +/// An ORE range domain is disabled on an install without the ORE operator +/// class, as the single-bound ORE domains are. `ore_fallback.sql` cannot do +/// it, since it is shared with EQL v3 and the range domains are v4 only, so +/// this file adds the same NOT VALID poison CHECK to its own ORE range, +/// calling the function `ore_fallback.sql` creates under the same condition. pub fn render_range_types_file(spec: &DomainFamily) -> Option { let terms = spec.range_terms(); if terms.is_empty() { return None; } let domains = terms - .into_iter() - .map(|term| { + .iter() + .map(|&term| { let name = spec.range_query_name(term); RangeDomainBlock { typname: sql_str(&name), @@ -129,10 +165,28 @@ pub fn render_range_types_file(spec: &DomainFamily) -> Option { } }) .collect(); + let ore_ranges: Vec = terms + .iter() + .filter(|&&t| t == Term::Ore) + .map(|&t| { + let name = query_domain_name(&spec.range_query_name(t)); + OreRangeFallback { + name_literal: sql_str(&name), + name, + alternatives: sql_str(&ore_range_alternatives(spec)), + } + }) + .collect(); + let mut requires = vec!["src/v3/schema.sql".to_string()]; + if !ore_ranges.is_empty() { + requires.push(ORE_FALLBACK.to_string()); + } let ctx = RangeTypesContext { only_directive: only_directive(), family_name: spec.name.to_string(), + requires, domains, + ore_ranges, }; Some( environment() @@ -161,6 +215,10 @@ struct Between { /// An ORE range, whose index needs the opclass a non-superuser /// install skips: the generated docs say so. ore: bool, + /// For an ORE range, the family's OPE column domains and OPE range, + /// which the generated docs point to instead. Empty otherwise. + ope_columns: String, + ope_range: String, } #[derive(serde::Serialize)] @@ -196,6 +254,17 @@ pub fn render_range_functions_file(spec: &DomainFamily) -> Option { ]; let mut accessors = Vec::new(); let mut betweens = Vec::new(); + let ope_columns = spec + .range_columns(Term::Ope) + .iter() + .map(|d| format!("`{}`", domain_name(&d.full_name(family_name)))) + .collect::>() + .join(", "); + let ope_range = if terms.contains(&Term::Ope) { + format!("`{}`", query_domain_name(&spec.range_query_name(Term::Ope))) + } else { + String::new() + }; for &term in &terms { let range = spec.range_query_name(term); let returns = format!("{INTERNAL_SCHEMA}.{}", term.ctor()); @@ -228,6 +297,16 @@ pub fn render_range_functions_file(spec: &DomainFamily) -> Option { range: range.clone(), extractor: term.extractor(), ore: term == Term::Ore, + ope_columns: if term == Term::Ore { + ope_columns.clone() + } else { + String::new() + }, + ope_range: if term == Term::Ore { + ope_range.clone() + } else { + String::new() + }, }); } } @@ -445,6 +524,69 @@ mod tests { } } + /// The ORE note names the family's own OPE columns and OPE range, not a + /// fixed list: only text has a `_search` domain. + #[test] + fn the_ore_note_names_the_familys_own_ope_columns() { + let integer = render_range_functions_file(integer()).unwrap(); + assert!( + integer.contains( + "use an OPE column (`public.{{prefix}}_integer_ord`, \ + `public.{{prefix}}_integer_ord_ope`)\n--! and `{{prefix}}.query_integer_range_ope`" + ), + "{integer}" + ); + assert!(!integer.contains("_search"), "{integer}"); + let text = render_range_functions_file(&TEXT).unwrap(); + assert!(text.contains("`public.{{prefix}}_text_search`"), "{text}"); + } + + /// An ORE range is disabled where the single-bound ORE domains are: the + /// file requires `ore_fallback.sql`, whose poison function it calls, and + /// adds the poison CHECK to the ORE range alone, behind the same opclass + /// check, with the OPE range as the hint. + #[test] + fn the_ore_range_is_disabled_without_the_ore_opclass() { + for f in CATALOG.iter().filter(|f| !f.range_terms().is_empty()) { + let body = render_range_types_file(f).unwrap(); + let ore = f.range_terms().contains(&Term::Ore); + assert_eq!( + body.contains("-- REQUIRE: src/v3/scalars/ore_fallback.sql"), + ore, + "{}", + f.name + ); + let v4 = render_str(&body, EqlVersion::V4).unwrap(); + let alters: Vec<&str> = v4 + .lines() + .filter(|l| l.contains("ADD CONSTRAINT eql_ore_unavailable")) + .collect(); + if !ore { + assert!(alters.is_empty(), "{}", f.name); + continue; + } + let name = format!("eql_v4.query_{}_range_ore", f.name); + assert_eq!( + alters, + [format!( + " ALTER DOMAIN {name} ADD CONSTRAINT eql_ore_unavailable" + )], + "{}", + f.name + ); + assert!( + v4.contains(&format!( + "CHECK (eql_v4_internal.ore_domain_unavailable(VALUE, '{name}', \ + 'eql_v4.query_{}_range_ope (range)')) NOT VALID;", + f.name + )), + "{v4}" + ); + assert!(v4 + .contains("AND c.opcintype = 'eql_v4_internal.ore_block_256'::pg_catalog.regtype")); + } + } + #[test] fn every_function_is_documented() { for f in CATALOG.iter().filter(|f| !f.range_terms().is_empty()) { diff --git a/packages/eql/crates/eql-codegen/templates/range_functions.sql.j2 b/packages/eql/crates/eql-codegen/templates/range_functions.sql.j2 index c4cd5c8bc..a0fa8a0ac 100644 --- a/packages/eql/crates/eql-codegen/templates/range_functions.sql.j2 +++ b/packages/eql/crates/eql-codegen/templates/range_functions.sql.j2 @@ -43,8 +43,9 @@ AS $$ SELECT {{ a.body }} $$; --! serves as one range scan.{% if b.ore %} For this ORE range that index needs the --! default btree operator class on {{ internal_schema }}.ore_block_256, which the --! installer creates only with superuser privileges. Without it (on Supabase, ---! for instance) the installer disables the ORE column domains, so use an OPE ---! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere.{% endif %} +--! for instance) the installer disables the ORE column domains and this ORE range, +--! so use an OPE column ({{ b.ope_columns }}) +--! and {{ b.ope_range }}, which index everywhere.{% endif %} --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{ schema }}.between(...)` parses, a bare `between(...)` does not. --! @note Cast the range to its exact domain, `$1::{{ schema }}.{{ b.range }}`. diff --git a/packages/eql/crates/eql-codegen/templates/range_types.sql.j2 b/packages/eql/crates/eql-codegen/templates/range_types.sql.j2 index 060205abf..4aea035e9 100644 --- a/packages/eql/crates/eql-codegen/templates/range_types.sql.j2 +++ b/packages/eql/crates/eql-codegen/templates/range_types.sql.j2 @@ -1,7 +1,8 @@ -- AUTOMATICALLY GENERATED FILE. {{ only_directive }} --- REQUIRE: src/v3/schema.sql - +{% for r in requires -%} +-- REQUIRE: {{ r }} +{% endfor %} --! @file v3/scalars/{{ family_name }}/query_{{ family_name }}_range_types.sql --! @brief Range query-operand domains for {{ family_name }}: the operand of `{{ schema }}.between`. --! @note A range holds both bounds of one `BETWEEN` as one value: @@ -17,9 +18,14 @@ --! @note EQL version {{ eql_version }} only: the `-- ONLY EQL VERSION:` line above keeps this --! file out of every other version's installer. --! @note Like every query operand, a range lives in `{{ schema }}`, not `public`, ---! and holds no ciphertext `c`. The client refuses a range whose low ---! bound is above its high bound; one that reaches the database anyway ---! matches no row. +--! and holds no ciphertext `c`. A range whose low bound is above its +--! high bound matches no row, as with SQL `BETWEEN`; the SQL does not +--! reorder the bounds.{% if ore_ranges %} +--! @note On an install without the ORE operator class (a non-superuser +--! install, on Supabase for instance), the ORE range domain is disabled +--! as the single-bound ORE domains are: a value cast to it raises +--! `feature_not_supported`, naming the OPE range. See +--! `v3/scalars/ore_fallback.sql`.{% endif %} DO $$ BEGIN @@ -47,3 +53,29 @@ BEGIN {% endfor -%} END $$; +{%- if ore_ranges %} + +-- Without the default btree operator class on {{ internal_schema }}.ore_block_256 +-- (the installer skips it on insufficient_privilege), ore_fallback.sql has +-- created {{ internal_schema }}.ore_domain_unavailable and poisoned the +-- single-bound ORE domains. It cannot reach this ORE range, which exists in +-- EQL v4 only, so the same NOT VALID poison CHECK is added here. +DO $do$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_opclass c + JOIN pg_catalog.pg_am am ON am.oid = c.opcmethod + WHERE am.amname = 'btree' + AND c.opcdefault + AND c.opcintype = '{{ internal_schema }}.ore_block_256'::pg_catalog.regtype + ) THEN + RETURN; + END IF; +{% for e in ore_ranges %} + ALTER DOMAIN {{ e.name }} ADD CONSTRAINT eql_ore_unavailable + CHECK ({{ internal_schema }}.ore_domain_unavailable(VALUE, '{{ e.name_literal }}', '{{ e.alternatives }}')) NOT VALID; +{%- endfor %} +END; +$do$; +{%- endif %} diff --git a/packages/eql/docs/reference/eql-functions.md b/packages/eql/docs/reference/eql-functions.md index 853bdb0b1..d65245783 100644 --- a/packages/eql/docs/reference/eql-functions.md +++ b/packages/eql/docs/reference/eql-functions.md @@ -163,11 +163,11 @@ CREATE INDEX ON users USING btree (eql_v4.ord_term(age)); SELECT * FROM users WHERE eql_v4.between(age, $1::eql_v4.query_integer_range_ope); ``` -- `eql_v4.between` is an inlinable SQL function, so the planner sees `ord_term(age) >= lo AND ord_term(age) <= hi`, and a btree on the column's order term serves it as one range scan: `eql_v4.ord_term(col)` for an OPE column (`_ord`, `_ord_ope`, `_search`), `eql_v4.ord_term_ore(col)` for an ORE one (`_ord_ore`, `_search_ore`). -- **An ORE column needs the `eql_v4_internal.ore_block_256` btree operator class**, for its index and so for an ORE range scan. The installer creates that class only when it runs with superuser privileges. Otherwise (on Supabase and other managed roles) it skips it and disables the ORE column domains: a value cast to `_ord_ore` or `_search_ore` raises `feature_not_supported`, naming the OPE and equality alternatives. On such an install, use an OPE column and its `_range_ope` domain, which need nothing more. +- `eql_v4.between` is an inlinable SQL function, so the planner sees `ord_term(age) >= lo AND ord_term(age) <= hi`, and a btree on the column's order term serves it as one range scan: `eql_v4.ord_term(col)` for an OPE column (`_ord`, `_ord_ope`, and `_search` for text), `eql_v4.ord_term_ore(col)` for an ORE one (`_ord_ore`, and `_search_ore` for text). +- **An ORE column needs the `eql_v4_internal.ore_block_256` btree operator class**, for its index and so for an ORE range scan. The installer creates that class only when it runs with superuser privileges. Otherwise (on Supabase and other managed roles) it skips it and disables the ORE column domains and the ORE range domains: a value cast to `_ord_ore` or `_search_ore` raises `feature_not_supported`, naming the OPE and equality alternatives, and a value cast to a `query__range_ore` domain raises the same error, naming the family's `_range_ope` domain. On such an install, use an OPE column and its `_range_ope` domain, which need nothing more. - Always write it schema-qualified. `BETWEEN` is an SQL keyword, so a bare `between(...)` does not parse. - **Always cast the range parameter to its exact range domain**, as in `$1::eql_v4.query_integer_range_ore`. PostgreSQL resolves a function's domain arguments as their base type, `jsonb`, and every `between` overload takes jsonb domains. So an uncast `$1`, a `jsonb` parameter, or a range of the wrong term (an OPE range on an `_ord_ore` column) or the wrong family matches several overloads, and the statement fails with SQLSTATE 42725 (`function eql_v4.between(...) is not unique`) when it is parsed, before it runs. This differs from the single-bound operators: `col < $1` resolves an untyped `$1`. -- A `NULL` range matches no row. So does a range whose low bound is above its high bound; the client refuses to build one. +- A `NULL` range matches no row. So does a range whose low bound is above its high bound, as with SQL `BETWEEN`; the SQL does not reorder the bounds. - The `eql_bindings::v4` Rust types (`IntegerRangeOreQuery`, …) and the Go `encrypt/eql` types of the same names hold a range value, so it binds as one parameter. --- diff --git a/packages/eql/src/v3/scalars/bigint/query_bigint_range_functions.sql b/packages/eql/src/v3/scalars/bigint/query_bigint_range_functions.sql index ad9fe55a0..0d9edb9c8 100644 --- a/packages/eql/src/v3/scalars/bigint/query_bigint_range_functions.sql +++ b/packages/eql/src/v3/scalars/bigint/query_bigint_range_functions.sql @@ -99,8 +99,9 @@ AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> ' --! serves as one range scan. For this ORE range that index needs the --! default btree operator class on {{prefix}}_internal.ore_block_256, which the --! installer creates only with superuser privileges. Without it (on Supabase, ---! for instance) the installer disables the ORE column domains, so use an OPE ---! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. +--! for instance) the installer disables the ORE column domains and this ORE range, +--! so use an OPE column (`public.{{prefix}}_bigint_ord`, `public.{{prefix}}_bigint_ord_ope`) +--! and `{{prefix}}.query_bigint_range_ope`, which index everywhere. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. --! @note Cast the range to its exact domain, `$1::{{prefix}}.query_bigint_range_ore`. diff --git a/packages/eql/src/v3/scalars/bigint/query_bigint_range_types.sql b/packages/eql/src/v3/scalars/bigint/query_bigint_range_types.sql index 9e70f5117..3123b7d23 100644 --- a/packages/eql/src/v3/scalars/bigint/query_bigint_range_types.sql +++ b/packages/eql/src/v3/scalars/bigint/query_bigint_range_types.sql @@ -1,6 +1,7 @@ -- AUTOMATICALLY GENERATED FILE. -- ONLY EQL VERSION: 4 -- REQUIRE: src/v3/schema.sql +-- REQUIRE: src/v3/scalars/ore_fallback.sql --! @file v3/scalars/bigint/query_bigint_range_types.sql --! @brief Range query-operand domains for bigint: the operand of `{{prefix}}.between`. @@ -17,9 +18,14 @@ --! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this --! file out of every other version's installer. --! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, ---! and holds no ciphertext `c`. The client refuses a range whose low ---! bound is above its high bound; one that reaches the database anyway ---! matches no row. +--! and holds no ciphertext `c`. A range whose low bound is above its +--! high bound matches no row, as with SQL `BETWEEN`; the SQL does not +--! reorder the bounds. +--! @note On an install without the ORE operator class (a non-superuser +--! install, on Supabase for instance), the ORE range domain is disabled +--! as the single-bound ORE domains are: a value cast to it raises +--! `feature_not_supported`, naming the OPE range. See +--! `v3/scalars/ore_fallback.sql`. DO $$ BEGIN @@ -68,3 +74,26 @@ BEGIN COMMENT ON DOMAIN {{prefix}}.query_bigint_range_ope IS 'EQL bigint range query operand (CLLW OPE)'; END $$; + +-- Without the default btree operator class on {{prefix}}_internal.ore_block_256 +-- (the installer skips it on insufficient_privilege), ore_fallback.sql has +-- created {{prefix}}_internal.ore_domain_unavailable and poisoned the +-- single-bound ORE domains. It cannot reach this ORE range, which exists in +-- EQL v4 only, so the same NOT VALID poison CHECK is added here. +DO $do$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_opclass c + JOIN pg_catalog.pg_am am ON am.oid = c.opcmethod + WHERE am.amname = 'btree' + AND c.opcdefault + AND c.opcintype = '{{prefix}}_internal.ore_block_256'::pg_catalog.regtype + ) THEN + RETURN; + END IF; + + ALTER DOMAIN {{prefix}}.query_bigint_range_ore ADD CONSTRAINT eql_ore_unavailable + CHECK ({{prefix}}_internal.ore_domain_unavailable(VALUE, '{{prefix}}.query_bigint_range_ore', '{{prefix}}.query_bigint_range_ope (range)')) NOT VALID; +END; +$do$; diff --git a/packages/eql/src/v3/scalars/date/query_date_range_functions.sql b/packages/eql/src/v3/scalars/date/query_date_range_functions.sql index be33d8dda..840e33f1b 100644 --- a/packages/eql/src/v3/scalars/date/query_date_range_functions.sql +++ b/packages/eql/src/v3/scalars/date/query_date_range_functions.sql @@ -99,8 +99,9 @@ AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> ' --! serves as one range scan. For this ORE range that index needs the --! default btree operator class on {{prefix}}_internal.ore_block_256, which the --! installer creates only with superuser privileges. Without it (on Supabase, ---! for instance) the installer disables the ORE column domains, so use an OPE ---! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. +--! for instance) the installer disables the ORE column domains and this ORE range, +--! so use an OPE column (`public.{{prefix}}_date_ord`, `public.{{prefix}}_date_ord_ope`) +--! and `{{prefix}}.query_date_range_ope`, which index everywhere. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. --! @note Cast the range to its exact domain, `$1::{{prefix}}.query_date_range_ore`. diff --git a/packages/eql/src/v3/scalars/date/query_date_range_types.sql b/packages/eql/src/v3/scalars/date/query_date_range_types.sql index 21c6f1de2..6f95cec58 100644 --- a/packages/eql/src/v3/scalars/date/query_date_range_types.sql +++ b/packages/eql/src/v3/scalars/date/query_date_range_types.sql @@ -1,6 +1,7 @@ -- AUTOMATICALLY GENERATED FILE. -- ONLY EQL VERSION: 4 -- REQUIRE: src/v3/schema.sql +-- REQUIRE: src/v3/scalars/ore_fallback.sql --! @file v3/scalars/date/query_date_range_types.sql --! @brief Range query-operand domains for date: the operand of `{{prefix}}.between`. @@ -17,9 +18,14 @@ --! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this --! file out of every other version's installer. --! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, ---! and holds no ciphertext `c`. The client refuses a range whose low ---! bound is above its high bound; one that reaches the database anyway ---! matches no row. +--! and holds no ciphertext `c`. A range whose low bound is above its +--! high bound matches no row, as with SQL `BETWEEN`; the SQL does not +--! reorder the bounds. +--! @note On an install without the ORE operator class (a non-superuser +--! install, on Supabase for instance), the ORE range domain is disabled +--! as the single-bound ORE domains are: a value cast to it raises +--! `feature_not_supported`, naming the OPE range. See +--! `v3/scalars/ore_fallback.sql`. DO $$ BEGIN @@ -68,3 +74,26 @@ BEGIN COMMENT ON DOMAIN {{prefix}}.query_date_range_ope IS 'EQL date range query operand (CLLW OPE)'; END $$; + +-- Without the default btree operator class on {{prefix}}_internal.ore_block_256 +-- (the installer skips it on insufficient_privilege), ore_fallback.sql has +-- created {{prefix}}_internal.ore_domain_unavailable and poisoned the +-- single-bound ORE domains. It cannot reach this ORE range, which exists in +-- EQL v4 only, so the same NOT VALID poison CHECK is added here. +DO $do$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_opclass c + JOIN pg_catalog.pg_am am ON am.oid = c.opcmethod + WHERE am.amname = 'btree' + AND c.opcdefault + AND c.opcintype = '{{prefix}}_internal.ore_block_256'::pg_catalog.regtype + ) THEN + RETURN; + END IF; + + ALTER DOMAIN {{prefix}}.query_date_range_ore ADD CONSTRAINT eql_ore_unavailable + CHECK ({{prefix}}_internal.ore_domain_unavailable(VALUE, '{{prefix}}.query_date_range_ore', '{{prefix}}.query_date_range_ope (range)')) NOT VALID; +END; +$do$; diff --git a/packages/eql/src/v3/scalars/double/query_double_range_functions.sql b/packages/eql/src/v3/scalars/double/query_double_range_functions.sql index e28773ac6..8aa8ed9eb 100644 --- a/packages/eql/src/v3/scalars/double/query_double_range_functions.sql +++ b/packages/eql/src/v3/scalars/double/query_double_range_functions.sql @@ -99,8 +99,9 @@ AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> ' --! serves as one range scan. For this ORE range that index needs the --! default btree operator class on {{prefix}}_internal.ore_block_256, which the --! installer creates only with superuser privileges. Without it (on Supabase, ---! for instance) the installer disables the ORE column domains, so use an OPE ---! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. +--! for instance) the installer disables the ORE column domains and this ORE range, +--! so use an OPE column (`public.{{prefix}}_double_ord`, `public.{{prefix}}_double_ord_ope`) +--! and `{{prefix}}.query_double_range_ope`, which index everywhere. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. --! @note Cast the range to its exact domain, `$1::{{prefix}}.query_double_range_ore`. diff --git a/packages/eql/src/v3/scalars/double/query_double_range_types.sql b/packages/eql/src/v3/scalars/double/query_double_range_types.sql index 4c227acda..242ed93ce 100644 --- a/packages/eql/src/v3/scalars/double/query_double_range_types.sql +++ b/packages/eql/src/v3/scalars/double/query_double_range_types.sql @@ -1,6 +1,7 @@ -- AUTOMATICALLY GENERATED FILE. -- ONLY EQL VERSION: 4 -- REQUIRE: src/v3/schema.sql +-- REQUIRE: src/v3/scalars/ore_fallback.sql --! @file v3/scalars/double/query_double_range_types.sql --! @brief Range query-operand domains for double: the operand of `{{prefix}}.between`. @@ -17,9 +18,14 @@ --! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this --! file out of every other version's installer. --! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, ---! and holds no ciphertext `c`. The client refuses a range whose low ---! bound is above its high bound; one that reaches the database anyway ---! matches no row. +--! and holds no ciphertext `c`. A range whose low bound is above its +--! high bound matches no row, as with SQL `BETWEEN`; the SQL does not +--! reorder the bounds. +--! @note On an install without the ORE operator class (a non-superuser +--! install, on Supabase for instance), the ORE range domain is disabled +--! as the single-bound ORE domains are: a value cast to it raises +--! `feature_not_supported`, naming the OPE range. See +--! `v3/scalars/ore_fallback.sql`. DO $$ BEGIN @@ -68,3 +74,26 @@ BEGIN COMMENT ON DOMAIN {{prefix}}.query_double_range_ope IS 'EQL double range query operand (CLLW OPE)'; END $$; + +-- Without the default btree operator class on {{prefix}}_internal.ore_block_256 +-- (the installer skips it on insufficient_privilege), ore_fallback.sql has +-- created {{prefix}}_internal.ore_domain_unavailable and poisoned the +-- single-bound ORE domains. It cannot reach this ORE range, which exists in +-- EQL v4 only, so the same NOT VALID poison CHECK is added here. +DO $do$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_opclass c + JOIN pg_catalog.pg_am am ON am.oid = c.opcmethod + WHERE am.amname = 'btree' + AND c.opcdefault + AND c.opcintype = '{{prefix}}_internal.ore_block_256'::pg_catalog.regtype + ) THEN + RETURN; + END IF; + + ALTER DOMAIN {{prefix}}.query_double_range_ore ADD CONSTRAINT eql_ore_unavailable + CHECK ({{prefix}}_internal.ore_domain_unavailable(VALUE, '{{prefix}}.query_double_range_ore', '{{prefix}}.query_double_range_ope (range)')) NOT VALID; +END; +$do$; diff --git a/packages/eql/src/v3/scalars/integer/query_integer_range_functions.sql b/packages/eql/src/v3/scalars/integer/query_integer_range_functions.sql index dce2eb8a1..d05e5c03a 100644 --- a/packages/eql/src/v3/scalars/integer/query_integer_range_functions.sql +++ b/packages/eql/src/v3/scalars/integer/query_integer_range_functions.sql @@ -99,8 +99,9 @@ AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> ' --! serves as one range scan. For this ORE range that index needs the --! default btree operator class on {{prefix}}_internal.ore_block_256, which the --! installer creates only with superuser privileges. Without it (on Supabase, ---! for instance) the installer disables the ORE column domains, so use an OPE ---! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. +--! for instance) the installer disables the ORE column domains and this ORE range, +--! so use an OPE column (`public.{{prefix}}_integer_ord`, `public.{{prefix}}_integer_ord_ope`) +--! and `{{prefix}}.query_integer_range_ope`, which index everywhere. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. --! @note Cast the range to its exact domain, `$1::{{prefix}}.query_integer_range_ore`. diff --git a/packages/eql/src/v3/scalars/integer/query_integer_range_types.sql b/packages/eql/src/v3/scalars/integer/query_integer_range_types.sql index 93fd7526d..7d8469e08 100644 --- a/packages/eql/src/v3/scalars/integer/query_integer_range_types.sql +++ b/packages/eql/src/v3/scalars/integer/query_integer_range_types.sql @@ -1,6 +1,7 @@ -- AUTOMATICALLY GENERATED FILE. -- ONLY EQL VERSION: 4 -- REQUIRE: src/v3/schema.sql +-- REQUIRE: src/v3/scalars/ore_fallback.sql --! @file v3/scalars/integer/query_integer_range_types.sql --! @brief Range query-operand domains for integer: the operand of `{{prefix}}.between`. @@ -17,9 +18,14 @@ --! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this --! file out of every other version's installer. --! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, ---! and holds no ciphertext `c`. The client refuses a range whose low ---! bound is above its high bound; one that reaches the database anyway ---! matches no row. +--! and holds no ciphertext `c`. A range whose low bound is above its +--! high bound matches no row, as with SQL `BETWEEN`; the SQL does not +--! reorder the bounds. +--! @note On an install without the ORE operator class (a non-superuser +--! install, on Supabase for instance), the ORE range domain is disabled +--! as the single-bound ORE domains are: a value cast to it raises +--! `feature_not_supported`, naming the OPE range. See +--! `v3/scalars/ore_fallback.sql`. DO $$ BEGIN @@ -68,3 +74,26 @@ BEGIN COMMENT ON DOMAIN {{prefix}}.query_integer_range_ope IS 'EQL integer range query operand (CLLW OPE)'; END $$; + +-- Without the default btree operator class on {{prefix}}_internal.ore_block_256 +-- (the installer skips it on insufficient_privilege), ore_fallback.sql has +-- created {{prefix}}_internal.ore_domain_unavailable and poisoned the +-- single-bound ORE domains. It cannot reach this ORE range, which exists in +-- EQL v4 only, so the same NOT VALID poison CHECK is added here. +DO $do$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_opclass c + JOIN pg_catalog.pg_am am ON am.oid = c.opcmethod + WHERE am.amname = 'btree' + AND c.opcdefault + AND c.opcintype = '{{prefix}}_internal.ore_block_256'::pg_catalog.regtype + ) THEN + RETURN; + END IF; + + ALTER DOMAIN {{prefix}}.query_integer_range_ore ADD CONSTRAINT eql_ore_unavailable + CHECK ({{prefix}}_internal.ore_domain_unavailable(VALUE, '{{prefix}}.query_integer_range_ore', '{{prefix}}.query_integer_range_ope (range)')) NOT VALID; +END; +$do$; diff --git a/packages/eql/src/v3/scalars/numeric/query_numeric_range_functions.sql b/packages/eql/src/v3/scalars/numeric/query_numeric_range_functions.sql index 792b51ee0..193f9763b 100644 --- a/packages/eql/src/v3/scalars/numeric/query_numeric_range_functions.sql +++ b/packages/eql/src/v3/scalars/numeric/query_numeric_range_functions.sql @@ -99,8 +99,9 @@ AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> ' --! serves as one range scan. For this ORE range that index needs the --! default btree operator class on {{prefix}}_internal.ore_block_256, which the --! installer creates only with superuser privileges. Without it (on Supabase, ---! for instance) the installer disables the ORE column domains, so use an OPE ---! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. +--! for instance) the installer disables the ORE column domains and this ORE range, +--! so use an OPE column (`public.{{prefix}}_numeric_ord`, `public.{{prefix}}_numeric_ord_ope`) +--! and `{{prefix}}.query_numeric_range_ope`, which index everywhere. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. --! @note Cast the range to its exact domain, `$1::{{prefix}}.query_numeric_range_ore`. diff --git a/packages/eql/src/v3/scalars/numeric/query_numeric_range_types.sql b/packages/eql/src/v3/scalars/numeric/query_numeric_range_types.sql index 31396e9ed..424526b59 100644 --- a/packages/eql/src/v3/scalars/numeric/query_numeric_range_types.sql +++ b/packages/eql/src/v3/scalars/numeric/query_numeric_range_types.sql @@ -1,6 +1,7 @@ -- AUTOMATICALLY GENERATED FILE. -- ONLY EQL VERSION: 4 -- REQUIRE: src/v3/schema.sql +-- REQUIRE: src/v3/scalars/ore_fallback.sql --! @file v3/scalars/numeric/query_numeric_range_types.sql --! @brief Range query-operand domains for numeric: the operand of `{{prefix}}.between`. @@ -17,9 +18,14 @@ --! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this --! file out of every other version's installer. --! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, ---! and holds no ciphertext `c`. The client refuses a range whose low ---! bound is above its high bound; one that reaches the database anyway ---! matches no row. +--! and holds no ciphertext `c`. A range whose low bound is above its +--! high bound matches no row, as with SQL `BETWEEN`; the SQL does not +--! reorder the bounds. +--! @note On an install without the ORE operator class (a non-superuser +--! install, on Supabase for instance), the ORE range domain is disabled +--! as the single-bound ORE domains are: a value cast to it raises +--! `feature_not_supported`, naming the OPE range. See +--! `v3/scalars/ore_fallback.sql`. DO $$ BEGIN @@ -68,3 +74,26 @@ BEGIN COMMENT ON DOMAIN {{prefix}}.query_numeric_range_ope IS 'EQL numeric range query operand (CLLW OPE)'; END $$; + +-- Without the default btree operator class on {{prefix}}_internal.ore_block_256 +-- (the installer skips it on insufficient_privilege), ore_fallback.sql has +-- created {{prefix}}_internal.ore_domain_unavailable and poisoned the +-- single-bound ORE domains. It cannot reach this ORE range, which exists in +-- EQL v4 only, so the same NOT VALID poison CHECK is added here. +DO $do$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_opclass c + JOIN pg_catalog.pg_am am ON am.oid = c.opcmethod + WHERE am.amname = 'btree' + AND c.opcdefault + AND c.opcintype = '{{prefix}}_internal.ore_block_256'::pg_catalog.regtype + ) THEN + RETURN; + END IF; + + ALTER DOMAIN {{prefix}}.query_numeric_range_ore ADD CONSTRAINT eql_ore_unavailable + CHECK ({{prefix}}_internal.ore_domain_unavailable(VALUE, '{{prefix}}.query_numeric_range_ore', '{{prefix}}.query_numeric_range_ope (range)')) NOT VALID; +END; +$do$; diff --git a/packages/eql/src/v3/scalars/real/query_real_range_functions.sql b/packages/eql/src/v3/scalars/real/query_real_range_functions.sql index 27a3b6146..875251fd6 100644 --- a/packages/eql/src/v3/scalars/real/query_real_range_functions.sql +++ b/packages/eql/src/v3/scalars/real/query_real_range_functions.sql @@ -99,8 +99,9 @@ AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> ' --! serves as one range scan. For this ORE range that index needs the --! default btree operator class on {{prefix}}_internal.ore_block_256, which the --! installer creates only with superuser privileges. Without it (on Supabase, ---! for instance) the installer disables the ORE column domains, so use an OPE ---! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. +--! for instance) the installer disables the ORE column domains and this ORE range, +--! so use an OPE column (`public.{{prefix}}_real_ord`, `public.{{prefix}}_real_ord_ope`) +--! and `{{prefix}}.query_real_range_ope`, which index everywhere. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. --! @note Cast the range to its exact domain, `$1::{{prefix}}.query_real_range_ore`. diff --git a/packages/eql/src/v3/scalars/real/query_real_range_types.sql b/packages/eql/src/v3/scalars/real/query_real_range_types.sql index 95e7c1b94..7a0f2c851 100644 --- a/packages/eql/src/v3/scalars/real/query_real_range_types.sql +++ b/packages/eql/src/v3/scalars/real/query_real_range_types.sql @@ -1,6 +1,7 @@ -- AUTOMATICALLY GENERATED FILE. -- ONLY EQL VERSION: 4 -- REQUIRE: src/v3/schema.sql +-- REQUIRE: src/v3/scalars/ore_fallback.sql --! @file v3/scalars/real/query_real_range_types.sql --! @brief Range query-operand domains for real: the operand of `{{prefix}}.between`. @@ -17,9 +18,14 @@ --! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this --! file out of every other version's installer. --! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, ---! and holds no ciphertext `c`. The client refuses a range whose low ---! bound is above its high bound; one that reaches the database anyway ---! matches no row. +--! and holds no ciphertext `c`. A range whose low bound is above its +--! high bound matches no row, as with SQL `BETWEEN`; the SQL does not +--! reorder the bounds. +--! @note On an install without the ORE operator class (a non-superuser +--! install, on Supabase for instance), the ORE range domain is disabled +--! as the single-bound ORE domains are: a value cast to it raises +--! `feature_not_supported`, naming the OPE range. See +--! `v3/scalars/ore_fallback.sql`. DO $$ BEGIN @@ -68,3 +74,26 @@ BEGIN COMMENT ON DOMAIN {{prefix}}.query_real_range_ope IS 'EQL real range query operand (CLLW OPE)'; END $$; + +-- Without the default btree operator class on {{prefix}}_internal.ore_block_256 +-- (the installer skips it on insufficient_privilege), ore_fallback.sql has +-- created {{prefix}}_internal.ore_domain_unavailable and poisoned the +-- single-bound ORE domains. It cannot reach this ORE range, which exists in +-- EQL v4 only, so the same NOT VALID poison CHECK is added here. +DO $do$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_opclass c + JOIN pg_catalog.pg_am am ON am.oid = c.opcmethod + WHERE am.amname = 'btree' + AND c.opcdefault + AND c.opcintype = '{{prefix}}_internal.ore_block_256'::pg_catalog.regtype + ) THEN + RETURN; + END IF; + + ALTER DOMAIN {{prefix}}.query_real_range_ore ADD CONSTRAINT eql_ore_unavailable + CHECK ({{prefix}}_internal.ore_domain_unavailable(VALUE, '{{prefix}}.query_real_range_ore', '{{prefix}}.query_real_range_ope (range)')) NOT VALID; +END; +$do$; diff --git a/packages/eql/src/v3/scalars/smallint/query_smallint_range_functions.sql b/packages/eql/src/v3/scalars/smallint/query_smallint_range_functions.sql index 0f4cd9315..2ec4f1809 100644 --- a/packages/eql/src/v3/scalars/smallint/query_smallint_range_functions.sql +++ b/packages/eql/src/v3/scalars/smallint/query_smallint_range_functions.sql @@ -99,8 +99,9 @@ AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> ' --! serves as one range scan. For this ORE range that index needs the --! default btree operator class on {{prefix}}_internal.ore_block_256, which the --! installer creates only with superuser privileges. Without it (on Supabase, ---! for instance) the installer disables the ORE column domains, so use an OPE ---! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. +--! for instance) the installer disables the ORE column domains and this ORE range, +--! so use an OPE column (`public.{{prefix}}_smallint_ord`, `public.{{prefix}}_smallint_ord_ope`) +--! and `{{prefix}}.query_smallint_range_ope`, which index everywhere. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. --! @note Cast the range to its exact domain, `$1::{{prefix}}.query_smallint_range_ore`. diff --git a/packages/eql/src/v3/scalars/smallint/query_smallint_range_types.sql b/packages/eql/src/v3/scalars/smallint/query_smallint_range_types.sql index 100d7b3e4..36258ec97 100644 --- a/packages/eql/src/v3/scalars/smallint/query_smallint_range_types.sql +++ b/packages/eql/src/v3/scalars/smallint/query_smallint_range_types.sql @@ -1,6 +1,7 @@ -- AUTOMATICALLY GENERATED FILE. -- ONLY EQL VERSION: 4 -- REQUIRE: src/v3/schema.sql +-- REQUIRE: src/v3/scalars/ore_fallback.sql --! @file v3/scalars/smallint/query_smallint_range_types.sql --! @brief Range query-operand domains for smallint: the operand of `{{prefix}}.between`. @@ -17,9 +18,14 @@ --! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this --! file out of every other version's installer. --! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, ---! and holds no ciphertext `c`. The client refuses a range whose low ---! bound is above its high bound; one that reaches the database anyway ---! matches no row. +--! and holds no ciphertext `c`. A range whose low bound is above its +--! high bound matches no row, as with SQL `BETWEEN`; the SQL does not +--! reorder the bounds. +--! @note On an install without the ORE operator class (a non-superuser +--! install, on Supabase for instance), the ORE range domain is disabled +--! as the single-bound ORE domains are: a value cast to it raises +--! `feature_not_supported`, naming the OPE range. See +--! `v3/scalars/ore_fallback.sql`. DO $$ BEGIN @@ -68,3 +74,26 @@ BEGIN COMMENT ON DOMAIN {{prefix}}.query_smallint_range_ope IS 'EQL smallint range query operand (CLLW OPE)'; END $$; + +-- Without the default btree operator class on {{prefix}}_internal.ore_block_256 +-- (the installer skips it on insufficient_privilege), ore_fallback.sql has +-- created {{prefix}}_internal.ore_domain_unavailable and poisoned the +-- single-bound ORE domains. It cannot reach this ORE range, which exists in +-- EQL v4 only, so the same NOT VALID poison CHECK is added here. +DO $do$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_opclass c + JOIN pg_catalog.pg_am am ON am.oid = c.opcmethod + WHERE am.amname = 'btree' + AND c.opcdefault + AND c.opcintype = '{{prefix}}_internal.ore_block_256'::pg_catalog.regtype + ) THEN + RETURN; + END IF; + + ALTER DOMAIN {{prefix}}.query_smallint_range_ore ADD CONSTRAINT eql_ore_unavailable + CHECK ({{prefix}}_internal.ore_domain_unavailable(VALUE, '{{prefix}}.query_smallint_range_ore', '{{prefix}}.query_smallint_range_ope (range)')) NOT VALID; +END; +$do$; diff --git a/packages/eql/src/v3/scalars/text/query_text_range_functions.sql b/packages/eql/src/v3/scalars/text/query_text_range_functions.sql index 6687365b7..34735635d 100644 --- a/packages/eql/src/v3/scalars/text/query_text_range_functions.sql +++ b/packages/eql/src/v3/scalars/text/query_text_range_functions.sql @@ -101,8 +101,9 @@ AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> ' --! serves as one range scan. For this ORE range that index needs the --! default btree operator class on {{prefix}}_internal.ore_block_256, which the --! installer creates only with superuser privileges. Without it (on Supabase, ---! for instance) the installer disables the ORE column domains, so use an OPE ---! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. +--! for instance) the installer disables the ORE column domains and this ORE range, +--! so use an OPE column (`public.{{prefix}}_text_ord`, `public.{{prefix}}_text_ord_ope`, `public.{{prefix}}_text_search`) +--! and `{{prefix}}.query_text_range_ope`, which index everywhere. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. --! @note Cast the range to its exact domain, `$1::{{prefix}}.query_text_range_ore`. @@ -135,8 +136,9 @@ AS $$ SELECT {{prefix}}.ord_term_ore(a) >= {{prefix}}_internal.lo(r) AND {{prefi --! serves as one range scan. For this ORE range that index needs the --! default btree operator class on {{prefix}}_internal.ore_block_256, which the --! installer creates only with superuser privileges. Without it (on Supabase, ---! for instance) the installer disables the ORE column domains, so use an OPE ---! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. +--! for instance) the installer disables the ORE column domains and this ORE range, +--! so use an OPE column (`public.{{prefix}}_text_ord`, `public.{{prefix}}_text_ord_ope`, `public.{{prefix}}_text_search`) +--! and `{{prefix}}.query_text_range_ope`, which index everywhere. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. --! @note Cast the range to its exact domain, `$1::{{prefix}}.query_text_range_ore`. diff --git a/packages/eql/src/v3/scalars/text/query_text_range_types.sql b/packages/eql/src/v3/scalars/text/query_text_range_types.sql index f6caf4689..3d7ff3196 100644 --- a/packages/eql/src/v3/scalars/text/query_text_range_types.sql +++ b/packages/eql/src/v3/scalars/text/query_text_range_types.sql @@ -1,6 +1,7 @@ -- AUTOMATICALLY GENERATED FILE. -- ONLY EQL VERSION: 4 -- REQUIRE: src/v3/schema.sql +-- REQUIRE: src/v3/scalars/ore_fallback.sql --! @file v3/scalars/text/query_text_range_types.sql --! @brief Range query-operand domains for text: the operand of `{{prefix}}.between`. @@ -17,9 +18,14 @@ --! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this --! file out of every other version's installer. --! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, ---! and holds no ciphertext `c`. The client refuses a range whose low ---! bound is above its high bound; one that reaches the database anyway ---! matches no row. +--! and holds no ciphertext `c`. A range whose low bound is above its +--! high bound matches no row, as with SQL `BETWEEN`; the SQL does not +--! reorder the bounds. +--! @note On an install without the ORE operator class (a non-superuser +--! install, on Supabase for instance), the ORE range domain is disabled +--! as the single-bound ORE domains are: a value cast to it raises +--! `feature_not_supported`, naming the OPE range. See +--! `v3/scalars/ore_fallback.sql`. DO $$ BEGIN @@ -68,3 +74,26 @@ BEGIN COMMENT ON DOMAIN {{prefix}}.query_text_range_ope IS 'EQL text range query operand (CLLW OPE)'; END $$; + +-- Without the default btree operator class on {{prefix}}_internal.ore_block_256 +-- (the installer skips it on insufficient_privilege), ore_fallback.sql has +-- created {{prefix}}_internal.ore_domain_unavailable and poisoned the +-- single-bound ORE domains. It cannot reach this ORE range, which exists in +-- EQL v4 only, so the same NOT VALID poison CHECK is added here. +DO $do$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_opclass c + JOIN pg_catalog.pg_am am ON am.oid = c.opcmethod + WHERE am.amname = 'btree' + AND c.opcdefault + AND c.opcintype = '{{prefix}}_internal.ore_block_256'::pg_catalog.regtype + ) THEN + RETURN; + END IF; + + ALTER DOMAIN {{prefix}}.query_text_range_ore ADD CONSTRAINT eql_ore_unavailable + CHECK ({{prefix}}_internal.ore_domain_unavailable(VALUE, '{{prefix}}.query_text_range_ore', '{{prefix}}.query_text_range_ope (range)')) NOT VALID; +END; +$do$; diff --git a/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_functions.sql b/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_functions.sql index 6b905a49d..6c1a95c89 100644 --- a/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_functions.sql +++ b/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_functions.sql @@ -99,8 +99,9 @@ AS $$ SELECT {{prefix}}_internal.ope_cllw(jsonb_build_object('op', r::jsonb -> ' --! serves as one range scan. For this ORE range that index needs the --! default btree operator class on {{prefix}}_internal.ore_block_256, which the --! installer creates only with superuser privileges. Without it (on Supabase, ---! for instance) the installer disables the ORE column domains, so use an OPE ---! column (`_ord`, `_ord_ope`, `_search`) and its OPE range, which index everywhere. +--! for instance) the installer disables the ORE column domains and this ORE range, +--! so use an OPE column (`public.{{prefix}}_timestamp_ord`, `public.{{prefix}}_timestamp_ord_ope`) +--! and `{{prefix}}.query_timestamp_range_ope`, which index everywhere. --! @note `BETWEEN` is an SQL keyword, so call it schema-qualified: --! `{{prefix}}.between(...)` parses, a bare `between(...)` does not. --! @note Cast the range to its exact domain, `$1::{{prefix}}.query_timestamp_range_ore`. diff --git a/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_types.sql b/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_types.sql index cd9fdc934..edc011ee3 100644 --- a/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_types.sql +++ b/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_types.sql @@ -1,6 +1,7 @@ -- AUTOMATICALLY GENERATED FILE. -- ONLY EQL VERSION: 4 -- REQUIRE: src/v3/schema.sql +-- REQUIRE: src/v3/scalars/ore_fallback.sql --! @file v3/scalars/timestamp/query_timestamp_range_types.sql --! @brief Range query-operand domains for timestamp: the operand of `{{prefix}}.between`. @@ -17,9 +18,14 @@ --! @note EQL version {{eql_version}} only: the `-- ONLY EQL VERSION:` line above keeps this --! file out of every other version's installer. --! @note Like every query operand, a range lives in `{{prefix}}`, not `public`, ---! and holds no ciphertext `c`. The client refuses a range whose low ---! bound is above its high bound; one that reaches the database anyway ---! matches no row. +--! and holds no ciphertext `c`. A range whose low bound is above its +--! high bound matches no row, as with SQL `BETWEEN`; the SQL does not +--! reorder the bounds. +--! @note On an install without the ORE operator class (a non-superuser +--! install, on Supabase for instance), the ORE range domain is disabled +--! as the single-bound ORE domains are: a value cast to it raises +--! `feature_not_supported`, naming the OPE range. See +--! `v3/scalars/ore_fallback.sql`. DO $$ BEGIN @@ -68,3 +74,26 @@ BEGIN COMMENT ON DOMAIN {{prefix}}.query_timestamp_range_ope IS 'EQL timestamp range query operand (CLLW OPE)'; END $$; + +-- Without the default btree operator class on {{prefix}}_internal.ore_block_256 +-- (the installer skips it on insufficient_privilege), ore_fallback.sql has +-- created {{prefix}}_internal.ore_domain_unavailable and poisoned the +-- single-bound ORE domains. It cannot reach this ORE range, which exists in +-- EQL v4 only, so the same NOT VALID poison CHECK is added here. +DO $do$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_opclass c + JOIN pg_catalog.pg_am am ON am.oid = c.opcmethod + WHERE am.amname = 'btree' + AND c.opcdefault + AND c.opcintype = '{{prefix}}_internal.ore_block_256'::pg_catalog.regtype + ) THEN + RETURN; + END IF; + + ALTER DOMAIN {{prefix}}.query_timestamp_range_ore ADD CONSTRAINT eql_ore_unavailable + CHECK ({{prefix}}_internal.ore_domain_unavailable(VALUE, '{{prefix}}.query_timestamp_range_ore', '{{prefix}}.query_timestamp_range_ope (range)')) NOT VALID; +END; +$do$; diff --git a/packages/eql/tests/encryption/tests/v4_range.rs b/packages/eql/tests/encryption/tests/v4_range.rs index 6dcf6ebc3..488f23672 100644 --- a/packages/eql/tests/encryption/tests/v4_range.rs +++ b/packages/eql/tests/encryption/tests/v4_range.rs @@ -362,8 +362,8 @@ fn postgres_between_finds_integers_in_an_inclusive_range() { } } - // A range whose low bound is above its high bound matches nothing: - // the client refuses one, and the SQL does not reorder it. + // A range whose low bound is above its high bound matches nothing, + // as with SQL `BETWEEN`: the SQL does not reorder the bounds. assert_eq!( found("ore", "query_integer_range_ore", &ore(30, 10)), Vec::::new() @@ -939,3 +939,109 @@ fn postgres_between_needs_the_exact_range_domain() { db.prepare("SELECT 1 FROM v4_range_cast WHERE ore < $1") .unwrap(); } + +/// A role and a database of its own, made by the superuser connection and +/// dropped on the way out, panic or not. Roles are cluster-global, so the +/// name carries the process id. +struct NonSuperuserDatabase { + name: String, +} + +impl NonSuperuserDatabase { + fn create(admin: &mut postgres::Client) -> Self { + let name = format!("eql_v4_nosuper_{}", std::process::id()); + // One statement per call: a multi-statement query runs in one + // transaction, and `CREATE DATABASE` / `DROP DATABASE` refuse one. + for statement in [ + format!("DROP DATABASE IF EXISTS {name} WITH (FORCE)"), + format!("DROP ROLE IF EXISTS {name}"), + format!("CREATE ROLE {name} LOGIN NOSUPERUSER PASSWORD '{name}'"), + format!("CREATE DATABASE {name} OWNER {name}"), + ] { + admin.batch_execute(&statement).unwrap(); + } + Self { name } + } + + fn connect(&self) -> postgres::Client { + let url = std::env::var("EQL_TEST_DATABASE_URL").expect("EQL_TEST_DATABASE_URL"); + let mut config: postgres::Config = url.parse().unwrap(); + config + .dbname(&self.name) + .user(&self.name) + .password(&self.name); + config.connect(postgres::NoTls).unwrap() + } +} + +impl Drop for NonSuperuserDatabase { + fn drop(&mut self) { + let name = &self.name; + let mut admin = connect(); + let _ = admin.batch_execute(&format!("DROP DATABASE IF EXISTS {name} WITH (FORCE)")); + let _ = admin.batch_execute(&format!("DROP ROLE IF EXISTS {name}")); + } +} + +/// Needs an EQL v4 installation, and a superuser on `EQL_TEST_DATABASE_URL` +/// who can create a role and a database. A non-superuser install skips the +/// `ore_block_256` btree opclass, and `ore_fallback.sql` disables the +/// single-bound ORE domains. Every ORE range domain is disabled the same way, +/// naming its family's OPE range, which stays usable. The bundle is the one +/// `mise run test:encryption:postgres` builds and installs. +#[test] +#[ignore = "requires EQL_TEST_DATABASE_URL pointing to an EQL v4 database"] +fn postgres_a_non_superuser_install_disables_the_ore_ranges() { + let bundle = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../release/cipherstash-encrypt-v4.sql" + )) + .expect("the v4 bundle: run `mise run build`"); + let mut admin = connect(); + let database = NonSuperuserDatabase::create(&mut admin); + let mut db = database.connect(); + db.batch_execute(&bundle).unwrap(); + + let opclass: bool = db + .query_one( + "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_opclass c + JOIN pg_catalog.pg_am am ON am.oid = c.opcmethod + WHERE am.amname = 'btree' AND c.opcdefault + AND c.opcintype = 'eql_v4_internal.ore_block_256'::pg_catalog.regtype)", + &[], + ) + .unwrap() + .get(0); + assert!(!opclass, "a non-superuser install created the ORE opclass"); + + let cast = |db: &mut postgres::Client, value: &Value, domain: &str| { + db.query_one( + &format!("SELECT $1::text::jsonb::{domain}::text"), + &[&value.to_string()], + ) + .map(|_| ()) + }; + let ore = json!({"v": 4, "i": identifier(), "lo": ["00"], "hi": ["00"]}); + let ope = json!({"v": 4, "i": identifier(), "lo": "00", "hi": "00"}); + let mut checked = 0; + for family in eql_domains::CATALOG + .iter() + .filter(|f| f.range_terms().contains(&eql_domains::Term::Ore)) + { + let ore_range = format!("eql_v4.{}", family.range_query_name(eql_domains::Term::Ore)); + let ope_range = format!("eql_v4.{}", family.range_query_name(eql_domains::Term::Ope)); + let err = cast(&mut db, &ore, &ore_range).unwrap_err(); + assert_eq!(sqlstate(&err), "0A000", "{ore_range}: {err}"); + let db_error = err.as_db_error().unwrap(); + assert!(db_error.message().contains(&ore_range), "{err}"); + assert!( + db_error.hint().is_some_and(|h| h.contains(&ope_range)), + "{ore_range}: {:?}", + db_error.hint() + ); + cast(&mut db, &ope, &ope_range).unwrap(); + checked += 1; + } + assert_eq!(checked, 9, "every ordered family has an ORE range"); + drop(db); +} From ad19081cc386321f947652f1c62e8321e302cd00 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 19 Oct 2026 21:46:35 +1100 Subject: [PATCH 6/6] fix(eql): refuse a range whose payload version is JSON null The range domains' CHECK compared `VALUE->>'v' = '4'`. For `"v": null` that comparison is SQL NULL, the whole CHECK is NULL, and PostgreSQL treats a NULL CHECK as passing, so a range with no v4 version cast cleanly and `eql_v4.between` ran on it. The check is now `COALESCE(VALUE->>'v' = '4', VALUE IS NULL)`: false for a JSON null version, still true for a NULL range (which matches no row). The comparison keeps the one form the v4 isolation gate accepts. `postgres_range_domains_check_their_shape` now casts a `"v": null` range to both the ORE and the OPE domain and expects 23514. It fails without the template change. The same `->>'v' = ''` predicate is in every other domain CHECK, most of them shared with v3; that is left to a separate change so the v3 bundle stays byte-identical here. --- packages/eql/crates/eql-codegen/src/range.rs | 2 +- .../crates/eql-codegen/templates/range_types.sql.j2 | 6 +++++- .../v3/scalars/bigint/query_bigint_range_types.sql | 12 ++++++++++-- .../src/v3/scalars/date/query_date_range_types.sql | 12 ++++++++++-- .../v3/scalars/double/query_double_range_types.sql | 12 ++++++++++-- .../v3/scalars/integer/query_integer_range_types.sql | 12 ++++++++++-- .../v3/scalars/numeric/query_numeric_range_types.sql | 12 ++++++++++-- .../src/v3/scalars/real/query_real_range_types.sql | 12 ++++++++++-- .../scalars/smallint/query_smallint_range_types.sql | 12 ++++++++++-- .../src/v3/scalars/text/query_text_range_types.sql | 12 ++++++++++-- .../timestamp/query_timestamp_range_types.sql | 12 ++++++++++-- packages/eql/tests/encryption/tests/v4_range.rs | 10 ++++++++++ 12 files changed, 106 insertions(+), 20 deletions(-) diff --git a/packages/eql/crates/eql-codegen/src/range.rs b/packages/eql/crates/eql-codegen/src/range.rs index 14a67e809..8c31f5d13 100644 --- a/packages/eql/crates/eql-codegen/src/range.rs +++ b/packages/eql/crates/eql-codegen/src/range.rs @@ -393,7 +393,7 @@ mod tests { "jsonb_array_length(VALUE -> 'lo') > 0", "jsonb_typeof(VALUE -> 'hi') = 'array'", "jsonb_array_length(VALUE -> 'hi') > 0", - "VALUE->>'v' = '4'", + "COALESCE(VALUE->>'v' = '4', VALUE IS NULL)", ] { assert!(ore.contains(clause), "ORE range lacks {clause}:\n{ore}"); } diff --git a/packages/eql/crates/eql-codegen/templates/range_types.sql.j2 b/packages/eql/crates/eql-codegen/templates/range_types.sql.j2 index 4aea035e9..82259afc1 100644 --- a/packages/eql/crates/eql-codegen/templates/range_types.sql.j2 +++ b/packages/eql/crates/eql-codegen/templates/range_types.sql.j2 @@ -45,7 +45,11 @@ BEGIN {%- for c in d.bound_checks %} AND {{ c }} {%- endfor %} - AND VALUE->>'v' = '{{ eql_version }}' + -- `"v": null` makes the comparison NULL, and a CHECK that is NULL + -- passes, so COALESCE refuses it. A NULL range still passes (no row + -- matches it). The comparison keeps the one form the v4 isolation + -- gate accepts. + AND COALESCE(VALUE->>'v' = '{{ eql_version }}', VALUE IS NULL) ); END IF; diff --git a/packages/eql/src/v3/scalars/bigint/query_bigint_range_types.sql b/packages/eql/src/v3/scalars/bigint/query_bigint_range_types.sql index 3123b7d23..37c422c54 100644 --- a/packages/eql/src/v3/scalars/bigint/query_bigint_range_types.sql +++ b/packages/eql/src/v3/scalars/bigint/query_bigint_range_types.sql @@ -46,7 +46,11 @@ BEGIN AND jsonb_array_length(VALUE -> 'lo') > 0 AND jsonb_typeof(VALUE -> 'hi') = 'array' AND jsonb_array_length(VALUE -> 'hi') > 0 - AND VALUE->>'v' = '{{eql_version}}' + -- `"v": null` makes the comparison NULL, and a CHECK that is NULL + -- passes, so COALESCE refuses it. A NULL range still passes (no row + -- matches it). The comparison keeps the one form the v4 isolation + -- gate accepts. + AND COALESCE(VALUE->>'v' = '{{eql_version}}', VALUE IS NULL) ); END IF; @@ -67,7 +71,11 @@ BEGIN AND NOT (VALUE ? 'c') AND jsonb_typeof(VALUE -> 'lo') = 'string' AND jsonb_typeof(VALUE -> 'hi') = 'string' - AND VALUE->>'v' = '{{eql_version}}' + -- `"v": null` makes the comparison NULL, and a CHECK that is NULL + -- passes, so COALESCE refuses it. A NULL range still passes (no row + -- matches it). The comparison keeps the one form the v4 isolation + -- gate accepts. + AND COALESCE(VALUE->>'v' = '{{eql_version}}', VALUE IS NULL) ); END IF; diff --git a/packages/eql/src/v3/scalars/date/query_date_range_types.sql b/packages/eql/src/v3/scalars/date/query_date_range_types.sql index 6f95cec58..31ec74c6a 100644 --- a/packages/eql/src/v3/scalars/date/query_date_range_types.sql +++ b/packages/eql/src/v3/scalars/date/query_date_range_types.sql @@ -46,7 +46,11 @@ BEGIN AND jsonb_array_length(VALUE -> 'lo') > 0 AND jsonb_typeof(VALUE -> 'hi') = 'array' AND jsonb_array_length(VALUE -> 'hi') > 0 - AND VALUE->>'v' = '{{eql_version}}' + -- `"v": null` makes the comparison NULL, and a CHECK that is NULL + -- passes, so COALESCE refuses it. A NULL range still passes (no row + -- matches it). The comparison keeps the one form the v4 isolation + -- gate accepts. + AND COALESCE(VALUE->>'v' = '{{eql_version}}', VALUE IS NULL) ); END IF; @@ -67,7 +71,11 @@ BEGIN AND NOT (VALUE ? 'c') AND jsonb_typeof(VALUE -> 'lo') = 'string' AND jsonb_typeof(VALUE -> 'hi') = 'string' - AND VALUE->>'v' = '{{eql_version}}' + -- `"v": null` makes the comparison NULL, and a CHECK that is NULL + -- passes, so COALESCE refuses it. A NULL range still passes (no row + -- matches it). The comparison keeps the one form the v4 isolation + -- gate accepts. + AND COALESCE(VALUE->>'v' = '{{eql_version}}', VALUE IS NULL) ); END IF; diff --git a/packages/eql/src/v3/scalars/double/query_double_range_types.sql b/packages/eql/src/v3/scalars/double/query_double_range_types.sql index 242ed93ce..4939fa339 100644 --- a/packages/eql/src/v3/scalars/double/query_double_range_types.sql +++ b/packages/eql/src/v3/scalars/double/query_double_range_types.sql @@ -46,7 +46,11 @@ BEGIN AND jsonb_array_length(VALUE -> 'lo') > 0 AND jsonb_typeof(VALUE -> 'hi') = 'array' AND jsonb_array_length(VALUE -> 'hi') > 0 - AND VALUE->>'v' = '{{eql_version}}' + -- `"v": null` makes the comparison NULL, and a CHECK that is NULL + -- passes, so COALESCE refuses it. A NULL range still passes (no row + -- matches it). The comparison keeps the one form the v4 isolation + -- gate accepts. + AND COALESCE(VALUE->>'v' = '{{eql_version}}', VALUE IS NULL) ); END IF; @@ -67,7 +71,11 @@ BEGIN AND NOT (VALUE ? 'c') AND jsonb_typeof(VALUE -> 'lo') = 'string' AND jsonb_typeof(VALUE -> 'hi') = 'string' - AND VALUE->>'v' = '{{eql_version}}' + -- `"v": null` makes the comparison NULL, and a CHECK that is NULL + -- passes, so COALESCE refuses it. A NULL range still passes (no row + -- matches it). The comparison keeps the one form the v4 isolation + -- gate accepts. + AND COALESCE(VALUE->>'v' = '{{eql_version}}', VALUE IS NULL) ); END IF; diff --git a/packages/eql/src/v3/scalars/integer/query_integer_range_types.sql b/packages/eql/src/v3/scalars/integer/query_integer_range_types.sql index 7d8469e08..c32c1be6c 100644 --- a/packages/eql/src/v3/scalars/integer/query_integer_range_types.sql +++ b/packages/eql/src/v3/scalars/integer/query_integer_range_types.sql @@ -46,7 +46,11 @@ BEGIN AND jsonb_array_length(VALUE -> 'lo') > 0 AND jsonb_typeof(VALUE -> 'hi') = 'array' AND jsonb_array_length(VALUE -> 'hi') > 0 - AND VALUE->>'v' = '{{eql_version}}' + -- `"v": null` makes the comparison NULL, and a CHECK that is NULL + -- passes, so COALESCE refuses it. A NULL range still passes (no row + -- matches it). The comparison keeps the one form the v4 isolation + -- gate accepts. + AND COALESCE(VALUE->>'v' = '{{eql_version}}', VALUE IS NULL) ); END IF; @@ -67,7 +71,11 @@ BEGIN AND NOT (VALUE ? 'c') AND jsonb_typeof(VALUE -> 'lo') = 'string' AND jsonb_typeof(VALUE -> 'hi') = 'string' - AND VALUE->>'v' = '{{eql_version}}' + -- `"v": null` makes the comparison NULL, and a CHECK that is NULL + -- passes, so COALESCE refuses it. A NULL range still passes (no row + -- matches it). The comparison keeps the one form the v4 isolation + -- gate accepts. + AND COALESCE(VALUE->>'v' = '{{eql_version}}', VALUE IS NULL) ); END IF; diff --git a/packages/eql/src/v3/scalars/numeric/query_numeric_range_types.sql b/packages/eql/src/v3/scalars/numeric/query_numeric_range_types.sql index 424526b59..2bd28ed5b 100644 --- a/packages/eql/src/v3/scalars/numeric/query_numeric_range_types.sql +++ b/packages/eql/src/v3/scalars/numeric/query_numeric_range_types.sql @@ -46,7 +46,11 @@ BEGIN AND jsonb_array_length(VALUE -> 'lo') > 0 AND jsonb_typeof(VALUE -> 'hi') = 'array' AND jsonb_array_length(VALUE -> 'hi') > 0 - AND VALUE->>'v' = '{{eql_version}}' + -- `"v": null` makes the comparison NULL, and a CHECK that is NULL + -- passes, so COALESCE refuses it. A NULL range still passes (no row + -- matches it). The comparison keeps the one form the v4 isolation + -- gate accepts. + AND COALESCE(VALUE->>'v' = '{{eql_version}}', VALUE IS NULL) ); END IF; @@ -67,7 +71,11 @@ BEGIN AND NOT (VALUE ? 'c') AND jsonb_typeof(VALUE -> 'lo') = 'string' AND jsonb_typeof(VALUE -> 'hi') = 'string' - AND VALUE->>'v' = '{{eql_version}}' + -- `"v": null` makes the comparison NULL, and a CHECK that is NULL + -- passes, so COALESCE refuses it. A NULL range still passes (no row + -- matches it). The comparison keeps the one form the v4 isolation + -- gate accepts. + AND COALESCE(VALUE->>'v' = '{{eql_version}}', VALUE IS NULL) ); END IF; diff --git a/packages/eql/src/v3/scalars/real/query_real_range_types.sql b/packages/eql/src/v3/scalars/real/query_real_range_types.sql index 7a0f2c851..b9508d140 100644 --- a/packages/eql/src/v3/scalars/real/query_real_range_types.sql +++ b/packages/eql/src/v3/scalars/real/query_real_range_types.sql @@ -46,7 +46,11 @@ BEGIN AND jsonb_array_length(VALUE -> 'lo') > 0 AND jsonb_typeof(VALUE -> 'hi') = 'array' AND jsonb_array_length(VALUE -> 'hi') > 0 - AND VALUE->>'v' = '{{eql_version}}' + -- `"v": null` makes the comparison NULL, and a CHECK that is NULL + -- passes, so COALESCE refuses it. A NULL range still passes (no row + -- matches it). The comparison keeps the one form the v4 isolation + -- gate accepts. + AND COALESCE(VALUE->>'v' = '{{eql_version}}', VALUE IS NULL) ); END IF; @@ -67,7 +71,11 @@ BEGIN AND NOT (VALUE ? 'c') AND jsonb_typeof(VALUE -> 'lo') = 'string' AND jsonb_typeof(VALUE -> 'hi') = 'string' - AND VALUE->>'v' = '{{eql_version}}' + -- `"v": null` makes the comparison NULL, and a CHECK that is NULL + -- passes, so COALESCE refuses it. A NULL range still passes (no row + -- matches it). The comparison keeps the one form the v4 isolation + -- gate accepts. + AND COALESCE(VALUE->>'v' = '{{eql_version}}', VALUE IS NULL) ); END IF; diff --git a/packages/eql/src/v3/scalars/smallint/query_smallint_range_types.sql b/packages/eql/src/v3/scalars/smallint/query_smallint_range_types.sql index 36258ec97..74ec90df4 100644 --- a/packages/eql/src/v3/scalars/smallint/query_smallint_range_types.sql +++ b/packages/eql/src/v3/scalars/smallint/query_smallint_range_types.sql @@ -46,7 +46,11 @@ BEGIN AND jsonb_array_length(VALUE -> 'lo') > 0 AND jsonb_typeof(VALUE -> 'hi') = 'array' AND jsonb_array_length(VALUE -> 'hi') > 0 - AND VALUE->>'v' = '{{eql_version}}' + -- `"v": null` makes the comparison NULL, and a CHECK that is NULL + -- passes, so COALESCE refuses it. A NULL range still passes (no row + -- matches it). The comparison keeps the one form the v4 isolation + -- gate accepts. + AND COALESCE(VALUE->>'v' = '{{eql_version}}', VALUE IS NULL) ); END IF; @@ -67,7 +71,11 @@ BEGIN AND NOT (VALUE ? 'c') AND jsonb_typeof(VALUE -> 'lo') = 'string' AND jsonb_typeof(VALUE -> 'hi') = 'string' - AND VALUE->>'v' = '{{eql_version}}' + -- `"v": null` makes the comparison NULL, and a CHECK that is NULL + -- passes, so COALESCE refuses it. A NULL range still passes (no row + -- matches it). The comparison keeps the one form the v4 isolation + -- gate accepts. + AND COALESCE(VALUE->>'v' = '{{eql_version}}', VALUE IS NULL) ); END IF; diff --git a/packages/eql/src/v3/scalars/text/query_text_range_types.sql b/packages/eql/src/v3/scalars/text/query_text_range_types.sql index 3d7ff3196..4b1fb707f 100644 --- a/packages/eql/src/v3/scalars/text/query_text_range_types.sql +++ b/packages/eql/src/v3/scalars/text/query_text_range_types.sql @@ -46,7 +46,11 @@ BEGIN AND jsonb_array_length(VALUE -> 'lo') > 0 AND jsonb_typeof(VALUE -> 'hi') = 'array' AND jsonb_array_length(VALUE -> 'hi') > 0 - AND VALUE->>'v' = '{{eql_version}}' + -- `"v": null` makes the comparison NULL, and a CHECK that is NULL + -- passes, so COALESCE refuses it. A NULL range still passes (no row + -- matches it). The comparison keeps the one form the v4 isolation + -- gate accepts. + AND COALESCE(VALUE->>'v' = '{{eql_version}}', VALUE IS NULL) ); END IF; @@ -67,7 +71,11 @@ BEGIN AND NOT (VALUE ? 'c') AND jsonb_typeof(VALUE -> 'lo') = 'string' AND jsonb_typeof(VALUE -> 'hi') = 'string' - AND VALUE->>'v' = '{{eql_version}}' + -- `"v": null` makes the comparison NULL, and a CHECK that is NULL + -- passes, so COALESCE refuses it. A NULL range still passes (no row + -- matches it). The comparison keeps the one form the v4 isolation + -- gate accepts. + AND COALESCE(VALUE->>'v' = '{{eql_version}}', VALUE IS NULL) ); END IF; diff --git a/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_types.sql b/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_types.sql index edc011ee3..795b8f267 100644 --- a/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_types.sql +++ b/packages/eql/src/v3/scalars/timestamp/query_timestamp_range_types.sql @@ -46,7 +46,11 @@ BEGIN AND jsonb_array_length(VALUE -> 'lo') > 0 AND jsonb_typeof(VALUE -> 'hi') = 'array' AND jsonb_array_length(VALUE -> 'hi') > 0 - AND VALUE->>'v' = '{{eql_version}}' + -- `"v": null` makes the comparison NULL, and a CHECK that is NULL + -- passes, so COALESCE refuses it. A NULL range still passes (no row + -- matches it). The comparison keeps the one form the v4 isolation + -- gate accepts. + AND COALESCE(VALUE->>'v' = '{{eql_version}}', VALUE IS NULL) ); END IF; @@ -67,7 +71,11 @@ BEGIN AND NOT (VALUE ? 'c') AND jsonb_typeof(VALUE -> 'lo') = 'string' AND jsonb_typeof(VALUE -> 'hi') = 'string' - AND VALUE->>'v' = '{{eql_version}}' + -- `"v": null` makes the comparison NULL, and a CHECK that is NULL + -- passes, so COALESCE refuses it. A NULL range still passes (no row + -- matches it). The comparison keeps the one form the v4 isolation + -- gate accepts. + AND COALESCE(VALUE->>'v' = '{{eql_version}}', VALUE IS NULL) ); END IF; diff --git a/packages/eql/tests/encryption/tests/v4_range.rs b/packages/eql/tests/encryption/tests/v4_range.rs index 488f23672..e5038b01d 100644 --- a/packages/eql/tests/encryption/tests/v4_range.rs +++ b/packages/eql/tests/encryption/tests/v4_range.rs @@ -628,6 +628,16 @@ fn postgres_range_domains_check_their_shape() { "query_integer_range_ore", "EQL v4 only", ), + ( + with(&ore, &|v| v["v"] = json!(null)), + "query_integer_range_ore", + "a JSON null version is not v4", + ), + ( + with(&ope, &|v| v["v"] = json!(null)), + "query_integer_range_ope", + "a JSON null version is not v4", + ), ( with(&ore, &|v| { v.as_object_mut().unwrap().remove("hi");