Skip to content

Commit 06504bb

Browse files
authored
Merge pull request #1446 from cipherstash/feat/eql-v4-json-tree-match
feat(eql): one-parameter jsonpath predicates on json_tree documents (json_tree_match, @@, match)
2 parents 82b562d + c6da7e2 commit 06504bb

28 files changed

Lines changed: 4751 additions & 133 deletions

File tree

‎.changeset/eql-json-tree-match.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@cipherstash/eql": minor
3+
---
4+
5+
EQL v4 `json_tree`: a whole filter as one parameter, `eql_v4.json_tree_match(col, $1::eql_v4.query_json_predicate)` and `col @@ $1::eql_v4.query_json_predicate` (#1441).
6+
7+
An application that compiles a filter language into SQL targets `jsonb_path_match(col, $1, $2)` on a plaintext column: one predicate, one set of values. On a `public.eql_v4_json_search` column each question is its own operator, so the same filter had to be assembled by hand, one operand per leaf. The new domain `eql_v4.query_json_predicate` is a boolean tree (`and`, `or`, `not`) whose leaves are the operands those operators already take (a `contains` needle, an `exists` selector, `cmp` ordering bounds and an `elements` predicate), each checked by its own domain's rule, and `eql_v4.json_tree_match` evaluates it with SQL's three-valued logic, the answer the hand-written WHERE gives. Stack Encrypt's `match` query operator compiles a jsonpath predicate in the subset the terms can answer into the tree. The tree is evaluated inside a function, so it filters a scan and engages no index; the operators written out per leaf are the fast path. The operand must carry its type: an untyped operand on the right of `@@` is refused as ambiguous, and `@@` with a `jsonpath` is still blocked.
8+
9+
`eql_v4.json_tree_element_match` now reads an opened document through `eql_v4_internal.json_tree_element_match_opened`, which `json_tree_match` shares; its answers are unchanged.

‎languages/golang/encrypt/eql/eql_gen.go‎

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎languages/golang/encrypt/guest/src/targets.rs‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,35 @@ mod tests {
268268
));
269269
}
270270

271+
/// The JSON document's query domains this guest names, and so the JSON
272+
/// operators the Go SDK is offered: the leaf operand, for the orderings
273+
/// at a path, and not the element-scoped or compiled predicate, whose
274+
/// operators (`element_match`, `match`) the Go side does not parse yet.
275+
/// Naming one is a change to the Go SDK's operator list: update this
276+
/// test with the Go side that takes the operator.
277+
#[cfg(feature = "eql")]
278+
#[test]
279+
fn a_document_names_the_leaf_domain_and_not_the_predicate_domains() {
280+
let targets = resolver().targets();
281+
let document = targets
282+
.iter()
283+
.find(|t| t.name == "JsonSearch")
284+
.expect("JsonSearch");
285+
assert_eq!(
286+
document.leaf_query_sql_domain.as_deref(),
287+
Some("eql_v4.query_json_leaf")
288+
);
289+
assert_eq!(document.elements_query_sql_domain, None);
290+
assert_eq!(document.predicate_query_sql_domain, None);
291+
let operators = document.operators();
292+
for absent in [
293+
stack_encrypt::dynamic::OpSpec::ElementMatch,
294+
stack_encrypt::dynamic::OpSpec::Match,
295+
] {
296+
assert!(!operators.contains(&absent), "{absent:?}: {operators:?}");
297+
}
298+
}
299+
271300
#[cfg(feature = "eql")]
272301
#[test]
273302
fn eql_refusals_convert_to_the_engines_with_the_field_left_to_the_lowering() {

‎packages/eql/AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ Where the two versions' SQL must differ, the source holds both forms in **versio
6060

6161
#### Files confined to one EQL version
6262

63-
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). Three surfaces do today. The generated range surface: `src/v3/scalars/<T>/query_<T>_range_types.sql` and `query_<T>_range_functions.sql` hold the range query domains (`eql_v4.query_<T>_range_ore` / `_range_ope`), their `lo` / `hi` accessors and `eql_v4.between(column, range)` (ADR-0009 decision 7). And the hand-written JSON tree family under `src/v3/json_tree/` (`types.sql`, `functions.sql`, `operators.sql`, `elements.sql`, `blockers.sql`): `public.eql_v4_json_search`, `public.eql_v4_json_entry`, `eql_v4.query_json`, `eql_v4.query_json_leaf` and `eql_v4.query_json_elements`, the document Stack Encrypt's JSON index writes, under the names v3 gives its searchable JSON document but not a port of the SteVec surface (design: `docs/plans/2026-10-17-json-tree-index.md` at the repository root; bindings: `crates/eql-bindings/src/v4/json_tree.rs`, hand-written like the SteVec structs; catalog: `eql_domains::DOCUMENT_FAMILIES`, whose stored type is the target `JsonSearch`). Both are `-- ONLY EQL VERSION: 4`. The third surface goes the other way: the SteVec SQL under `src/v3/json/` and the generated `json_entry_<T>` cross files under `src/v3/scalars/<T>/` are `-- ONLY EQL VERSION: 3`, because in v4 their domain names belong to the JSON tree family. EQL v3 is what cipherstash-client writes, nothing there produces a range or a JSON tree document, and its SQL must not change: the v3 bundle differs from an unconfined build only by those directive comment lines. `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 files confined to either version; the per-version lists are `build/render/eql_v<N>/deps-ordered.txt`.
63+
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). Three surfaces do today. The generated range surface: `src/v3/scalars/<T>/query_<T>_range_types.sql` and `query_<T>_range_functions.sql` hold the range query domains (`eql_v4.query_<T>_range_ore` / `_range_ope`), their `lo` / `hi` accessors and `eql_v4.between(column, range)` (ADR-0009 decision 7). And the hand-written JSON tree family under `src/v3/json_tree/` (`types.sql`, `functions.sql`, `operators.sql`, `elements.sql`, `predicate.sql`, `blockers.sql`): `public.eql_v4_json_search`, `public.eql_v4_json_entry`, `eql_v4.query_json`, `eql_v4.query_json_leaf`, `eql_v4.query_json_elements` and `eql_v4.query_json_predicate`, the document Stack Encrypt's JSON index writes, under the names v3 gives its searchable JSON document but not a port of the SteVec surface (design: `docs/plans/2026-10-17-json-tree-index.md` at the repository root; bindings: `crates/eql-bindings/src/v4/json_tree.rs`, hand-written like the SteVec structs; catalog: `eql_domains::DOCUMENT_FAMILIES`, whose stored type is the target `JsonSearch`). Both are `-- ONLY EQL VERSION: 4`. The third surface goes the other way: the SteVec SQL under `src/v3/json/` and the generated `json_entry_<T>` cross files under `src/v3/scalars/<T>/` are `-- ONLY EQL VERSION: 3`, because in v4 their domain names belong to the JSON tree family. EQL v3 is what cipherstash-client writes, nothing there produces a range or a JSON tree document, and its SQL must not change: the v3 bundle differs from an unconfined build only by those directive comment lines. `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 files confined to either version; the per-version lists are `build/render/eql_v<N>/deps-ordered.txt`.
6464

6565
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.
6666

‎packages/eql/crates/eql-bindings/src/encryption/targets.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,18 @@ impl Target {
164164
.map(|_| crate::v4::json_tree::JsonTreeElementsQuery::sql_domain_static())
165165
}
166166

167+
/// The PostgreSQL domain of the type's compiled predicate, for a type
168+
/// that stores a JSON document (`JsonSearch`:
169+
/// `eql_v4.query_json_predicate`); `None` for every other type. Stack
170+
/// Encrypt binds `match` as it
171+
/// (`TargetDescriptor::with_predicate_query_sql_domain`), and answers
172+
/// `match` only on a type that names it.
173+
pub fn predicate_query_sql_domain(&self) -> Option<&'static str> {
174+
use crate::v4::DomainType;
175+
document(self.name)
176+
.map(|_| crate::v4::json_tree::JsonTreePredicateQuery::sql_domain_static())
177+
}
178+
167179
/// The options this type's match index is built under, for a type that
168180
/// carries one (`TextMatch`): [`EqlMatch`](super::EqlMatch)'s, the one
169181
/// match configuration every EQL type's filter is built under. `None`

‎packages/eql/crates/eql-bindings/src/v4/inventory.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ pub fn all_query() -> Vec<Box<dyn DomainType>> {
107107
Box::new(PhantomData::<super::json_tree::JsonTreeQuery>),
108108
Box::new(PhantomData::<super::json_tree::JsonTreeLeafQuery>),
109109
Box::new(PhantomData::<super::json_tree::JsonTreeElementsQuery>),
110+
Box::new(PhantomData::<super::json_tree::JsonTreePredicateQuery>),
110111
]
111112
}
112113
/// Every v4 RANGE query operand (`eql_v4.query_<family>_range_<term>`),

‎packages/eql/crates/eql-bindings/src/v4/json_tree.rs‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,57 @@ pub struct JsonTreeElementsQuery {
219219
pub m: Vec<JsonTreeElementsMember>,
220220
}
221221

222+
/// The bounds of an ordering at one path: one or two ordering operands at
223+
/// the path's selector, `gt` or `gte` the lower and `lt` or `lte` the upper.
224+
/// The `cmp` leaf of a [`JsonTreePredicateQuery`]. The domain's CHECK also
225+
/// holds what this type does not: at least one bound, at most one of each
226+
/// side, and every bound at one selector.
227+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS, JsonSchema)]
228+
#[serde(deny_unknown_fields)]
229+
pub struct JsonTreeBounds {
230+
#[serde(default, skip_serializing_if = "Option::is_none")]
231+
#[ts(optional)]
232+
pub gt: Option<JsonTreeLeafQuery>,
233+
#[serde(default, skip_serializing_if = "Option::is_none")]
234+
#[ts(optional)]
235+
pub gte: Option<JsonTreeLeafQuery>,
236+
#[serde(default, skip_serializing_if = "Option::is_none")]
237+
#[ts(optional)]
238+
pub lt: Option<JsonTreeLeafQuery>,
239+
#[serde(default, skip_serializing_if = "Option::is_none")]
240+
#[ts(optional)]
241+
pub lte: Option<JsonTreeLeafQuery>,
242+
}
243+
244+
/// `eql_v4.query_json_predicate`: a compiled predicate, a boolean tree
245+
/// whose leaves are the family's other query operands, which
246+
/// `eql_v4.json_tree_match` and `col @@ $1` evaluate. What Stack Encrypt's
247+
/// `match` operator compiles a jsonpath predicate into. Each node is an
248+
/// object of one key naming its kind: `{"and": [node, …]}`, `{"or": […]}`,
249+
/// `{"not": node}`, `{"contains": needle}`, `{"exists": selector}`,
250+
/// `{"cmp": bounds}` and `{"elements": predicate}`. The domain's CHECK also
251+
/// holds what this type does not: at least one child under `and` and `or`.
252+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS, JsonSchema)]
253+
#[serde(rename_all = "snake_case", deny_unknown_fields)]
254+
pub enum JsonTreePredicateQuery {
255+
/// True when every child is; false when one is false; else NULL.
256+
And(Vec<JsonTreePredicateQuery>),
257+
/// True when one child is; false when every child is false; else NULL.
258+
Or(Vec<JsonTreePredicateQuery>),
259+
/// The child's negation, NULL of NULL.
260+
Not(Box<JsonTreePredicateQuery>),
261+
/// The document contains the needle, `col @> needle`.
262+
Contains(JsonTreeQuery),
263+
/// The document has a node at the path, `json_tree_path_exists`.
264+
Exists(JsonTreeSelector),
265+
/// The first entry at the bounds' selector against each bound,
266+
/// `(col -> s) > gt AND (col -> s) < lt`.
267+
Cmp(JsonTreeBounds),
268+
/// One element of an array satisfies every member,
269+
/// `json_tree_element_match`.
270+
Elements(JsonTreeElementsQuery),
271+
}
272+
222273
/// The entry `col -> selector` returned, with the selector that asked for
223274
/// it: opens to the scalar the entry holds, or to the whole document for a
224275
/// sealed root asked for at `$`. The only constructor,
@@ -330,6 +381,7 @@ json_tree_domain_type!(JsonTreeEntry, "public.eql_v4_json_entry");
330381
json_tree_domain_type!(JsonTreeQuery, "eql_v4.query_json");
331382
json_tree_domain_type!(JsonTreeLeafQuery, "eql_v4.query_json_leaf");
332383
json_tree_domain_type!(JsonTreeElementsQuery, "eql_v4.query_json_elements");
384+
json_tree_domain_type!(JsonTreePredicateQuery, "eql_v4.query_json_predicate");
333385

334386
// =============================================================================
335387
// Conversions to and from the engine's types, through their one wire form
@@ -594,6 +646,47 @@ mod tests {
594646
JsonTreeElementsQuery::sql_domain_static(),
595647
"eql_v4.query_json_elements"
596648
);
649+
assert_eq!(
650+
JsonTreePredicateQuery::sql_domain_static(),
651+
"eql_v4.query_json_predicate"
652+
);
653+
}
654+
655+
#[test]
656+
fn a_predicate_query_is_a_tree_of_one_key_nodes() {
657+
let leaf = serde_json::json!({"s": b64(0xaa), "op": "AQI="});
658+
let needle = serde_json::json!({"sv": [{"s": b64(1), "vs": b64(2)}], "el": []});
659+
let elements = serde_json::json!({"m": [{"t": [b64(1)], "gt": leaf}]});
660+
let query = serde_json::json!({"and": [
661+
{"exists": b64(0xaa)},
662+
{"not": {"cmp": {"gte": leaf, "lt": leaf}}},
663+
{"or": [{"contains": needle}, {"elements": elements}]},
664+
]});
665+
let parsed: JsonTreePredicateQuery = serde_json::from_value(query.clone()).unwrap();
666+
let JsonTreePredicateQuery::And(children) = &parsed else {
667+
panic!("the root is an and: {parsed:?}")
668+
};
669+
assert!(
670+
matches!(children[0], JsonTreePredicateQuery::Exists(_)),
671+
"{children:?}"
672+
);
673+
assert_eq!(
674+
serde_json::to_value(&parsed).unwrap(),
675+
query,
676+
"a tree reads and writes as itself"
677+
);
678+
for wrong in [
679+
serde_json::json!({"exists": b64(1), "not": {"exists": b64(1)}}),
680+
serde_json::json!({"xor": []}),
681+
serde_json::json!({"cmp": {"eq": leaf}}),
682+
serde_json::json!({"exists": 1}),
683+
serde_json::json!([]),
684+
] {
685+
assert!(
686+
serde_json::from_value::<JsonTreePredicateQuery>(wrong.clone()).is_err(),
687+
"{wrong} should not deserialize"
688+
);
689+
}
597690
}
598691

599692
#[test]

‎packages/eql/crates/eql-codegen/src/bindings.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,6 +1084,7 @@ pub fn render_v4_inventory_rs() -> String {
10841084
eql_domains::DocumentRole::Query,
10851085
eql_domains::DocumentRole::LeafQuery,
10861086
eql_domains::DocumentRole::ElementsQuery,
1087+
eql_domains::DocumentRole::PredicateQuery,
10871088
]);
10881089
quote! { #query_entries #documents }
10891090
};

‎packages/eql/crates/eql-codegen/src/go_eql.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,9 @@ fn document_types(out: &mut String) {
189189
DocumentRole::ElementsQuery => {
190190
"element-scoped predicate, which eql_v4.json_tree_element_match tests"
191191
}
192+
DocumentRole::PredicateQuery => {
193+
"compiled predicate, a boolean tree of the other operands, which eql_v4.json_tree_match and `@@` evaluate"
194+
}
192195
DocumentRole::Entry => {
193196
"entry: one node of a document, as `->` and the walks return it"
194197
}
@@ -275,6 +278,7 @@ mod tests {
275278
"JSONSearchQuery",
276279
"JSONSearchLeafQuery",
277280
"JSONSearchElementsQuery",
281+
"JSONSearchPredicateQuery",
278282
"JSONEntry",
279283
] {
280284
assert_eq!(

‎packages/eql/crates/eql-domains/src/lib.rs‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,9 @@ pub enum DocumentRole {
567567
/// The element-scoped predicate: fields of one array element, each
568568
/// with its read tokens and its bounds.
569569
ElementsQuery,
570+
/// The compiled predicate: a boolean tree whose leaves are the other
571+
/// query operands.
572+
PredicateQuery,
570573
/// One node, as `->` and the walks return it.
571574
Entry,
572575
}
@@ -625,8 +628,10 @@ impl DocumentFamily {
625628
/// `JsonSearch` over `public.eql_v4_json_search`, its needle
626629
/// `JsonSearchQuery` (`eql_v4.query_json`), its ordering operand
627630
/// `JsonSearchLeafQuery` (`eql_v4.query_json_leaf`), its element-scoped
628-
/// predicate `JsonSearchElementsQuery` (`eql_v4.query_json_elements`) and
629-
/// its entry `JsonEntry` (`public.eql_v4_json_entry`). The storage-only `Json`
631+
/// predicate `JsonSearchElementsQuery` (`eql_v4.query_json_elements`), its
632+
/// compiled predicate `JsonSearchPredicateQuery`
633+
/// (`eql_v4.query_json_predicate`) and its entry `JsonEntry`
634+
/// (`public.eql_v4_json_entry`). The storage-only `Json`
630635
/// (`public.eql_v4_json`) is the `json` family's. The Rust structs keep the
631636
/// implementation's name, as v3's `SteVecDocument` does.
632637
pub const JSON_TREE: DocumentFamily = DocumentFamily {
@@ -658,6 +663,12 @@ pub const JSON_TREE: DocumentFamily = DocumentFamily {
658663
rust: "JsonTreeElementsQuery",
659664
sql_domain: "eql_v4.query_json_elements",
660665
},
666+
DocumentDomain {
667+
role: DocumentRole::PredicateQuery,
668+
name: "JsonSearchPredicateQuery",
669+
rust: "JsonTreePredicateQuery",
670+
sql_domain: "eql_v4.query_json_predicate",
671+
},
661672
DocumentDomain {
662673
role: DocumentRole::Entry,
663674
name: "JsonEntry",

0 commit comments

Comments
 (0)