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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Changed

- **EQL v3 (searchable encryption)**: Proxy now targets EQL v3. Encrypted columns are declared with self-configuring, typed `jsonb` domains (for example `eql_v3_text_search`, `eql_v3_integer_ord`, `eql_v3_json_search`) that encode both the scalar type and the column's searchable capabilities in the column type itself, replacing EQL v2's opaque `eql_v2_encrypted` composite type and its separate `eql_v2_configuration` table. The bundled `cipherstash-client` is upgraded to 0.42.0 and EQL to 3.0.2. Existing v2-encrypted data and schemas must be migrated to v3.

### Added

- **Encrypted full-text match with `@@`**: The `@@` operator is now supported on encrypted text columns whose domain carries a match (bloom-filter) term, rewritten to the EQL v3 `eql_v3.match_term` form.

### Fixed

- **`LIKE`/`ILIKE` capability checking**: `LIKE` and `ILIKE` on an encrypted column are now gated by the column's token-match capability. Previously these predicates bypassed capability checking and were silently accepted on columns that do not support fuzzy match; they are now rejected with a capability error.

## [2.2.4] - 2026-06-18

### Fixed
Expand Down
9 changes: 7 additions & 2 deletions packages/eql-mapper-macros/src/parse_type_decl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ impl Parse for EqlTrait {
Err(syn::Error::new(
input.span(),
format!(
"Expected Eq, Ord, TokenMatch or JsonLike while parsing EqlTrait; got: {}",
"Expected Eq, Ord, TokenMatch, JsonLike or Contain while parsing EqlTrait; got: {}",
input.cursor().token_stream()
),
))
Expand Down Expand Up @@ -477,6 +477,11 @@ impl Parse for SqltkBinOp {

if input.peek(token::At) {
let _: token::At = input.parse()?;
// `@@` (fuzzy match) or `@>` (containment).
if input.peek(token::At) {
let _: token::At = input.parse()?;
return Ok(Self(quote!(::sqltk::parser::ast::BinaryOperator::AtAt)));
}
let _: token::Gt = input.parse()?;
return Ok(Self(quote!(::sqltk::parser::ast::BinaryOperator::AtArrow)));
}
Expand Down Expand Up @@ -552,7 +557,7 @@ impl Parse for SqltkBinOp {

Err(syn::Error::new(
input.span(),
"Expected an operator corresponding to one of the EQL traits Eq, Ord, TokenMatch or JsonLike".to_string(),
"Expected an operator corresponding to one of the EQL traits Eq, Ord, TokenMatch, JsonLike or Contain".to_string(),
))
}
}
Expand Down
12 changes: 7 additions & 5 deletions packages/eql-mapper/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ Parses SQL, infers a type for every node, and rewrites statements that touch enc
columns into their EQL v3 equivalents. It knows nothing about the PostgreSQL wire
protocol, ZeroKMS, or ciphertext β€” it reasons about types and rewrites syntax.

> **Migration in flight.** The code still emits the EQL v2 surface
> (`eql_v2_encrypted` casts, `eql_v2.*` calls); the vocabulary below is the **v3
> target** the type-checker extension is being designed against. See
> [`docs/adr/`](./docs/adr/) for the load-bearing decisions and
> `docs/plans/2026-07-20-eql-v3-type-checker-handoff.md` for the impact maps.
> **v3 migration.** The mapper now emits the EQL **v3** surface β€” v3 domain casts
> (`::public.eql_v3_*` for stored values, `::eql_v3.query_*` for operands) and the
> `eql_v3.*` functional-index form (term-extraction functions, `eql_v3.jsonb_*`,
> `eql_v3."->"`, `match_term`). No `eql_v2.*` names remain in its output.
> End-to-end validation against a live database with EQL v3 installed is still
> pending. See [`docs/adr/`](./docs/adr/) for the load-bearing decisions and
> `docs/plans/2026-07-20-eql-v3-type-checker-handoff.md` for the original impact maps.

## Language

Expand Down
118 changes: 118 additions & 0 deletions packages/eql-mapper/docs/adr/0003-v3-rewrite-pipeline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
---
status: accepted
---

# The EQL v3 rewrite pipeline: term functions, cast targets, and operand context

ADR-0001 fixes *what* the mapper emits (the `eql_v3.*_term()` functional-index form);
this ADR fixes *how* the transformation pipeline produces it, because the v2β†’v3 change
is structural, not a find-and-replace of names.

## Two contexts, two cast targets

An encrypted value appears in a statement in one of two roles, and they cast differently:

- **Stored value** β€” an INSERT `VALUES` item or an UPDATE `SET` right-hand side. It casts
to the column domain, `'<ciphertext>'::jsonb::public.eql_v3_<token>_<cap>`, and is **not**
wrapped in a term function.
- **Query operand** β€” the right-hand side of a predicate (`col = $1`, `col > 'x'`). It casts
to the query twin, `'<ciphertext>'::jsonb::eql_v3.query_<token>_<cap>`, and the whole
predicate is rewritten through term functions (below).

The v2 pipeline did not need this distinction: every encrypted value cast to the single
opaque `eql_v2_encrypted`, and the opaque type carried its own operators. Under v3 the two
roles produce different SQL, so the pipeline must know a value's role.

**Decision:** the *type checker* records each encrypted literal/param's role while it walks
the AST (it already visits the INSERT/UPDATE targets and the predicate operands during
inference), and the transformation rules read that role. Re-deriving role from AST context
inside the transform is the fallback if threading it through inference proves awkward, but
the inference pass is where the context is already known.

## Operator rewriting

A comparison with an encrypted operand is rewritten by wrapping **both** operands in the
term function the operator's capability selects:

```
col <op> operand β†’ eql_v3.<term>(col) <op> eql_v3.<term>(operand)
```

The term function is chosen by `(operator, the terms the column's domain stores)` β€” verified
against the `eql_v3.eq`/`lt`/… bodies in the installed `cipherstash-encrypt.sql`:

| Operator | Term function |
|---|---|
| `=` `<>` | `eq_term` **if the domain stores `hm`**, else `ord_term` (`op`), else `ord_term_ore` (`ob`) |
| `<` `<=` `>` `>=` | `ord_term` (domain stores `op`) / `ord_term_ore` (domain stores `ob`) |
| `@@` | `match_term` (domain stores `bf`) |

Two verified subtleties:

- **`=` is not always `eq_term`.** A term-extraction function exists exactly where its term
does: `eq_term` only on domains storing `hm` (`_eq`, `_search*`, and β€” the text exception β€”
`text_ord*`). On an ord-only scalar such as `integer_ord` there is no `hm`, so `eql_v3.eq`
itself is `ord_term(a) = ord_term(b)`. The mapper mirrors this: `=`/`<>` fall back to the
ordering term when the domain has no `hm`.
- **`ord_term` vs `ord_term_ore`** is not derivable from the coarse `Ord` trait β€” it comes
from the domain identity (ADR-0002): a `*_ord` / `*_ord_ope` domain stores `op` β‡’
`ord_term`; a `*_ord_ore` / `*_search_ore` domain stores `ob` β‡’ `ord_term_ore`.

Which terms a domain stores is recoverable from its typname (with the text `hm` exception);
the mapper derives them there rather than re-consulting `eql-bindings`.

### Operand cast target β€” the query twin

The right-hand operand casts to the **query twin** `eql_v3.query_<token>_<cap>` (schema
`eql_v3`), e.g. a `public.eql_v3_integer_ord` column's operand casts to
`eql_v3.query_integer_ord`. Verified: the twins exist for every scalar domain, carry the
**term-only** payload (`{v,i,<terms>}`, no stored ciphertext `c`) that a query value actually
is, and have their own `ord_term`/`eq_term` overloads. So:

```
salary > 'x' β†’ eql_v3.ord_term(salary) > eql_v3.ord_term('<ct>'::jsonb::eql_v3.query_numeric_ord)
```

The column operand needs no cast (it is already the domain type); only the query operand is
cast, and to the twin β€” **not** the column domain, whose CHECK requires the ciphertext a
query value does not carry. (`eql_v3.eq(domain, jsonb)` casting to the column domain is a
separate convenience overload, not what the mapper emits.)

**Selecting the term function *is* the capability check.** A column whose domain provides no
term function for the operator (e.g. `ORDER BY` / `>` on an `_eq` column, any operator on a
storage-only `boolean`/`json` column) has no valid rewrite target β€” that absence *is* the
capability error the type checker raises. This keeps one mechanism, not a separate bounds
check bolted on.

## JSON is different (see ADR-0002 amendment)

Encrypted JSON columns (`eql_v3_json_search`) keep `->`/`->>` (JsonLike) **and** `@>`/`<@`
(Contain) β€” verified against the installed `cipherstash-encrypt.sql`. `Contain` is therefore
retained as a JSON-only capability and its rewrite targets the SteVec containment surface
(`eql_v3.to_ste_vec_query` + `@>`), **not** deleted. `@>`/`<@` on *scalar* encrypted columns
still have no term/rewrite and so raise.

## Rule inventory under v3

- `CastLiteralsAsEncrypted` / `CastParamsAsEncrypted` β€” retained; the cast target moves from
`eql_v2_encrypted` to the role-appropriate v3 domain (column domain vs query twin). These
gain access to each node's domain identity (via `node_types`) to name the target.
- **`RewriteEqlComparisonOps`** (new) β€” wraps scalar comparison operands in term functions
and performs the capability check. Models its node handling on `RewriteContainmentOps`
(`mem::replace` against a throwaway `Value::Null` to preserve operand `NodeKey` identity
for the cast rules that run after).
- `RewriteContainmentOps` β€” **retargeted, not retired**: `@>`/`<@` on JSON columns rewrite to
the v3 SteVec containment surface; on scalar columns they raise.
- `RewriteStandardSqlFnsOnEqlTypes` β€” retargeted from `eql_v2.{min,max,jsonb_*}` to the v3
surface. Whether some of these become native overload resolution (and the rule shrinks) is
gated on v3 shipping operator/function overloads bound to the domains; verify per function.
- `PreserveEffectiveAliases`, `FailOnPlaceholderChange` β€” unchanged.

## Consequences

- The pipeline gains a stored-vs-operand notion it did not have; this is the load-bearing new
concept, and getting it wrong casts an operand to a column domain (or vice versa).
- Bound checking goes live here: the term-function selection raising on an absent capability
is the user-visible capability error, so the ADR-0001 "let the database do its job" stance
is refined β€” the mapper raises when there is no valid rewrite, rather than emitting SQL that
would fail at the database.
29 changes: 22 additions & 7 deletions packages/eql-mapper/src/inference/infer_type_impls/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::{
EqlTrait, IdentCase, TypeInferencer,
};
use eql_mapper_macros::trace_infer;
use sqltk::parser::ast::{AccessExpr, Array, Expr, Ident, Subscript};
use sqltk::parser::ast::{AccessExpr, Array, BinaryOperator, Expr, Ident, Subscript};

#[trace_infer]
impl<'ast> InferType<'ast, Expr> for TypeInferencer<'ast> {
Expand Down Expand Up @@ -112,23 +112,38 @@ impl<'ast> InferType<'ast, Expr> for TypeInferencer<'ast> {
get_sql_binop_rule(op).apply_constraints(self, left, right, expr_val)?;
}

//customer_name LIKE 'A%';
// `customer_name LIKE 'A%'`. Route LIKE/ILIKE through the `~~`/`~~*`
// operator rules so an encrypted LHS must implement `TokenMatch` (the
// pattern becomes its `Tokenized` type, the result is `Native`).
// Previously this only unified the result with `Native`, so LIKE on an
// encrypted column bypassed capability checking entirely.
Expr::Like {
negated: _,
negated,
expr,
pattern,
escape_char: _,
any: false,
} => {
let op = if *negated {
BinaryOperator::PGNotLikeMatch
} else {
BinaryOperator::PGLikeMatch
};
get_sql_binop_rule(&op).apply_constraints(self, expr, pattern, expr_val)?;
}
| Expr::ILike {
negated: _,
Expr::ILike {
negated,
expr,
pattern,
escape_char: _,
any: false,
} => {
self.unify_node_with_type(expr_val, Type::native())?;
self.unify_nodes(&**expr, &**pattern)?;
let op = if *negated {
BinaryOperator::PGNotILikeMatch
} else {
BinaryOperator::PGILikeMatch
};
get_sql_binop_rule(&op).apply_constraints(self, expr, pattern, expr_val)?;
}

Expr::Like { any: true, .. } | Expr::ILike { any: true, .. } => {
Expand Down
43 changes: 32 additions & 11 deletions packages/eql-mapper/src/inference/sql_types/sql_decls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ static SQL_BINARY_OPERATORS: LazyLock<HashMap<BinaryOperator, BinaryOpDecl>> =
<T>(T !~~ <T as TokenMatch>::Tokenized) -> Native where T: TokenMatch; // NOT LIKE
<T>(T ~~* <T as TokenMatch>::Tokenized) -> Native where T: TokenMatch; // ILIKE
<T>(T !~~* <T as TokenMatch>::Tokenized) -> Native where T: TokenMatch; // NOT ILIKE
<T>(T @@ <T as TokenMatch>::Tokenized) -> Native where T: TokenMatch; // @@ fuzzy match
};
ops.into_iter()
.map(|binary_op_spec| (binary_op_spec.op.clone(), binary_op_spec))
Expand Down Expand Up @@ -65,17 +66,20 @@ static SQL_FUNCTION_TYPES: LazyLock<HashMap<IdentCase<ObjectName>, FunctionDecl>
pg_catalog.jsonb_array_length<T>(T) -> Native where T: JsonLike;
pg_catalog.jsonb_array_elements<T>(T) -> SetOf<T> where T: JsonLike;
pg_catalog.jsonb_array_elements_text<T>(T) -> SetOf<T> where T: JsonLike;
eql_v2.min<T>(T) -> T where T: Ord;
eql_v2.max<T>(T) -> T where T: Ord;
eql_v2.jsonb_path_query<T>(T, <T as JsonLike>::Path) -> T where T: JsonLike;
eql_v2.jsonb_path_query_first<T>(T, <T as JsonLike>::Path) -> T where T: JsonLike;
eql_v2.jsonb_path_exists<T>(T, <T as JsonLike>::Path) -> Native where T: JsonLike;
eql_v2.jsonb_array_length<T>(T) -> Native where T: JsonLike;
eql_v2.jsonb_array_elements<T>(T) -> SetOf<T> where T: JsonLike;
eql_v2.jsonb_array_elements_text<T>(T) -> SetOf<T> where T: JsonLike;
eql_v2.jsonb_array<T>(T) -> Native where T: Contain;
eql_v2.jsonb_contains<T>(T, T) -> Native where T: Contain;
eql_v2.jsonb_contained_by<T>(T, T) -> Native where T: Contain;
eql_v3.min<T>(T) -> T where T: Ord;
eql_v3.max<T>(T) -> T where T: Ord;
eql_v3.jsonb_path_query<T>(T, <T as JsonLike>::Path) -> T where T: JsonLike;
eql_v3.jsonb_path_query_first<T>(T, <T as JsonLike>::Path) -> T where T: JsonLike;
eql_v3.jsonb_path_exists<T>(T, <T as JsonLike>::Path) -> Native where T: JsonLike;
eql_v3.jsonb_array_length<T>(T) -> Native where T: JsonLike;
eql_v3.jsonb_array_elements<T>(T) -> SetOf<T> where T: JsonLike;
eql_v3.jsonb_array_elements_text<T>(T) -> SetOf<T> where T: JsonLike;
// JSON containment (`@>`/`<@`) β€” retained in v3, scoped to JSON
// columns (ADR-0002 amendment). `@>`/`<@` on scalar encrypted columns
// still raise.
eql_v3.jsonb_array<T>(T) -> Native where T: Contain;
eql_v3.jsonb_contains<T>(T, T) -> Native where T: Contain;
eql_v3.jsonb_contained_by<T>(T, T) -> Native where T: Contain;
};

HashMap::from_iter(
Expand All @@ -102,6 +106,23 @@ pub(crate) fn get_sql_function(fn_name: &ObjectName) -> SqlFunction {
.unwrap_or(SqlFunction::Fallback)
}

/// The `eql_v3.<name>` counterpart a `pg_catalog` function is rewritten to on EQL
/// types, or `None` if none is declared. `count`, for example, works on encrypted
/// values natively (Postgres counts the domain directly), so it has no counterpart
/// and is left untouched.
pub(crate) fn get_eql_v3_function_name(fn_name: &ObjectName) -> Option<ObjectName> {
let bare = fn_name.0.last()?;
let eql_v3_name = ObjectName(vec![
ObjectNamePart::Identifier(Ident::new("eql_v3")),
bare.clone(),
]);
if SQL_FUNCTION_TYPES.contains_key(&IdentCase(eql_v3_name.clone())) {
Some(eql_v3_name)
} else {
None
}
}

#[cfg(test)]
mod tests {
use crate::inference::sql_types::sql_decls::{SQL_BINARY_OPERATORS, SQL_FUNCTION_TYPES};
Expand Down
Loading