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
43 changes: 43 additions & 0 deletions languages/golang/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,37 @@ listed under **Breaking**. Releases are tagged `languages/golang/vX.Y.Z`.

### Added

- **Bind a whole jsonpath filter on a `JsonSearch` field as one parameter
(#1443).** Every `JsonSearch` field gets
`Fields.<Field>.Match(ctx, cipher, predicate, vars, opts)`, which takes
what plaintext `jsonb_path_match(col, $1, $2)` takes: a jsonpath predicate
and its variables (any value `encoding/json` writes as an object, a
`json.RawMessage` of one, or `nil` for none). It returns an
`eql.JSONSearchPredicateQuery` for
`col @@ $1::eql_v4.query_json_predicate` or
`eql_v4.json_tree_match(col, $1::eql_v4.query_json_predicate)`. The engine
parses the predicate and compiles it into a tree of the operands `EqAt`,
`Selector`, `At.<Path>` and `Elements.<Array>.Match` derive; the Go side
reads none of it. The grammar is paths of `.key`, `."quoted key"` and
`[*]`; `==`, `!=`, `<`, `<=`, `>`, `>=` against a variable or a JSON
literal; `exists(path)` and `exists(path ? (@.a == $x && @.b > $y))`; and
`&&`, `||`, `!(…)`. A construct it cannot answer is refused as an
`*encrypt.Diagnostic` wrapping `encrypt.ErrEncoding`, never compiled to a
tree that drops a leaf; an ordering at a path the tag does not order is
`encrypt.ErrTerm`. Each row answers as `jsonb_path_match` does over its
plaintext, save four documented cases (lax array unwrapping and wrapping,
a string compared with a number, and folded ordering terms). The tree is
evaluated inside a function, so no index engages: `EqAt`, `Contains` and
`At.<Path>` stay the indexed form. The embedded EQL guest names the
`eql_v4.query_json_predicate` domain, so `Checker.Operators` lists `match`
(input `predicate`) on every `JsonSearch` field, and the guest reports a
refused predicate as a malformed input rather than an internal failure.

- **`Diagnostic.Construct` and `Diagnostic.At` (#1443).** A refused `Match`
predicate names the construct in the grammar's words (`` `like_regex` ``,
`a position (`[2]`)`) and the byte offset in the predicate where it
starts. Neither carries text from the predicate.

- **Test several members of one array element of a `JsonSearch` field
(#1442).** A field whose `ordered=` paths name members beneath an array's
wildcard (`$.contacts[*].relationship;$.contacts[*].calls`) gets
Expand Down Expand Up @@ -148,6 +179,18 @@ listed under **Breaking**. Releases are tagged `languages/golang/vX.Y.Z`.

### Breaking

- **Every `encrypt/eql` type prints and logs redacted (#1443).** `String`,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redacted fmt output is a silent behaviour change and reads as Breaking, not Changed. Before this PR every encrypt/eql type was a bare []byte to fmt, so fmt.Sprint(q), %s, %v and %x all yielded the JSON text. Any caller that relied on that (string-building SQL, json.RawMessage(fmt.Sprint(q)), logging a value for debugging, a test fixture comparing %s output) now gets eql.X{[redacted]} with no error and no compile failure. Consider listing it under Breaking so a reader scanning for upgrade hazards sees it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to Breaking in 5902c80. The entry now says what changed for a caller: %s and %q used to give the JSON (fmt.Sprint, %v and %x gave the bytes as numbers or hex), and all of them now give the redacted text with no error.

To get the JSON, convert the value (string(v), json.RawMessage(v)) or call v.MarshalJSON(); Value is unchanged too. There is no named accessor method for the JSON, because the value itself is the JSON, and the entry says so. The eql package doc says the same, and TestValuesPrintAndLogRedacted checks that the conversion still gives the JSON.

`Format` (every verb, `%#v` included) and `LogValue` write the type's
name and `[redacted]` (`eql.TextEq{[redacted]}`, `eql.TextEq{}` when
empty), never the bytes: a stored value holds ciphertext and terms, and a
query value terms. Before, each type was a bare `[]byte` to `fmt` and
`slog`: `%s` and `%q` gave its JSON, and `fmt.Sprint(v)`, `%v` and `%x`
its bytes as numbers or hex. They now give the redacted text, with no
error. A caller that read the JSON with `%s` converts the value instead, `string(v)` or `json.RawMessage(v)`, or
calls `v.MarshalJSON()`; `Value` is unchanged too. There is no named
accessor method for the JSON: the value is the JSON. Generated by
eql-codegen for every type.

- **`eql.SteVecDocument` and `eql.SteVecQuery` are gone.** They named
EQL v4's SteVec domains, which the engine never produced and the v4
bundle no longer carries: in v4, `public.eql_v4_json_search` is the
Expand Down
59 changes: 59 additions & 0 deletions languages/golang/cmd/stashgen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ The field's `Fields` entry has a method per operation:
| `Subtree(ctx, c, path, opts)` | `encrypt.JSONRead`: `Tokens()`, `Open` and `OpenOne` | `eql_v4.json_tree_subtree(col, $1::text[], 64)` |
| `Children(ctx, c, path)`, `ChildCount(ctx, c, path)` | `encrypt.JSONToken` | `eql_v4.json_tree_children(col, $1::text)`, `eql_v4.json_tree_child_count(col, $1::text)` |
| `Elements.Visits.Match(ctx, c, predicates, opts)` | `eql.JSONSearchElementsQuery` | `eql_v4.json_tree_element_match(col, $1::eql_v4.query_json_elements)` |
| `Match(ctx, c, predicate, vars, opts)` | `eql.JSONSearchPredicateQuery` | `col @@ $1::eql_v4.query_json_predicate`, or `eql_v4.json_tree_match(col, $1::eql_v4.query_json_predicate)` |

`At` has one accessor per ordered path, named for its keys (`$.visits[*].at` is `At.VisitsAt`, `$.a.1` is `At.A1`); two paths with one name, or one path given twice, stop `stashgen`.
An ordering compares the value `->` extracts at its path, and EQL's `<` compares ordering terms alone, so take the selector from the same accessor (`At.Age.Selector`) rather than spelling the path again.
Expand Down Expand Up @@ -220,6 +221,64 @@ Each comparison is of the member's ordering term, so `MemberEq` is an equality o
`EqAt` at the member's full path is exact, of any element.
`Match` reads each array to `opts.WildcardBound` elements, 64 when it is 0, and a row whose array is longer fails the query with SQLSTATE 22023 rather than answer over part of it: set the bound past the longest array.
The function runs on each row a scan gives it, so narrow the rows first with an indexed predicate, such as `@>` on the same values.

`Match` on the field itself takes a whole filter as one parameter: a jsonpath predicate and its variables, the two things plaintext `jsonb_path_match(col, $1, $2)` takes.
It is generated on every `JsonSearch` field, ordered paths or none: the engine reads the predicate when `Match` is called, and refuses what the field cannot answer then.

```go
q, err := entities.Fields.CustomFieldValues.Match(ctx, cipher,
`$."extra"."country" == $val && $."weight" < $max`,
map[string]any{"val": "nz", "max": 9.5}, encrypt.JSONReadOptions{})
```

```sql
SELECT id FROM entity WHERE custom_field_values @@ $1::eql_v4.query_json_predicate
-- or, the same rows
SELECT id FROM entity WHERE eql_v4.json_tree_match(custom_field_values, $1::eql_v4.query_json_predicate)
```

`vars` are any value `encoding/json` writes as a JSON object (a map, a struct, or a `json.RawMessage` of one), or `nil` for none; a Go string is refused, as it is for a `Contains` needle.
The Go side reads nothing of the predicate: the engine parses it and compiles it into a tree of the operands the methods above derive, one leaf per comparison.
The grammar is the subset of SQL/JSON path that the stored terms can answer, as PostgreSQL reads it:

- paths: `$`, then `.key`, `."quoted key"` and `[*]` steps;
- values: a variable (`$v`, `$"v"`) or a JSON literal (a string, a number, `true`, `false`, `null`);
- comparisons: `==`, `!=` (or `<>`), `<`, `<=`, `>`, `>=` between a path and a value, either side first;
- `exists(path)`, and `exists(path ? (@.m == $v && @.n > $w))`, conditions on `@` or `@.member` joined by `&&`;
- `&&`, `||`, `!(…)`, parentheses and `/* */` comments. Keywords are read in any case.

An ordering (`<` and the rest) needs its path in `ordered=`, and so does every member of a filter of more than one condition, which compiles to the element match above.
Anything else is refused before any term is derived, as an `*encrypt.Diagnostic` wrapping `encrypt.ErrEncoding`.
`Construct()` names the construct in the grammar's own words (`` `like_regex` ``, `a position (`[2]`)`), and `At()` is the byte offset in the predicate where it starts; neither says anything of the predicate's text or the variables' values.
`Reason()` says why:

- `json_predicate_syntax`: text the grammar does not read.
- `json_predicate_unsupported`: a jsonpath construct the stored terms cannot answer, such as `like_regex`, arithmetic, a method, `.**`, or a filter outside `exists`.
- `json_predicate_unknown_variable`: a `$name` that `vars` does not hold.
- `json_predicate_value_type`: an object or array as a value, or anything but a number or a string under an ordering or in a filter of more than one condition.
- `json_predicate_not_equal_at_wildcard`: `!=` at a path with `[*]`.
- `json_predicate_too_deep`: more than 32 levels of nesting, or a path deeper than a document.
- `json_path_position`: a position (`[2]`) in a path.
- `json_path_no_wildcard`: a filter on a path with no `[*]`.
- `json_path_wildcard`: a `[*]` in a filter's member.
- `json_syntax`: JSON text that does not parse, a literal in the predicate (`1e999`) or `vars` (`{`, or an empty or whitespace-only `json.RawMessage`); with `At()` for a literal, without it for `vars`.
- `json_input`: `vars` that are not a JSON object (`[1]`, `1`).

An ordering at a path the tag does not order is `encrypt.ErrTerm` with the code `stack_encrypt::json_path_not_ordered`.

Each row answers as `jsonb_path_match` answers over its plaintext, true, false or NULL, save four cases:

- Lax mode's unwrapping of an array the path names no `[*]` for is not applied: `$.tags == "a"` over `{"tags": ["a"]}` is false here. Write `[*]`.
- Nor is its wrapping of a value that is not an array under `[*]`: `$.tags[*] == "a"` over `{"tags": "a"}` is false here. Keep an array an array.
- A string compared with a number, which jsonpath calls unknown, is false under `==` and true under `!=`. Keep one kind of value at a path.
- Orderings, and filters of more than one condition, compare the folded ordering terms: text case- and accent-folded, numbers as float64. So `exists($.c[*] ? (@.r == "sibling" && @.n > 5))` finds a `"Sibling"`; `==` anywhere else is exact.

A filter of more than one condition reads each array to `opts.WildcardBound` elements, 64 when it is 0; a row whose array is longer fails the query with SQLSTATE 22023, as the element match does.

`Match` costs an index: `eql_v4.json_tree_match` evaluates the tree inside a function, so it filters every row a scan gives it and no index engages.
The per-operator form engages the GIN index for `@>` (`EqAt`, `Contains`) and a btree on an ordered path's term (`At.<Path>`).
Where a query needs an index, write those leaves with the per-operator methods, or put one beside `Match` to narrow the rows: `WHERE col @> $1::eql_v4.query_json AND col @@ $2::eql_v4.query_json_predicate`.

`OpenOne` reads one value and refuses a read whose path has a wildcard before anything is opened; open a wildcard read with `Open`.
A needle and a value are Go values: a `json.RawMessage` of JSON text crosses as it is, and anything else is marshalled with `encoding/json`.
`EqAt` and the orderings take a scalar: a string, a number, a bool, or `nil` for JSON `null`.
Expand Down
25 changes: 25 additions & 0 deletions languages/golang/cmd/stashgen/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,18 @@ func TestRunGeneratesJSONSearchFields(t *testing.T) {
if strings.Contains(written, "PrefsFieldElements") {
t.Error("Prefs orders no member, and got Elements")
}
// Every document field has Match, ordered paths or none: what a
// predicate may use is the engine's to decide when it compiles it.
for _, field := range []string{"ProfileField", "PrefsField"} {
for _, want := range []string{
"func (f " + field + ") Match(ctx context.Context, c *encrypt.Cipher, predicate string, vars any, opts encrypt.JSONReadOptions) (eql.JSONSearchPredicateQuery, error)",
"gensupport.JSONMatch[eql.JSONSearchPredicateQuery](ctx, f.field, c, predicate, vars, opts)",
} {
if !strings.Contains(written, want) {
t.Errorf("the generated file lacks %q", want)
}
}
}
// The members of one array, named as At names their paths; an ordered
// path that ends at its wildcard, or has none, is no array's member.
written, stderr, code = generate("\tProfile json.RawMessage `stash:\"profile,encrypt_into=JsonSearch(ordered=$.extra.emergency_contacts[*].relationship;$.extra.emergency_contacts[*].previous_call_count;$.tags[*];$.age)\"`")
Expand Down Expand Up @@ -570,6 +582,19 @@ func TestRunGeneratesJSONSearchFields(t *testing.T) {
if _, err := os.Stat(filepath.Join(dir, "customer_stash.go")); !os.IsNotExist(err) {
t.Fatal("a file was written after the refusal")
}
// The same for any document field, ordered or not, and an engine that
// answers no match.
dir = writeModule(t, map[string]string{"model.go": source("\tProfile json.RawMessage `stash:\"profile,encrypt_into=JsonSearch\"`")})
stderrBuf.Reset()
if code := run([]string{"-type", "Customer"}, dir, &bytes.Buffer{}, &stderrBuf, withoutOperator("match")); code != 1 {
t.Fatalf("exit %d, want 1\n%s", code, stderrBuf.String())
}
if got := stderrBuf.String(); !strings.Contains(got, "Customer.Profile") || !strings.Contains(got, "the engine answers no match on JsonSearch") {
t.Fatalf("stderr = %q", got)
}
if _, err := os.Stat(filepath.Join(dir, "customer_stash.go")); !os.IsNotExist(err) {
t.Fatal("a file was written after the refusal")
}
for _, c := range []struct{ name, fields, want string }{
{"a sealed null", "\tPrefs *json.RawMessage `stash:\"prefs,encrypt_into=JsonSearch,null=sealed\"`", "Customer.Prefs: a nil has an equality term and no other, so another index's column would show which values are absent; write null=sql"},
{"a nil document that does not say null=sql", "\tPrefs *json.RawMessage `stash:\"prefs,encrypt_into=JsonSearch\"`", "null=sql"},
Expand Down
30 changes: 30 additions & 0 deletions languages/golang/encrypt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,36 @@ asked, naming it. A row whose array is longer than
`JSONReadOptions.WildcardBound` (64 by default) fails the query with
SQLSTATE 22023.

A whole filter binds as one parameter with the field's `Match`: a jsonpath
predicate and its variables, as plaintext `jsonb_path_match(col, $1, $2)`
takes them. The engine reads the predicate and compiles it into a tree of
the operands above; the Go side reads none of it.

```go
q, err := users.Fields.Profile.Match(ctx, cipher,
`$."plan" == $plan && $."age" < $max`,
map[string]any{"plan": "pro", "max": 40}, encrypt.JSONReadOptions{})
rows, err := db.QueryContext(ctx, `SELECT id FROM users WHERE profile @@ $1::eql_v4.query_json_predicate`, q)
```

`eql_v4.json_tree_match(profile, $1::eql_v4.query_json_predicate)` returns
the same rows. The variables are any value `encoding/json` writes as an
object, or `nil` for none. The grammar is paths of `.key`, `."key"` and
`[*]`; `==`, `!=`, `<`, `<=`, `>`, `>=` with a variable or a JSON literal;
`exists(path)` and `exists(path ? (@.a == $x && @.b > $y))`; `&&`, `||` and
`!(…)`. An ordering, and each member of a filter of more than one condition,
needs its path in `ordered=`. Anything else is refused as a `*Diagnostic`
wrapping `ErrEncoding`, whose `Reason()`, `Construct()` and `At()` give the
reason, the construct in the grammar's words and the byte offset in the
predicate, never its text or the values. Each row answers as
`jsonb_path_match` does over its plaintext, save four cases the stashgen
README lists: lax unwrapping and wrapping of arrays are not applied, a
string compared with a number is false under `==`, and orderings and
multi-condition filters compare folded terms. The tree is evaluated inside a
function, so no index engages: where a query needs one, write its leaves
with `EqAt`, `Contains` and `At.<Path>`, whose `@>` and `->` comparisons
use the GIN and btree indexes, or put one beside `Match` to narrow the rows.

## Building

The package embeds `wasm/stack_encrypt_guest.wasm`, a build artefact of the
Expand Down
10 changes: 5 additions & 5 deletions languages/golang/encrypt/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ func parseOperators(value any) ([]record.Operator, error) {
op.Index, err = parseOperatorIndex(s)
case "input":
switch s {
case "value", "pair", "json", "path_json", "path_pair", "path", "path_bound", "path_predicates":
case "value", "pair", "json", "path_json", "path_pair", "path", "path_bound", "path_predicates", "predicate":
op.Input = s
default:
return nil, fmt.Errorf("operator %d: unknown input %q", i, s)
Expand All @@ -282,14 +282,14 @@ func parseOperators(value any) ([]record.Operator, error) {
// of the names the engine's OpSpec::parse accepts, the scalar operators
// (eq, lt, lte, gt, gte, between, matches) and a document's (contains,
// contained_by, eq_at, the orderings at a path, between_at, selector,
// subtree, children, child_count, element_match). The catalog (se_targets) and se_query_term are
// read through this, parseFidelity and parseOperatorIndex, so the two
// readers refuse the same values.
// subtree, children, child_count, element_match, match). The catalog
// (se_targets) and se_query_term are read through this, parseFidelity and
// parseOperatorIndex, so the two readers refuse the same values.
func parseOperatorName(s string) (string, error) {
switch s {
case "eq", "lt", "lte", "gt", "gte", "between", "matches",
"contains", "contained_by", "eq_at", "lt_at", "lte_at", "gt_at", "gte_at", "between_at",
"selector", "subtree", "children", "child_count", "element_match":
"selector", "subtree", "children", "child_count", "element_match", "match":
return s, nil
}
return "", fmt.Errorf("unknown operator %q", s)
Expand Down
16 changes: 16 additions & 0 deletions languages/golang/encrypt/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,22 @@
// array is longer than [JSONReadOptions.WildcardBound] (64 by default)
// fails the query with SQLSTATE 22023.
//
// A whole filter binds as one parameter with the field's Match: a jsonpath
// predicate and its variables, as plaintext jsonb_path_match takes them.
// The engine parses the predicate and compiles it into a tree of the
// operands above; nothing in Go reads it.
//
// q, err := customers.Fields.Profile.Match(ctx, cipher,
// `$."name" == $name && $."age" < $max`,
// map[string]any{"name": "Alice", "max": 40}, encrypt.JSONReadOptions{})
// // WHERE profile @@ $1::eql_v4.query_json_predicate
//
// A predicate the engine cannot answer is refused as a [Diagnostic]
// wrapping [ErrEncoding], whose Reason, Construct and At name the refusal
// and the byte offset in the predicate where it starts. The tree is
// evaluated inside eql_v4.json_tree_match, so no index engages: a query
// that needs one writes its leaves with EqAt, Contains and At.<Path>.
//
// # Cancellation
//
// Every call takes a [context.Context], and a call whose context is
Expand Down
31 changes: 30 additions & 1 deletion languages/golang/encrypt/eql/eql.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
// the guest returns and never assembles an EQL value itself (ADR-0007,
// amended 2026-10-06). Each type implements driver.Valuer and sql.Scanner
// for one column, and json.Marshaler, which writes the value as the JSON
// it is.
// it is. It prints (fmt.Stringer, fmt.Formatter, for every verb) and logs
// (slog.LogValuer) as its type's name and never its bytes, so fmt.Sprint
// and %s do not give its JSON: a stored value holds ciphertext and terms,
// and a query value terms. The JSON is the value itself, a []byte:
// convert it (string(v), json.RawMessage(v)), or call MarshalJSON.
//
// Importing this package links the build of the engine that holds the EQL
// types: eql_gen.go's init registers the embedded module with package
Expand All @@ -28,6 +32,8 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
)

// Type is one EQL type as the catalog describes it: what stashgen learns
Expand Down Expand Up @@ -121,3 +127,26 @@ func unmarshal(dst *[]byte, b []byte) error {
*dst = append([]byte(nil), b...)
return nil
}

// redacted stands in for an EQL value's bytes in what it prints and logs.
const redacted = "[redacted]"

// text is what an EQL value prints: its type's name, and [redacted] for
// its bytes, or {} when it is empty.
func text(b []byte, kind string) string {
if len(b) == 0 {
return "eql." + kind + "{}"
}
return "eql." + kind + "{" + redacted + "}"
}

// format writes [text] for every verb fmt hands an EQL value.
func format(f fmt.State, b []byte, kind string) {
_, _ = io.WriteString(f, text(b, kind))
}

// logValue is what slog records of an EQL value: the [text] it prints,
// never its bytes.
func logValue(b []byte, kind string) slog.Value {
return slog.StringValue(text(b, kind))
}
Loading
Loading