Repository navigation
feat(go): test several members of one array element of a JsonSearch field - #1451
Conversation
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the
|
Mutation testing (cargo-mutants,
|
ebb4c28 to
bb9a5c9
Compare
6a31ad7 to
500aa58
Compare
bb9a5c9 to
cb6a190
Compare
|
Rebased onto #1446's new head (500aa58); new head cb6a190. Rebase only: no change to this PR's own commits. Why: the three EQL PRs below changed under review (#1444's feedback, the move of the JSON tree SQLx tests onto documents Stack Encrypt seals, and the clippy fix in #1445 that gates |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
cipherstash-agent
left a comment
There was a problem hiding this comment.
Review attestation — automated /code-review medium plus cross-check against existing PR feedback, at cb6a190.
- Critical / blocking: none.
- New findings: none.
- Adds to existing: one minor reply on the
Values()thread (slices.Cloneis shallow, so reference-typed values stay mutable). - Dropped: four candidate findings. The
len(values)guard was already raised and removed in this PR. The member-spelling refusal is disclosed in the PR's review notes. The per-callElementArrays()regroup and thestrings.Trimcutset injsonArrayNameare unreachable or negligible today.
Not checked: CI results, and the Codex security review, which was still running.
Generated by Claude Code
500aa58 to
6109e38
Compare
cb6a190 to
52802ef
Compare
52802ef to
2237691
Compare
6109e38 to
8b8cfc6
Compare
cbab542 to
db20d2c
Compare
8b8cfc6 to
38510d8
Compare
db20d2c to
e65fca9
Compare
38510d8 to
3dfbe7f
Compare
…ield A filter such as "a contact who is a sibling and was called more than five times" had no Go method. An EqAt and an ordering at the contacts' paths can each be answered by a different contact, so the application had to read the array back and re-apply the predicate itself. The engine and EQL already answer it: element_match derives one eql_v4.query_json_elements operand, and json_tree_element_match is true of a row where one element passes every predicate. This wires it into the Go SDK. The EQL guest's resolver now names the elements domain, so the engine lists element_match (input path_predicates) on a field that orders a path, and the checker and stashgen read the operator. The compiled predicate domain stays unnamed: match is #1443's. stashgen groups the tag's ordered paths by the array their last wildcard names (record.TargetOptions.ElementArrays) and writes one accessor per array under Elements, named as At names a path, with Match(ctx, cipher, opts, predicates...). An array with no ordered member beneath it gets none, and a root array ($[*]) is Root. The members are compile-time names. stashgen writes Paths (NPaths with -name), a tree nested as the JSON nests it, of encrypt.Path[E, T] values: Paths.Profile.Visits.Place. E is a type stashgen writes for each array, and T is the member's Go type, read from the document's struct as encoding/json reads it, or any where the type names none (a json.RawMessage, say). encrypt.AtPath(path) gives Eq, Lt, Lte, Gt, Gte and Between, each taking a T and returning an encrypt.ElementPredicate[E], and an array's Match takes only its own E. So a value of another type, or a predicate on another array's member, does not compile. Paths holds fields and no methods, so no JSON key is reserved. For a filter built at run time, the array accessor's Member("place") gives the same comparisons with any values. An array gets no accessor, and a notice naming its ordered paths, when its keys give no accessor name, when another array's keys give the same one, or when Paths cannot name its members: a key that gives no Go name on its own, or one name that would stand for two paths. A tag that generated before still generates, with its At unchanged. Match writes each value with gensupport.JSONText as the orderings do, and returns an eql.JSONSearchElementsQuery. The member check runs in the SDK, against the members the declaration orders beneath the array, before the guest is called: the engine's json_path_not_ordered names no member, so the first error a caller sees names the member, the array and the members it orders. It wraps ErrTerm, as the engine's refusal does; no predicates, a zero predicate, a zero Path and a member with a wildcard wrap ErrEncoding. Every other refusal is the engine's Diagnostic. A predicate prints and logs its member and comparison, never its values, and copies the bytes of a json.RawMessage or []byte value. Tests: the hermetic suite pins the operand against the subtree reads and the orderings of the same members, the run-time form against the typed one, every pre-engine refusal, and each engine refusal's sentinel and code through the guest. A type-checking test loads the generated testusers package with a probe file and holds the two compile errors, a value of another type and another array's predicate; the stashgen test does the same against a generated module, and pins the member types and every case that gets no accessor. The PostgreSQL suite (go:test:postgres) finds the row where one contact passes both predicates, by path and by name, and not the row where two contacts each pass one, which the EqAt and ordering superset returns, and raises SQLSTATE 22023 past the bound. The guest test now pins the elements domain named and match not listed. Closes #1442 Claude-Session: https://claude.ai/code/session_01MrxG73fnX1p7oiizh3GRG5
The stashgen README's "Encrypted JSON documents" table gets the Match row and its SQL, with when Elements and Paths are generated, how encrypt.AtPath makes a predicate from a path, what a path's type checks, the run-time Member form, what is refused before the engine, which arrays get no accessor, the folded equality and the 22023 bound. Its list of what stashgen writes, and -name, name Paths. The SDK README and package doc show the call, and the CHANGELOG records it under Unreleased. The typed-document example orders each visit's at and place, and finds the customer with one visit at home after 15, with the paths from Paths: Ada has a visit at home and a visit after 15, but no one visit that is both. Its generated file is regenerated from the new tag. Refs #1442 Claude-Session: https://claude.ai/code/session_01MrxG73fnX1p7oiizh3GRG5
3dfbe7f to
c6da7e2
Compare
e65fca9 to
ae71ed2
Compare
Summary
A Go program can now ask "does one array element of this encrypted JSON document pass several tests at once?", for example "a contact who is a sibling and was called more than five times". Before, the Go SDK could only ask each half on its own (
EqAtand an ordering at a path), and each half could be answered by a different contact, so the application had to read the array back, decrypt it and re-apply the filter by hand.Terms used below:
JsonSearchfield is a Go struct, map orjson.RawMessagefield stored as one encrypted JSON document in apublic.eql_v4_json_searchcolumn.ordered=$.contacts[*].calls) whose numbers and strings get an ordering term, so the database can compare them without decrypting.stashgenis the Go generator that writes the typedFieldsvalue from the struct tags.This is the Go half of #1440. The engine and EQL work it builds on (#1444, #1445, #1446) is merged into
next, so this PR's diff is its own two commits. It does not touch the engine or the EQL SQL.Changes
Public Go API
On a generated
JsonSearchfield,Fields.<Field>.Elements.<Array>.Match(ctx, cipher, opts, predicates...): one accessor per array that has at least one ordered member beneath its wildcard, named asAtnames a path ($.extra.emergency_contacts[*]isElements.ExtraEmergencyContacts; a root array$[*]isElements.Root). Two arrays whose keys give one name, and an array whose keys give none, get no accessor:stashgenstill generates the field and itsAtunchanged, exits 0, and prints a notice naming the ordered paths as written, which the generated file repeats in a comment. So no tag that generated before this PR stops generating.Matchreturnseql.JSONSearchElementsQuery(already generated by feat(eql): one-parameter jsonpath predicates on json_tree documents (json_tree_match, @@, match) #1446), which binds as the one operand ofWHERE eql_v4.json_tree_element_match(col, $1::eql_v4.query_json_elements).Typed members, checked by the compiler.
stashgenwrites aPathstree besideFields(named asFieldsis:CustomerPathsunder-name Customer), nested as the JSON nests:Paths.Profile.Visits.Placeis anencrypt.Path[ProfileFieldElementsVisits, string], its first type parameter naming the array and its second the member's Go type, read from the document struct asencoding/jsonreads it (anywhere the struct cannot say).encrypt.AtPath(path)givesEq,Lt,Lte,Gt,GteandBetween, which take that Go type and return anencrypt.ElementPredicate[E]for that array alone:So a misspelt member, a value of the wrong type (
AtPath(Paths.Profile.Visits.At).Gt("15")) and a member of another array do not compile.Pathsholds only fields and the array accessor only methods, so no JSON key is reserved: a member namedmatchisPaths.….Match, a field.Members named at run time.
Fields.<Field>.Elements.<Array>.Member(name)gives the same comparisons overanyvalues, for a filter compiler that only knows member names as text; it makes the sameElementPredicate[E], and a member the field does not order is refused when the operand is derived.Values are Go values written to JSON by the same
gensupport.JSONTextthe orderings use. A predicate prints and logs its member and comparison, never its values;Values()returns a copy, so a built predicate cannot be changed.Refusals
ElementPredicateorPath, a member with a wildcard (allencrypt.ErrEncoding), and a member whose full path the field does not order (encrypt.ErrTerm). The last one names the member, the array and the members the field orders there, because the engine's ownjson_path_not_orderednames none.*encrypt.Diagnostic, wrappingErrEncodingfor a data-plan reason andErrTermfor an index refusal, asSubtreeandAt.<P>.Ltalready surface theirs.JSONReadOptions.WildcardBoundkeeps the meaning it has forSubtree: 0 sends no bound and the engine's default, 64, applies. So the engine'sjson_wildcard_bound_zerocannot be reached throughMatch; it is still tested throughCipher.QueryTerm.Wiring
languages/golang/encrypt/guest/src/targets.rs): the EQL resolver nameseql_v4.query_json_elements. The compiled-predicate domain stays unnamed (matchis Go SDK: addMatch(ctx, cipher, predicate, vars)on a JsonSearch field, binding one jsonpath-style predicate as one parameter, on top of #1441 #1443). The guest test now asserts the elements domain is named,element_matchis listed on a field that orders a path, andmatchis not listed.parseOperatorNameand the operator input parser acceptelement_match/path_predicates; stashgen'sjsonOperatorstoo.record.TargetOptions.ElementArraysgroups ordered paths by the array their last wildcard names; stashgen uses it to decide which accessors to write, and gensupport uses it to check members at run time.element_match's data-plan refusals areError::Source(encoding) and its index refusalsTermError(term); none falls into the internal catch-all.Tests
TestElementMatchTypeChecks(encrypt) loads the real generatedtestuserspackage with an overlay file throughgo/packages. A correct program compiles;AtPath(contact.Age).Gt("15")(a string for anint64member) does not, and neither does passing one type's array predicate to another type'sMatch.TestRunTypesTheElementMatch(cmd/stashgen) does the same throughgo buildof a freshly generated module against the stub SDK. It also checks the member types stashgen reads from the struct: anint64through a pointer, a named string type,time.Timethrough a promoted field, an untagged field, a map step, andanyfor a path the struct does not hold.encrypt): the operand's member tokens equal theSubtreetokens of the full path at the same bound (bound + 1, the probe last);eqisgteandlteat one value and equalsAt.<P>.Gte;gtequalsAt.<P>.Gt;betweenequalsAt.<P>.Between; Go values and their JSON text derive the same operand; the default bound gives 65 tokens. Typed paths andMember(name)derive the same operand (TestElementMatchIsTheReadsAndTheOrderingsOfEachMember,TestAtPathMakesThePredicatesOfItsMember). Every pre-engine refusal returns its sentinel with noDiagnostic, a zeroPathincluded. Every engine refusal listed in feat(eql): test equality and ranges on one array element of a json_tree document #1445 comes back through the guest with its sentinel and its reason or code. Predicates print and log without their values, and copyjson.RawMessageand[]bytevalues.go:test:postgres, newTestJSONElementMatchInPostgres): one family has a sibling and, separately, a contact called more than five times; another has one contact who is both.Matchreturns only the second; theEqAt+ ordering superset returns both. Members named at run time find the same row. Also a range, three members, folded equality ("sibling"matches,EqAtdoes not), a bound of 1 raising SQLSTATE 22023, and an unordered member refused before the engine.TestRunGeneratesJSONSearchFields): accessor andPathsentries generated for an array with an ordered member, not for one without ($.tags[*],$.age), named asAtnames paths, a root array namedRoot; colliding or unnameable arrays skipped with a notice (exit 0,Atunchanged), including thePaths-only cases (a key that names nothing on its own, a member above a member, a member above an array, two keys giving onePathsname); and an engine that answers noelement_matchrefused.stashgen/testdata/stubsdk) gainsencrypt.Path,AtPath,MemberTestandElementPredicate[E], and itseqlstub the JSON search types, so generated JSON code compiles against it.testusers.Entity, mirroring Go SDK: no way to test an equality and a range on the same array element of a JsonSearch field — add an element-scopedMatchon top of #1440 #1442's example;customer_stash.goandsubscriber_stash.goare regenerated.Docs
cmd/stashgen/README.md"Encrypted JSON documents": aMatchrow with its SQL, a section onPathsandencrypt.AtPath, the run-timeMemberform, whenElementsexists, the folded equality and the 22023 bound;Pathsin "What stashgen writes" and in the-nameflag row.stashgen/doc.goand the-namehelp text incmd/stashgen/main.go:Pathsis named asFieldsis.encrypt/README.md,encrypt/doc.go,languages/golang/CHANGELOG.md(the Go module records changes there, not in changesets).encrypt/example/json) now orders each visit'satandplace, and finds the one customer with a visit at home after 15. Its generated file is regenerated.Verification
mise run wasm:guest:test(run before the typed-path change, which did not touch the guest): fmt, clippy (8 feature/target combinations), rustdoc, nextest 73 passed (default) and 77 passed (eql).go vet ./...,go test ./...(every package ok),gofmt -l .(empty),mise run go:lint(0 issues), andgo generate ./...changes nothing.mise run go:test:postgresagainst a local PostgreSQL 15 with the EQL v4 bundle built from this tree: all 6 tests passed, includingTestJSONElementMatchInPostgres. These use the deterministic guests, so no CipherStash credentials were needed.Related
Closes #1442
Review notes
Pathstree withencrypt.AtPath, keeping a run-time form (Member(name)) for filters built from text.Matchtakes the options before the variadic predicates, because Go requires the variadic parameter last and the wildcard bound must stay expressible.Pathshas no entry. A key that names nothing on its own (the1in$.a.1[*].x), or onePathsname standing for two paths, gives the affected arrays noElementsaccessor and a notice, as the existingElementsname collision does, so no tag that generated before stops generating.encrypt/gensupport/json.go(JSONElementMatch),stashgen/emit.go(planJSON,jsonElements),internal/record/record.go(ElementArrays).relationship,address.city). A member spelled another way (["relationship"]) is refused as not ordered, though the engine would accept it. Tag paths are plain JSONPath, so the tag spelling is the only one a caller needs.Match(ctx, cipher, predicate, vars)on a JsonSearch field, binding one jsonpath-style predicate as one parameter, on top of #1441 #1443: thematchoperator and itspredicateinput shape, namingeql_v4.query_json_predicatein the guest, mappingError::Predicateto an input status in the guest'sstatus.rs(it falls into the internal catch-all today), and flipping the guest test'smatchassertion.