Skip to content

Commit 82b562d

Browse files
authored
Merge pull request #1445 from cipherstash/feat/eql-v4-json-tree-element-match
feat(eql): test equality and ranges on one array element of a json_tree document
2 parents 19946e8 + b7fa5a6 commit 82b562d

23 files changed

Lines changed: 2649 additions & 68 deletions

File tree

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 predicate on one array element, `eql_v4.json_tree_element_match(col, $1::eql_v4.query_json_elements)` (#1440).
6+
7+
`exists($.contacts[*] ? (@.relationship == "Sibling" && @.calls > 5))` asks for one contact that is both. On a `public.eql_v4_json_search` column the nearest query, `@>` for the relationship and an ordering over `eql_v4.json_tree_path_query` for the calls, can be answered by two different contacts, so it returned more rows than the plaintext query. The new function answers the element-scoped question with one operand, the new domain `eql_v4.query_json_elements`, which Stack Encrypt's `element_match` query operator derives: for each field of the element, the tokens of a subtree read of its path and its bounds. Every field must be at a path the column orders, and equality is the ordering term's, so text compares case- and accent-folded and integers past 2^53 can tie. A row whose array is longer than the bound the operand was derived to raises `invalid_parameter_value` rather than answer over part of the array.
8+
9+
`eql_v4.json_tree_subtree_indexed(col, tokens, budget)` is the subtree read with `k`, the place in `tokens` of the token whose node each row sits in, which pairs the fields of one element. `eql_v4.json_tree_subtree` now returns its rows without `k`, and is otherwise 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.

‎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`, `blockers.sql`): `public.eql_v4_json_search`, `public.eql_v4_json_entry`, `eql_v4.query_json` and `eql_v4.query_json_leaf`, 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`, `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`.
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
@@ -152,6 +152,18 @@ impl Target {
152152
document(self.name).map(|_| crate::v4::json_tree::JsonTreeLeafQuery::sql_domain_static())
153153
}
154154

155+
/// The PostgreSQL domain of the type's element-scoped predicate, for a
156+
/// type that stores a JSON document (`JsonSearch`:
157+
/// `eql_v4.query_json_elements`); `None` for every other type. Stack
158+
/// Encrypt binds `element_match` as it
159+
/// (`TargetDescriptor::with_elements_query_sql_domain`), and answers
160+
/// `element_match` only on a type that names it.
161+
pub fn elements_query_sql_domain(&self) -> Option<&'static str> {
162+
use crate::v4::DomainType;
163+
document(self.name)
164+
.map(|_| crate::v4::json_tree::JsonTreeElementsQuery::sql_domain_static())
165+
}
166+
155167
/// The options this type's match index is built under, for a type that
156168
/// carries one (`TextMatch`): [`EqlMatch`](super::EqlMatch)'s, the one
157169
/// 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
@@ -106,6 +106,7 @@ pub fn all_query() -> Vec<Box<dyn DomainType>> {
106106
Box::new(PhantomData::<super::double::DoubleOrdOpeQuery>),
107107
Box::new(PhantomData::<super::json_tree::JsonTreeQuery>),
108108
Box::new(PhantomData::<super::json_tree::JsonTreeLeafQuery>),
109+
Box::new(PhantomData::<super::json_tree::JsonTreeElementsQuery>),
109110
]
110111
}
111112
/// Every v4 RANGE query operand (`eql_v4.query_<family>_range_<term>`),

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

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,48 @@ pub struct JsonTreeLeafQuery {
177177
pub op: JsonTreeOrderingTerm,
178178
}
179179

180+
/// A read token, in [`Base64Text`]: sixteen bytes a client derives from a
181+
/// concrete path alone, which `eql_v4.json_tree_subtree` and its indexed
182+
/// form take to find the node at that path in every row.
183+
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, TS, JsonSchema)]
184+
pub struct JsonTreeToken(pub Base64Text);
185+
186+
/// One member of an element-scoped predicate: the tokens of a read of one
187+
/// field's path through the array's wildcard, one per element below the
188+
/// bound in element order and then the probe, and the field's bounds,
189+
/// each an ordering operand. `gt` or `gte` is the lower bound, `lt` or
190+
/// `lte` the upper; equality is `gte` and `lte` at one value.
191+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS, JsonSchema)]
192+
#[serde(deny_unknown_fields)]
193+
pub struct JsonTreeElementsMember {
194+
pub t: Vec<JsonTreeToken>,
195+
#[serde(default, skip_serializing_if = "Option::is_none")]
196+
#[ts(optional)]
197+
pub gt: Option<JsonTreeLeafQuery>,
198+
#[serde(default, skip_serializing_if = "Option::is_none")]
199+
#[ts(optional)]
200+
pub gte: Option<JsonTreeLeafQuery>,
201+
#[serde(default, skip_serializing_if = "Option::is_none")]
202+
#[ts(optional)]
203+
pub lt: Option<JsonTreeLeafQuery>,
204+
#[serde(default, skip_serializing_if = "Option::is_none")]
205+
#[ts(optional)]
206+
pub lte: Option<JsonTreeLeafQuery>,
207+
}
208+
209+
/// `eql_v4.query_json_elements`: an element-scoped predicate, `{"m":
210+
/// [member, …]}`, true of a row where some one element of the array
211+
/// satisfies every member (`eql_v4.json_tree_element_match`). What Stack
212+
/// Encrypt's `element_match` operator derives. The domain's CHECK also
213+
/// holds what this type does not: at least one member, each with at least
214+
/// one bound and at most one of each side, all its bounds at one selector,
215+
/// and every member's tokens as many as every other's.
216+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS, JsonSchema)]
217+
#[serde(deny_unknown_fields)]
218+
pub struct JsonTreeElementsQuery {
219+
pub m: Vec<JsonTreeElementsMember>,
220+
}
221+
180222
/// The entry `col -> selector` returned, with the selector that asked for
181223
/// it: opens to the scalar the entry holds, or to the whole document for a
182224
/// sealed root asked for at `$`. The only constructor,
@@ -287,6 +329,7 @@ json_tree_domain_type!(JsonTreeDocument, "public.eql_v4_json_search");
287329
json_tree_domain_type!(JsonTreeEntry, "public.eql_v4_json_entry");
288330
json_tree_domain_type!(JsonTreeQuery, "eql_v4.query_json");
289331
json_tree_domain_type!(JsonTreeLeafQuery, "eql_v4.query_json_leaf");
332+
json_tree_domain_type!(JsonTreeElementsQuery, "eql_v4.query_json_elements");
290333

291334
// =============================================================================
292335
// Conversions to and from the engine's types, through their one wire form
@@ -547,6 +590,38 @@ mod tests {
547590
JsonTreeLeafQuery::sql_domain_static(),
548591
"eql_v4.query_json_leaf"
549592
);
593+
assert_eq!(
594+
JsonTreeElementsQuery::sql_domain_static(),
595+
"eql_v4.query_json_elements"
596+
);
597+
}
598+
599+
#[test]
600+
fn an_elements_query_is_members_of_tokens_and_bounds() {
601+
let leaf = serde_json::json!({"s": b64(0xaa), "op": "AQI="});
602+
let query = serde_json::json!({"m": [
603+
{"t": [b64(1), b64(2)], "gte": leaf, "lte": leaf},
604+
{"t": [b64(3), b64(4)], "gt": leaf},
605+
]});
606+
let parsed: JsonTreeElementsQuery = serde_json::from_value(query.clone()).unwrap();
607+
assert_eq!(parsed.m.len(), 2, "one member per field");
608+
assert!(parsed.m[1].lt.is_none(), "an absent bound is none");
609+
assert_eq!(
610+
serde_json::to_value(&parsed).unwrap(),
611+
query,
612+
"absent bounds are left out"
613+
);
614+
for wrong in [
615+
serde_json::json!({"m": [{"t": [b64(1)], "eq": leaf}]}),
616+
serde_json::json!({"m": [{"gt": leaf}]}),
617+
serde_json::json!({"m": [], "x": 1}),
618+
serde_json::json!({"m": [{"t": ["not base64"], "gt": leaf}]}),
619+
] {
620+
assert!(
621+
serde_json::from_value::<JsonTreeElementsQuery>(wrong.clone()).is_err(),
622+
"{wrong} should not deserialize"
623+
);
624+
}
550625
}
551626

552627
#[test]

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1061,7 +1061,7 @@ pub fn render_v4_inventory_rs() -> String {
10611061
})
10621062
.collect();
10631063
// The document families' hand-written types: the stored document in
1064-
// `all()`, its two query operands in `all_query()`. The entry `->`
1064+
// `all()`, its query operands in `all_query()`. The entry `->`
10651065
// returns is neither a column nor an operand, so it is in neither.
10661066
let document = |roles: &'static [eql_domains::DocumentRole]| -> TokenStream {
10671067
eql_domains::DOCUMENT_FAMILIES
@@ -1083,6 +1083,7 @@ pub fn render_v4_inventory_rs() -> String {
10831083
let documents = document(&[
10841084
eql_domains::DocumentRole::Query,
10851085
eql_domains::DocumentRole::LeafQuery,
1086+
eql_domains::DocumentRole::ElementsQuery,
10861087
]);
10871088
quote! { #query_entries #documents }
10881089
};

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,9 @@ fn document_types(out: &mut String) {
186186
DocumentRole::LeafQuery => {
187187
"ordering operand at a path, compared with an entry `->` extracts"
188188
}
189+
DocumentRole::ElementsQuery => {
190+
"element-scoped predicate, which eql_v4.json_tree_element_match tests"
191+
}
189192
DocumentRole::Entry => {
190193
"entry: one node of a document, as `->` and the walks return it"
191194
}
@@ -271,6 +274,7 @@ mod tests {
271274
"JSONSearch",
272275
"JSONSearchQuery",
273276
"JSONSearchLeafQuery",
277+
"JSONSearchElementsQuery",
274278
"JSONEntry",
275279
] {
276280
assert_eq!(

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -554,7 +554,7 @@ pub const CATALOG: &[DomainFamily] = &[
554554
];
555555

556556
/// The role a domain of a [`DocumentFamily`] plays: the column type, the
557-
/// two query operands, or the entry the SQL returns from a document.
557+
/// query operands, or the entry the SQL returns from a document.
558558
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
559559
pub enum DocumentRole {
560560
/// The stored document: the column type, and the target a data plan
@@ -564,6 +564,9 @@ pub enum DocumentRole {
564564
Query,
565565
/// The ordering operand at a path.
566566
LeafQuery,
567+
/// The element-scoped predicate: fields of one array element, each
568+
/// with its read tokens and its bounds.
569+
ElementsQuery,
567570
/// One node, as `->` and the walks return it.
568571
Entry,
569572
}
@@ -621,8 +624,9 @@ impl DocumentFamily {
621624
/// gives its own (`public.eql_v3_json_search`, the SteVec document):
622625
/// `JsonSearch` over `public.eql_v4_json_search`, its needle
623626
/// `JsonSearchQuery` (`eql_v4.query_json`), its ordering operand
624-
/// `JsonSearchLeafQuery` (`eql_v4.query_json_leaf`) and its entry
625-
/// `JsonEntry` (`public.eql_v4_json_entry`). The storage-only `Json`
627+
/// `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`
626630
/// (`public.eql_v4_json`) is the `json` family's. The Rust structs keep the
627631
/// implementation's name, as v3's `SteVecDocument` does.
628632
pub const JSON_TREE: DocumentFamily = DocumentFamily {
@@ -648,6 +652,12 @@ pub const JSON_TREE: DocumentFamily = DocumentFamily {
648652
rust: "JsonTreeLeafQuery",
649653
sql_domain: "eql_v4.query_json_leaf",
650654
},
655+
DocumentDomain {
656+
role: DocumentRole::ElementsQuery,
657+
name: "JsonSearchElementsQuery",
658+
rust: "JsonTreeElementsQuery",
659+
sql_domain: "eql_v4.query_json_elements",
660+
},
651661
DocumentDomain {
652662
role: DocumentRole::Entry,
653663
name: "JsonEntry",

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1633,6 +1633,7 @@ mod document_families {
16331633
DocumentRole::Stored,
16341634
DocumentRole::Query,
16351635
DocumentRole::LeafQuery,
1636+
DocumentRole::ElementsQuery,
16361637
DocumentRole::Entry,
16371638
] {
16381639
assert_eq!(
@@ -1645,7 +1646,9 @@ mod document_families {
16451646
for domain in family.domains {
16461647
let schema = match domain.role {
16471648
DocumentRole::Stored | DocumentRole::Entry => "public.eql_v4_",
1648-
DocumentRole::Query | DocumentRole::LeafQuery => "eql_v4.query_",
1649+
DocumentRole::Query | DocumentRole::LeafQuery | DocumentRole::ElementsQuery => {
1650+
"eql_v4.query_"
1651+
}
16491652
};
16501653
assert!(
16511654
domain.sql_domain.starts_with(schema),
@@ -1677,6 +1680,11 @@ mod document_families {
16771680
"JsonSearchLeafQuery",
16781681
"eql_v4.query_json_leaf",
16791682
),
1683+
(
1684+
DocumentRole::ElementsQuery,
1685+
"JsonSearchElementsQuery",
1686+
"eql_v4.query_json_elements",
1687+
),
16801688
(DocumentRole::Entry, "JsonEntry", "public.eql_v4_json_entry"),
16811689
] {
16821690
let domain = JSON_TREE.domain(role).unwrap();

0 commit comments

Comments
 (0)