From 2e950427ab940fad3c69056b99c6258a9643027a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 27 Oct 2026 00:42:56 +1100 Subject: [PATCH 1/4] docs(go): add typed JSON walkthrough and correct EQL reference --- CONTRIBUTING.md | 25 +- README.md | 3 + languages/golang/CHANGELOG.md | 7 +- languages/golang/README.md | 50 ++++ languages/golang/cmd/stashgen/README.md | 8 +- languages/golang/encrypt/README.md | 28 +- .../golang/encrypt/example/json/README.md | 48 ++++ .../encrypt/example/json/customer_stash.go | 263 ++++++++++++++++++ languages/golang/encrypt/example/json/main.go | 145 ++++++++++ .../golang/encrypt/example/json/model.go | 24 ++ .../internal/testusers/customer_stash.go | 9 + .../internal/testusers/subscriber_stash.go | 12 + languages/golang/stashgen/emit.go | 9 + .../go-eql-doc-capabilities.test.mjs | 33 +++ 14 files changed, 648 insertions(+), 16 deletions(-) create mode 100644 languages/golang/README.md create mode 100644 languages/golang/encrypt/example/json/README.md create mode 100644 languages/golang/encrypt/example/json/customer_stash.go create mode 100644 languages/golang/encrypt/example/json/main.go create mode 100644 languages/golang/encrypt/example/json/model.go create mode 100644 scripts/__tests__/go-eql-doc-capabilities.test.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec9690394..44e0fb851 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to CipherStash Stack -Thank you for your interest in contributing to the CipherStash Stack for TypeScript! This document walks you through the repository's structure, how to build and run the project locally, and how to make contributions effectively. +Thank you for your interest in contributing to CipherStash Stack. This document walks you through the repository's structure, how to build and run the project locally, and how to make contributions effectively. ## I want to report a bug, or make a feature request @@ -20,6 +20,7 @@ This is a [Turborepo](https://turbo.build/) monorepo managed with [pnpm](https:/ │ │ ├── cli/ <-- The `stash` CLI │ │ └── ... <-- stack-drizzle, stack-supabase, stack-prisma, nextjs, migrate, wizard, ... │ └── examples/ <-- Runnable example apps +├── languages/golang/ <-- Go SDK, stashgen and credential packages ├── packages/eql/ <-- EQL (@cipherstash/eql and its Rust crates) ├── e2e/ <-- Cross-package end-to-end tests ├── skills/ <-- Agent skills @@ -29,6 +30,8 @@ This is a [Turborepo](https://turbo.build/) monorepo managed with [pnpm](https:/ See [AGENTS.md](./AGENTS.md) for a detailed layout, key APIs, environment variables, and gotchas — it's written for coding agents but is the most complete developer reference in the repo. +For the Go module, start with [languages/golang/README.md](languages/golang/README.md). + ### `languages/typescript/packages/stack` **@cipherstash/stack** is the main package published to npm. It contains the encryption client and all integrations (Drizzle, Supabase, DynamoDB, secrets, identity). This is likely where you'll spend most of your time. @@ -44,6 +47,7 @@ The `languages/typescript/examples/` directory contains applications demonstrati - **Node.js** >= 22 - **pnpm** (the version pinned in `package.json`'s `packageManager` field) - CipherStash credentials if you want to run integration tests or examples (see [AGENTS.md](./AGENTS.md) for the required environment variables) +- For Go work: mise and the Go and Rust toolchains pinned in `mise.toml` ### 1. Clone the Repo @@ -76,6 +80,25 @@ pnpm run dev Then navigate to one of the examples in `languages/typescript/examples/` and follow its README. +### Go module + +The Go module is in `languages/golang/`, outside the pnpm workspace. Its +`encrypt` and `auth` packages embed WASI guests built from Rust. In a source +checkout, build those guests before running the Go suite: + +```bash +mise run wasm:guest:build:deterministic +mise run wasm:guest:build:eql:deterministic +mise run wasm:auth-guest:build +mise run go:test +``` + +For EQL v4 PostgreSQL coverage, set `EQL_TEST_DATABASE_URL` and run +`mise run go:test:postgres`. Go examples that connect to ZeroKMS need +CipherStash credentials. The [Go module guide](languages/golang/README.md) +links to the SDK, generator, credentials, examples and release notes. Commit +generated `*_stash.go` files when changing Go model tags; CI checks for drift. + ## Making Changes 1. **Create a new branch** from `main`: diff --git a/README.md b/README.md index d92a7502a..b50aaf24f 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,9 @@ npx stash init > The Stack also ships a `stash` CLI for auth, schema, and database setup. See the [SDK reference][reference]. +Building in Go? Start with the [Go SDK guide](languages/golang/README.md) for +typed field encryption, generated methods and EQL v4 PostgreSQL queries. + ## What's in the Stack ### 🔐 Searchable encryption diff --git a/languages/golang/CHANGELOG.md b/languages/golang/CHANGELOG.md index c1b577e63..cdc6fbaad 100644 --- a/languages/golang/CHANGELOG.md +++ b/languages/golang/CHANGELOG.md @@ -78,8 +78,8 @@ listed under **Breaking**. Releases are tagged `languages/golang/vX.Y.Z`. `eql.JSONSearchLeafQuery` and its entry `eql.JSONEntry`, generated from the EQL catalog. The names are the ones EQL v3 gives its searchable JSON document; `eql.JSON` stays the storage-only type. The - engine stores one from the module's own plans; `stashgen` does not - generate a field of it yet. + engine stores one from the module's own plans, and `stashgen` generates + fields tagged `encrypt_into=JsonSearch`. - **Decimals, dates and timestamps take an order index (#1236).** An `encrypt.Decimal`, `encrypt.Date` or `time.Time` field takes `ore` and @@ -238,7 +238,8 @@ listed under **Breaking**. Releases are tagged `languages/golang/vX.Y.Z`. implements `driver.Valuer`, `sql.Scanner` and JSON like the other query types. They are not in `eql.Types`, which lists stored types, and no generated method returns one - yet: the ordered EQL types are not producible. + yet: `stashgen` does not construct a range query operand, although the + ordered EQL types are producible and expose single-bound comparisons. - **`encrypt.MatchQuery` and `encrypt.EQLMatchQuery`**: what a `Matches` method returns. Each carries the term or EQL query value to bind, `Verify` (check decrypted rows, since a match can return false positives) diff --git a/languages/golang/README.md b/languages/golang/README.md new file mode 100644 index 000000000..798498066 --- /dev/null +++ b/languages/golang/README.md @@ -0,0 +1,50 @@ +# CipherStash Stack for Go + +This module provides searchable field encryption for Go structs. `stashgen` +turns `stash` tags into typed encryption, decryption and query methods. The +`encrypt` package runs the Rust encryption engine in an embedded WASI guest +with wazero; applications do not need cgo. + +Start with the [SDK guide](encrypt/README.md) for installation, credentials, +generation and PostgreSQL storage. The [stashgen reference](cmd/stashgen/README.md) +explains tags, supported field types and generated methods. The +[typed JSON document example](encrypt/example/json/README.md) walks through +an EQL v4 table, queries and partial reads. The [credential guide](auth/README.md) +covers profile, access key and federation strategies. See the +[Go changelog](CHANGELOG.md) for release and compatibility notes. + +## Using a released module + +Use Go 1.26.10 or later (or 1.27.3 or later on the 1.27 line). Add the +generator to your application module, declare a struct with `stash` +tags, then generate and commit its `*_stash.go` file: + +```sh +go get -tool github.com/cipherstash/stack/languages/golang/cmd/stashgen +go generate ./... +``` + +Use a tagged Go module version. Release tags include the embedded WASI guests; +a checkout or an untagged commit does not. The [SDK guide](encrypt/README.md#building) +explains how to build them locally. Searchable `encrypt_into` fields require +EQL v4 in PostgreSQL; the EQL v4 SQL bundle is attached to the matching Go +release. Go and TypeScript currently use different EQL versions, so follow +the [EQL column guide](encrypt/README.md#eql-columns) when choosing a schema. + +## Working in this repository + +From the repository root, install the toolchain pinned by `mise.toml` and +build the guests before running Go tests: + +```sh +mise run wasm:guest:build:deterministic +mise run wasm:guest:build:eql:deterministic +mise run wasm:auth-guest:build +mise run go:test +``` + +The `.wasm` files are build outputs and are not committed on development +branches. `mise run go:test:postgres` also runs the PostgreSQL suite against +the EQL v4 bundle built from this tree; it needs the database named by +`EQL_TEST_DATABASE_URL`. See `mise.toml` and [AGENTS.md](../../AGENTS.md) +for the build and release details. diff --git a/languages/golang/cmd/stashgen/README.md b/languages/golang/cmd/stashgen/README.md index 60e6f0aeb..960096d21 100644 --- a/languages/golang/cmd/stashgen/README.md +++ b/languages/golang/cmd/stashgen/README.md @@ -114,6 +114,7 @@ These words are the same as the Rust API's words for the same behaviour, with tw ### Encrypted JSON documents A field with `encrypt_into=JsonSearch` is stored as one encrypted JSON document, an `eql.JSONSearch` in a `public.eql_v4_json_search` column. +For a complete generated model and PostgreSQL query flow, see the [runnable typed-document example](../../encrypt/example/json/README.md). Declare it as a struct, map or slice type, or as a `json.RawMessage` that holds the document's JSON text: ```go @@ -289,12 +290,15 @@ The engine produces these EQL types today, each from one Go field type: | `Real`, `RealEq`, `RealOrd`, `RealOrdOpe`, `RealOrdOre` | `float32` | | `Double`, `DoubleEq`, `DoubleOrd`, `DoubleOrdOpe`, `DoubleOrdOre` | `float64` | | `Date`, `DateEq` | `encrypt.Date` | +| `DateOrd`, `DateOrdOpe`, `DateOrdOre` | `encrypt.Date` | | `Timestamp`, `TimestampEq` | `time.Time` | +| `TimestampOrd`, `TimestampOrdOpe`, `TimestampOrdOre` | `time.Time` | | `Numeric`, `NumericEq` | `encrypt.Decimal` | +| `NumericOrd`, `NumericOrdOpe`, `NumericOrdOre` | `encrypt.Decimal` | +| `JsonSearch` | struct, map, slice, or `json.RawMessage`; see [Encrypted JSON documents](#encrypted-json-documents) | The `Eq` types answer `Eq`; the `Ord`, `OrdOpe` and `OrdOre` types answer `Lt`, `Lte`, `Gt` and `Gte`, and on text `Eq` too; `TextMatch` answers `Matches`, and `TextSearch` and `TextSearchOre` all of `Eq`, `Lt`, `Lte`, `Gt`, `Gte` and `Matches`, each query carrying only the term its operator reads; `Text`, `Smallint`, `Integer`, `Bigint`, `Boolean`, `Real`, `Double`, `Date`, `Timestamp` and `Numeric` are stored and read only, with no query method. -The ordered date, timestamp and numeric types are refused: the engine derives no order term for these kinds yet. -An ordered type has no `Between` method yet, because a `BETWEEN` operand needs EQL v4's range query domains. +The ordered date, timestamp and numeric types answer the four comparisons, as do the other ordered EQL types. The generated EQL fields have no `Between` method yet: EQL v4 has range query domains, but `stashgen` does not construct their operands. A JSON document's declared ordered paths have their own `Between` method, which returns two leaf query values; see [Encrypted JSON documents](#encrypted-json-documents). Any built-in Go integer field takes an integer type (ADR-0002) and seals at the type's width: an `int16` field always fits an `Integer`, and an `int64` field is range checked value by value, `Encrypt` refusing a value that does not fit with `encrypt.ErrEncoding`. `encrypt.Int128` and `encrypt.Uint128` are not converted. A float's width must match: a `float64` field is not a `Real`. A `TextMatch` field refuses text shorter than three characters, such as an empty string or a two-letter code, because it has no 3-grams to index; its query refuses such text too. diff --git a/languages/golang/encrypt/README.md b/languages/golang/encrypt/README.md index 15355cc76..04ed4182d 100644 --- a/languages/golang/encrypt/README.md +++ b/languages/golang/encrypt/README.md @@ -349,8 +349,9 @@ operand of `eql_v4.between(column, $1::eql_v4.query_integer_range_ore)`. Keep the cast to the exact range domain: an uncast `$1`, a `jsonb` parameter, or a range of another term or family fails with SQLSTATE 42725 ("function ... is not unique"), unlike the single-bound operators, which resolve an untyped -`$1`. No generated method returns one yet, because the ordered EQL types are -not producible. +`$1`. The ordered EQL types are producible, but `stashgen` does not yet +generate a `Between` method or construct these range query values. Their +generated fields expose the four single-bound comparisons. `Eq`, the orderings and `Between` are exact: the server's answer is the answer. `Matches` is probabilistic: the server answers with every row whose @@ -512,10 +513,14 @@ The EQL types the engine produces, and the Go field types each takes: | `DoubleOrd`, `DoubleOrdOpe`, `DoubleOrdOre` | `float64` | `=`, `<>`, `<`, `<=`, `>`, `>=`, `ORDER BY` | | `Date` | `encrypt.Date` | none | | `DateEq` | `encrypt.Date` | `=`, `<>` | +| `DateOrd`, `DateOrdOpe`, `DateOrdOre` | `encrypt.Date` | `<`, `<=`, `>`, `>=`, `ORDER BY` | | `Timestamp` | `time.Time` | none | | `TimestampEq` | `time.Time` | `=`, `<>` (to the microsecond, truncated) | +| `TimestampOrd`, `TimestampOrdOpe`, `TimestampOrdOre` | `time.Time` | `<`, `<=`, `>`, `>=`, `ORDER BY` | | `Numeric` | `encrypt.Decimal` | none | | `NumericEq` | `encrypt.Decimal` | `=`, `<>` (`1.5 = 1.50`) | +| `NumericOrd`, `NumericOrdOpe`, `NumericOrdOre` | `encrypt.Decimal` | `<`, `<=`, `>`, `>=`, `ORDER BY` | +| `JsonSearch` (`eql.JSONSearch`) | struct, map, slice, or `json.RawMessage` | containment, equality at a path, ordering at declared paths, selection, subtree reads; see [Encrypted JSON documents](#encrypted-json-documents) | Any built-in Go integer field takes an integer EQL type (ADR-0002): the field is sealed at the type's width, and opens back into the field's type. A type @@ -546,8 +551,8 @@ decimal with its scale and a `time.Time` with its nanoseconds, in UTC. A `time.Time` is sealed as its instant, whatever its location. A zero `encrypt.Date`, a date or instant outside years -262143 to 262142 and a decimal with more than 28 digits after the point are refused with -`ErrEncoding`. The ordered date, timestamp and numeric types are not -produced yet: the engine derives no order term for these kinds. +`ErrEncoding`. Ordered dates compare by day, timestamps by the truncated +microsecond, and decimals by value (`1.5` ties `1.50`). The `Ord` and `OrdOpe` types store an OPE term, which EQL compares as bytes, and order by ADR-0002's order key: integers by value, floats as PostgreSQL's @@ -564,7 +569,8 @@ answers `Eq` through an equality term only. A text order type also stores `TextEq`'s equality term, and `=` reads that, so `Café` and `cafe` are neither `<` nor `>` each other and not `=` either. Text holding a code point Unicode 16 does not assign has no order term and is refused. There is no -`Between` method yet: a `BETWEEN` needs EQL v4's range query domains. +generated `Between` method on these EQL fields yet, although EQL v4 has +range query domains and `eql_v4.between` for them. The `OrdOre` types and `TextSearchOre` order by the same key, through a block ORE term (ore-rs's 6-bit schemes) in place of the OPE term. Each term @@ -576,9 +582,9 @@ the installer disables these domains, and the `Ord` types are the ones to use. Text whose fold is longer than 1023 bytes, or ends in NUL, has no block ORE term and is refused, stored or queried. -The rest of the catalog (ordered dates, timestamps and decimals, the v3 JSON types) is listed in -`eql.Types` with the reason it is not produced yet, and `stashgen` refuses it -with that reason. The guest builds the value: the generated code +The catalog in `eql.Types` also lists EQL types the engine does not produce, +with the reason for each; `stashgen` refuses those types with that reason. +The guest builds a producible value: the generated code imports `encrypt/eql`, which embeds the build of the engine that holds the EQL types and registers it on import, so the program runs that build and stores what it returns. `Fields.Email.Eq` returns the `eql.TextEqQuery` @@ -595,8 +601,10 @@ column. `JsonSearch` stores a whole JSON document in one `public.eql_v4_json_search` column, from a struct, map or slice field, or a `json.RawMessage` -(`stash:"profile,encrypt_into=JsonSearch(ordered=$.age)"`; see the -stashgen README). A struct is marshalled with `encoding/json` when it is +(`stash:"profile,encrypt_into=JsonSearch(ordered=$.age)"`). See the +[stashgen reference](../cmd/stashgen/README.md#encrypted-json-documents) and +the [runnable typed-document example](example/json/README.md). A struct is +marshalled with `encoding/json` when it is sealed and read back into its type when it is opened. Its numbers come back exactly; the stashgen README lists what `encoding/json` does not keep (invalid UTF-8, a nil map or slice, a time's zone). Unlike the types above, diff --git a/languages/golang/encrypt/example/json/README.md b/languages/golang/encrypt/example/json/README.md new file mode 100644 index 000000000..2fe4a9800 --- /dev/null +++ b/languages/golang/encrypt/example/json/README.md @@ -0,0 +1,48 @@ +# Typed encrypted JSON document example + +This program stores a Go `Profile` struct as one encrypted EQL v4 JSON column. +It finds a row by containment, compares an ordered path, decrypts a whole +profile, and opens only the visit times from another query. + +## Run + +You need Go 1.26.10 or later, PostgreSQL with the EQL v4 SQL bundle installed, a `DATABASE_URL` +pointing to it, and CipherStash credentials. `stash auth login` writes a +developer profile; the `CS_*` environment variables also work. The example +creates a temporary table, so it does not leave data behind. + +From the repository root: + +```sh +mise run wasm:guest:build:eql wasm:auth-guest:build +export DATABASE_URL='postgres://localhost:5432/your_database' +cd languages/golang +go run ./encrypt/example/json +``` + +For a fresh database, install the `eql-v4-install.sql` asset attached to the +Go module release before running the program. The guest builds are needed in +a repository checkout because its WASM files are not committed. A tagged Go +module version carries them. + +## Change the model + +The declaration is in `model.go`. `stashgen` writes `customer_stash.go` from +its `stash` tags. After changing the struct or tags, run this from +`languages/golang` and commit the changed generated file: + +```sh +go generate ./encrypt/example/json +``` + +`Profile` uses `encoding/json` for the document shape. The `ordered=$.age` +tag adds an ordering term for that path. A containment needle made from a +struct includes every field without `omitempty`, including zero values; +`main.go` uses a map to name only the member being queried. The prepared +subtree read returns JSON text, which `encrypt.DecodeJSON[int64]` decodes. + +The example's `SELECT` statements cast parameters to EQL's operand types. +The age comparison uses the selector derived from the same ordered path as +its query term. A JSON selector or read token is a capability over a column; +do not log it. Partial reads authenticate returned entries against the +prepared request, but do not prove that the database returned every entry. diff --git a/languages/golang/encrypt/example/json/customer_stash.go b/languages/golang/encrypt/example/json/customer_stash.go new file mode 100644 index 000000000..092f14762 --- /dev/null +++ b/languages/golang/encrypt/example/json/customer_stash.go @@ -0,0 +1,263 @@ +// Code generated by stashgen. DO NOT EDIT. + +package main + +import ( + "context" + "log/slog" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/eql" + "github.com/cipherstash/stack/languages/golang/encrypt/gensupport" +) + +// Stops compiling when the library does not accept this version of generated file. +const _ = gensupport.GeneratedVersion2 + +type EncryptedCustomer struct { + ID int64 + Profile eql.JSONSearch +} + +func (e EncryptedCustomer) String() string { + return gensupport.Redacted("EncryptedCustomer", map[string]any{"ID": e.ID}, "Profile") +} + +func (e EncryptedCustomer) LogValue() slog.Value { + return gensupport.RedactedLog(map[string]any{"ID": e.ID}, "Profile") +} + +// Written because of -redact: Customer no longer prints its sealed fields. +func (c Customer) String() string { + return gensupport.Redacted("Customer", map[string]any{"ID": c.ID}, "Profile") +} + +func (c Customer) LogValue() slog.Value { + return gensupport.RedactedLog(map[string]any{"ID": c.ID}, "Profile") +} + +// GoString is what %#v prints; without it %#v prints every field. +func (c Customer) GoString() string { + return gensupport.Redacted("Customer", map[string]any{"ID": c.ID}, "Profile") +} + +// Stops compiling when Customer gains, loses, reorders or retypes a field. +var _ = customerShape(Customer{}) + +type customerShape struct { + _ struct{} + ID int64 + Profile Profile +} + +var declaration = gensupport.Declare("customers"). + Passthrough("id"). + EncryptInto("profile", gensupport.JSON, "JsonSearch"). + Options("profile", gensupport.TargetOptions{Ordered: []string{"$.age"}}) + +var codec = gensupport.New(gensupport.Generated[Customer, EncryptedCustomer]{ + TypeName: "Customer", + Declaration: declaration, + Source: func(v Customer) gensupport.Values { + return gensupport.Values{ + "id": v.ID, + "profile": v.Profile, + } + }, + Seal: func(rec gensupport.Record) (EncryptedCustomer, error) { + var e EncryptedCustomer + var err error + if e.ID, err = gensupport.Passthrough[int64](rec, "id"); err != nil { + return EncryptedCustomer{}, err + } + e.Profile = eql.JSONSearch(rec["profile"].EQL) + return e, nil + }, + Open: func(e EncryptedCustomer) gensupport.Record { + return gensupport.Record{ + "id": {Value: e.ID}, + "profile": {EQL: e.Profile}, + } + }, + Value: func(e EncryptedCustomer, vals gensupport.Values) (Customer, error) { + var v Customer + var err error + if v.ID, err = gensupport.Get[int64](vals, "id"); err != nil { + return Customer{}, err + } + if v.Profile, err = gensupport.GetDocument[Profile](vals, "profile"); err != nil { + return Customer{}, err + } + return v, nil + }, +}) + +// Encrypt seals each Customer, with one ZeroKMS request for each 500 sealed +// values, plus one the first time a keyset is used. The result has one element +// for each input, in the same order. +func Encrypt(ctx context.Context, cipher *encrypt.Cipher, main []Customer) ([]EncryptedCustomer, error) { + return codec.Encrypt(ctx, cipher, main) +} + +// Decrypt opens each EncryptedCustomer, with one ZeroKMS request for each 500 +// sealed values, plus one the first time a keyset is used. +func Decrypt(ctx context.Context, d encrypt.Decrypter, encrypted []EncryptedCustomer) ([]Customer, error) { + return codec.Decrypt(ctx, d, encrypted) +} + +// Fields provides operations for the sealed fields of Customer. Each entry +// binds queries and encrypts updates using its field's declaration. +var Fields = struct { + Profile ProfileField +}{ + Profile: newProfileField(gensupport.NewDocumentField[Profile](declaration, "profile")), +} + +// ProfileField provides document queries and reads for the Profile field. +type ProfileField struct { + field gensupport.Field[Profile] + // At has one accessor per path the field orders, whose methods + // are the orderings there. + At ProfileFieldAt +} + +func newProfileField(f gensupport.Field[Profile]) ProfileField { + return ProfileField{field: f, At: ProfileFieldAt{ + Age: ProfileFieldPath{field: f, path: "$.age"}, + }} +} + +// Encrypt seals one Profile document for an update of its stored column. +func (f ProfileField) Encrypt(ctx context.Context, c *encrypt.Cipher, v Profile) (eql.JSONSearch, error) { + out, err := f.field.Encrypt(ctx, c, v) + return eql.JSONSearch(out.EQL), err +} + +// Contains derives the query for documents that contain needle: col @> query. +// needle is a json.RawMessage of JSON text, or a map, struct or slice that +// encoding/json writes; a Go string or a pointer to one is refused, as one JSON +// string is not JSON text, and so is a needle that writes JSON null (nil, or a +// nil pointer, map or slice). Each member it writes must be in the document, so +// a zero-valued struct field narrows the query to documents holding that zero: +// give a needle struct pointer fields, or omitempty, or use a map. Each array +// element of the needle must sit inside one element of the document's array. A +// needle of the field's own type, Profile, is refused for that reason, and so +// is a pointer to it: write a needle type whose fields are pointers with +// omitempty, or use a map. +func (f ProfileField) Contains(ctx context.Context, c *encrypt.Cipher, needle any) (eql.JSONSearchQuery, error) { + return gensupport.JSONQuery[eql.JSONSearchQuery](ctx, f.field, c, "contains", needle) +} + +// ContainedBy derives the query for documents contained by needle: col <@ +// query. needle is a json.RawMessage of JSON text, or a map, struct or slice +// that encoding/json writes (not a Go string or a pointer to one, nor a needle +// that writes JSON null); here a zero-valued field widens the query, admitting +// documents that hold that zero. +func (f ProfileField) ContainedBy(ctx context.Context, c *encrypt.Cipher, needle any) (eql.JSONSearchQuery, error) { + return gensupport.JSONQuery[eql.JSONSearchQuery](ctx, f.field, c, "contained_by", needle) +} + +// EqAt derives the query for documents holding the scalar v at path: col @> +// query. v is a Go string, number or bool, nil for JSON null, or a +// json.RawMessage of JSON text; a Go string is the JSON string it holds, never +// parsed as JSON. A wildcard step matches when any element holds it; a position +// ([2]) is refused, since the stored selector reads every position as the +// wildcard. +func (f ProfileField) EqAt(ctx context.Context, c *encrypt.Cipher, path string, v any) (eql.JSONSearchQuery, error) { + return gensupport.JSONQueryAt[eql.JSONSearchQuery](ctx, f.field, c, "eq_at", path, v) +} + +// Selector prepares `->` at path: bind Selector() as col -> $1::text, and open +// the entries it returns with Open; encrypt.DecodeJSON reads each value into a +// Go type. A position ([2]) is refused; read one with Subtree. +func (f ProfileField) Selector(ctx context.Context, c *encrypt.Cipher, path string) (encrypt.JSONSelection[eql.JSONEntry], error) { + return gensupport.JSONSelect[eql.JSONEntry](ctx, f.field, c, path) +} + +// Subtree prepares a read of the value at path: bind Tokens() as +// eql_v4.json_tree_subtree(col, $1::text[], budget), and open the rows it +// returns with Open or OpenOne; encrypt.DecodeJSON reads each value into a Go +// type. +func (f ProfileField) Subtree(ctx context.Context, c *encrypt.Cipher, path string, opts encrypt.JSONReadOptions) (encrypt.JSONRead[eql.JSONEntry], error) { + return gensupport.JSONSubtree[eql.JSONEntry](ctx, f.field, c, path, opts) +} + +// Children derives the token of the node at path, a path with no wildcard, +// which eql_v4.json_tree_children(col, $1::text) takes. Its rows are encrypted +// entries one level down. +func (f ProfileField) Children(ctx context.Context, c *encrypt.Cipher, path string) (encrypt.JSONToken, error) { + return gensupport.JSONNodeToken(ctx, f.field, c, "children", path) +} + +// ChildCount derives the token of the node at path, which +// eql_v4.json_tree_child_count(col, $1::text) takes: zero for a scalar or an +// empty container, NULL where the row has no node there or for the sealed root. +func (f ProfileField) ChildCount(ctx context.Context, c *encrypt.Cipher, path string) (encrypt.JSONToken, error) { + return gensupport.JSONNodeToken(ctx, f.field, c, "child_count", path) +} + +// ProfileFieldAt names the declared ordered paths of Profile. Use an accessor's +// Selector with its ordering query. +type ProfileFieldAt struct { + Age ProfileFieldPath // $.age +} + +// ProfileFieldPath is one ordered path of Profile. Its orderings derive the +// leaf operand (eql_v4.query_json_leaf) a stored leaf's ordering term is +// compared with, and Selector the path's selector. At a path with no array +// step, compare col -> Selector() with the operand, and order by +// eql_v4.ord_term(col -> Selector()). At a wildcard path ([*]) col -> +// Selector() is an unspecified element of each row, so write the comparison +// over every match, EXISTS (SELECT 1 FROM eql_v4.json_tree_path_query(col, +// $sel) e WHERE e < $1::eql_v4.query_json_leaf), and order by the least or +// greatest match's term: (SELECT eql_v4.ord_term(e) FROM +// eql_v4.json_tree_path_query(col, $sel) e ORDER BY 1 LIMIT 1). Each operand is +// a Go number or string, or a json.RawMessage of JSON text. Numbers order as +// float64, so integers past 2^53 can tie; text orders case- and accent-folded. +type ProfileFieldPath struct { + field gensupport.Field[Profile] + path string +} + +// Lt derives the operand that finds a leaf at this path less than v: (col -> +// $sel) < $1::eql_v4.query_json_leaf, or at a wildcard path the same comparison +// over every match (see ProfileFieldPath). +func (p ProfileFieldPath) Lt(ctx context.Context, c *encrypt.Cipher, v any) (eql.JSONSearchLeafQuery, error) { + return gensupport.JSONQueryAt[eql.JSONSearchLeafQuery](ctx, p.field, c, "lt_at", p.path, v) +} + +// Lte derives the operand that finds a leaf at this path at most v: (col -> +// $sel) <= $1::eql_v4.query_json_leaf, or at a wildcard path the same +// comparison over every match (see ProfileFieldPath). +func (p ProfileFieldPath) Lte(ctx context.Context, c *encrypt.Cipher, v any) (eql.JSONSearchLeafQuery, error) { + return gensupport.JSONQueryAt[eql.JSONSearchLeafQuery](ctx, p.field, c, "lte_at", p.path, v) +} + +// Gt derives the operand that finds a leaf at this path greater than v: (col -> +// $sel) > $1::eql_v4.query_json_leaf, or at a wildcard path the same comparison +// over every match (see ProfileFieldPath). +func (p ProfileFieldPath) Gt(ctx context.Context, c *encrypt.Cipher, v any) (eql.JSONSearchLeafQuery, error) { + return gensupport.JSONQueryAt[eql.JSONSearchLeafQuery](ctx, p.field, c, "gt_at", p.path, v) +} + +// Gte derives the operand that finds a leaf at this path at least v: (col -> +// $sel) >= $1::eql_v4.query_json_leaf, or at a wildcard path the same +// comparison over every match (see ProfileFieldPath). +func (p ProfileFieldPath) Gte(ctx context.Context, c *encrypt.Cipher, v any) (eql.JSONSearchLeafQuery, error) { + return gensupport.JSONQueryAt[eql.JSONSearchLeafQuery](ctx, p.field, c, "gte_at", p.path, v) +} + +// Between derives the two operands that find a leaf at this path from lo to hi, +// inclusive: (col -> $sel) >= Low AND (col -> $sel) <= High, or at a wildcard +// path the same over every match, with both bounds on one element (see +// ProfileFieldPath). +func (p ProfileFieldPath) Between(ctx context.Context, c *encrypt.Cipher, lo, hi any) (encrypt.JSONRange[eql.JSONSearchLeafQuery], error) { + return gensupport.JSONBetweenAt[eql.JSONSearchLeafQuery](ctx, p.field, c, p.path, lo, hi) +} + +// Selector prepares `->` at this path: bind Selector() beside the ordering's +// operand, from the same path, as col -> $sel, or at a wildcard path as +// eql_v4.json_tree_path_query(col, $sel), whose matches are every element. +func (p ProfileFieldPath) Selector(ctx context.Context, c *encrypt.Cipher) (encrypt.JSONSelection[eql.JSONEntry], error) { + return gensupport.JSONSelect[eql.JSONEntry](ctx, p.field, c, p.path) +} diff --git a/languages/golang/encrypt/example/json/main.go b/languages/golang/encrypt/example/json/main.go new file mode 100644 index 000000000..6072d7540 --- /dev/null +++ b/languages/golang/encrypt/example/json/main.go @@ -0,0 +1,145 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "os" + + _ "github.com/jackc/pgx/v5/stdlib" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/eql" +) + +func main() { + if err := run(context.Background()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(ctx context.Context) error { + url := os.Getenv("DATABASE_URL") + if url == "" { + return fmt.Errorf("set DATABASE_URL to a PostgreSQL database with EQL v4 installed") + } + db, err := sql.Open("pgx", url) + if err != nil { + return fmt.Errorf("open PostgreSQL: %w", err) + } + defer db.Close() + // A temporary table belongs to one connection. Keep that connection for + // the whole example so the following statements see the same table. + conn, err := db.Conn(ctx) + if err != nil { + return fmt.Errorf("connect to PostgreSQL: %w", err) + } + defer conn.Close() + if _, err := conn.ExecContext(ctx, `CREATE TEMP TABLE customers ( + id bigint PRIMARY KEY, + profile public.eql_v4_json_search NOT NULL + )`); err != nil { + return fmt.Errorf("create table (is EQL v4 installed?): %w", err) + } + + client, err := encrypt.NewClient(ctx) + if err != nil { + return fmt.Errorf("connect to ZeroKMS: %w", err) + } + defer client.Close() + cipher := client.DefaultKeyset() + customers := []Customer{ + {ID: 1, Profile: Profile{Name: "Ada", Age: 35, Visits: []Visit{{At: 10}, {At: 20}}}}, + {ID: 2, Profile: Profile{Name: "Bea", Age: 28, Visits: []Visit{{At: 30}}}}, + } + sealed, err := Encrypt(ctx, cipher, customers) + if err != nil { + return fmt.Errorf("encrypt customers: %w", err) + } + for _, customer := range sealed { + // The EQL value implements driver.Valuer, so database/sql binds it. + if _, err := conn.ExecContext(ctx, `INSERT INTO customers (id, profile) VALUES ($1, $2)`, customer.ID, customer.Profile); err != nil { + return fmt.Errorf("insert customer %d: %w", customer.ID, err) + } + } + + // A map needle names only the members to test. A Profile value would + // include its zero-valued fields in the containment query. + needle, err := Fields.Profile.Contains(ctx, cipher, map[string]any{"name": "Ada"}) + if err != nil { + return fmt.Errorf("prepare containment query: %w", err) + } + var found EncryptedCustomer + if err := conn.QueryRowContext(ctx, + `SELECT id, profile FROM customers WHERE profile @> $1::eql_v4.query_json`, needle, + ).Scan(&found.ID, &found.Profile); err != nil { + return fmt.Errorf("find customer: %w", err) + } + opened, err := Decrypt(ctx, cipher, []EncryptedCustomer{found}) + if err != nil { + return fmt.Errorf("decrypt customer: %w", err) + } + fmt.Printf("found customer %d, age %d\n", opened[0].ID, opened[0].Profile.Age) + + // Order comparisons need both the operand and the selector for the same + // declared path. Cast each SQL parameter to select EQL's operators. + selector, err := Fields.Profile.At.Age.Selector(ctx, cipher) + if err != nil { + return fmt.Errorf("prepare age selector: %w", err) + } + older, err := Fields.Profile.At.Age.Gt(ctx, cipher, 30) + if err != nil { + return fmt.Errorf("prepare age comparison: %w", err) + } + var olderID int64 + if err := conn.QueryRowContext(ctx, + `SELECT id FROM customers WHERE (profile -> $1::text) > $2::eql_v4.query_json_leaf`, + selector.Selector(), older, + ).Scan(&olderID); err != nil { + return fmt.Errorf("find older customer: %w", err) + } + fmt.Printf("customer older than 30: %d\n", olderID) + + // Read only the visit times of one row. The database returns encrypted + // entries; Open checks them against the prepared read and returns JSON text. + read, err := Fields.Profile.Subtree(ctx, cipher, "$.visits[*].at", encrypt.JSONReadOptions{}) + if err != nil { + return fmt.Errorf("prepare visit read: %w", err) + } + rows, err := conn.QueryContext(ctx, + `SELECT entry FROM customers, eql_v4.json_tree_subtree(profile, $2::text[], 64) AS entry WHERE id = $1`, + olderID, read.Tokens(), + ) + if err != nil { + return fmt.Errorf("read encrypted visits: %w", err) + } + var entries []eql.JSONEntry + for rows.Next() { + var entry eql.JSONEntry + if err := rows.Scan(&entry); err != nil { + rows.Close() + return fmt.Errorf("scan visit entry: %w", err) + } + entries = append(entries, entry) + } + if err := rows.Err(); err != nil { + rows.Close() + return fmt.Errorf("iterate visit entries: %w", err) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close visit rows: %w", err) + } + values, err := read.Open(ctx, [][]eql.JSONEntry{entries}) + if err != nil { + return fmt.Errorf("open visit entries: %w", err) + } + for _, value := range values[0] { + at, err := encrypt.DecodeJSON[int64](value) + if err != nil { + return fmt.Errorf("decode visit time: %w", err) + } + fmt.Printf("visit at %d\n", at) + } + return nil +} diff --git a/languages/golang/encrypt/example/json/model.go b/languages/golang/encrypt/example/json/model.go new file mode 100644 index 000000000..eef660abc --- /dev/null +++ b/languages/golang/encrypt/example/json/model.go @@ -0,0 +1,24 @@ +// Command json demonstrates a typed encrypted JSON document in PostgreSQL. +// Run go generate after changing Customer or its stash tags. +package main + +//go:generate go run github.com/cipherstash/stack/languages/golang/cmd/stashgen -type Customer -redact + +// Visit is one visit stored inside a customer's encrypted profile. +type Visit struct { + At int64 `json:"at"` +} + +// Profile is a typed JSON document. Its Age is searchable by order. +type Profile struct { + Name string `json:"name"` + Age int64 `json:"age"` + Visits []Visit `json:"visits"` +} + +// Customer stores Profile in one EQL v4 JSON column. +type Customer struct { + _ struct{} `stash:"context=customers"` + ID int64 `stash:"id,passthrough"` + Profile Profile `stash:"profile,encrypt_into=JsonSearch(ordered=$.age)"` +} diff --git a/languages/golang/encrypt/internal/testusers/customer_stash.go b/languages/golang/encrypt/internal/testusers/customer_stash.go index c7058678b..e8ebb07db 100644 --- a/languages/golang/encrypt/internal/testusers/customer_stash.go +++ b/languages/golang/encrypt/internal/testusers/customer_stash.go @@ -112,6 +112,8 @@ func DecryptCustomer(ctx context.Context, d encrypt.Decrypter, encrypted []Encry return customerCodec.Decrypt(ctx, d, encrypted) } +// CustomerFields provides operations for the sealed fields of Customer. Each +// entry binds queries and encrypts updates using its field's declaration. var CustomerFields = struct { Profile CustomerProfileField Prefs CustomerPrefsField @@ -120,6 +122,8 @@ var CustomerFields = struct { Prefs: newCustomerPrefsField(gensupport.NewField[json.RawMessage](customerDeclaration, "prefs")), } +// CustomerProfileField provides document queries and reads for the Profile +// field. type CustomerProfileField struct { field gensupport.Field[json.RawMessage] // At has one accessor per path the field orders, whose methods @@ -134,6 +138,7 @@ func newCustomerProfileField(f gensupport.Field[json.RawMessage]) CustomerProfil }} } +// Encrypt seals one Profile document for an update of its stored column. func (f CustomerProfileField) Encrypt(ctx context.Context, c *encrypt.Cipher, v json.RawMessage) (eql.JSONSearch, error) { out, err := f.field.Encrypt(ctx, c, v) return eql.JSONSearch(out.EQL), err @@ -199,6 +204,8 @@ func (f CustomerProfileField) ChildCount(ctx context.Context, c *encrypt.Cipher, return gensupport.JSONNodeToken(ctx, f.field, c, "child_count", path) } +// CustomerProfileFieldAt names the declared ordered paths of Profile. Use an +// accessor's Selector with its ordering query. type CustomerProfileFieldAt struct { Age CustomerProfileFieldPath // $.age VisitsAt CustomerProfileFieldPath // $.visits[*].at @@ -264,6 +271,7 @@ func (p CustomerProfileFieldPath) Selector(ctx context.Context, c *encrypt.Ciphe return gensupport.JSONSelect[eql.JSONEntry](ctx, p.field, c, p.path) } +// CustomerPrefsField provides document queries and reads for the Prefs field. type CustomerPrefsField struct { field gensupport.Field[json.RawMessage] } @@ -272,6 +280,7 @@ func newCustomerPrefsField(f gensupport.Field[json.RawMessage]) CustomerPrefsFie return CustomerPrefsField{field: f} } +// Encrypt seals one Prefs document for an update of its stored column. func (f CustomerPrefsField) Encrypt(ctx context.Context, c *encrypt.Cipher, v json.RawMessage) (eql.JSONSearch, error) { out, err := f.field.Encrypt(ctx, c, v) return eql.JSONSearch(out.EQL), err diff --git a/languages/golang/encrypt/internal/testusers/subscriber_stash.go b/languages/golang/encrypt/internal/testusers/subscriber_stash.go index 6768e0870..486c74648 100644 --- a/languages/golang/encrypt/internal/testusers/subscriber_stash.go +++ b/languages/golang/encrypt/internal/testusers/subscriber_stash.go @@ -120,6 +120,8 @@ func DecryptSubscriber(ctx context.Context, d encrypt.Decrypter, encrypted []Enc return subscriberCodec.Decrypt(ctx, d, encrypted) } +// SubscriberFields provides operations for the sealed fields of Subscriber. +// Each entry binds queries and encrypts updates using its field's declaration. var SubscriberFields = struct { Profile SubscriberProfileField Prefs SubscriberPrefsField @@ -130,6 +132,8 @@ var SubscriberFields = struct { Labels: newSubscriberLabelsField(gensupport.NewField[map[string]string](subscriberDeclaration, "labels")), } +// SubscriberProfileField provides document queries and reads for the Profile +// field. type SubscriberProfileField struct { field gensupport.Field[SubscriberProfile] // At has one accessor per path the field orders, whose methods @@ -144,6 +148,7 @@ func newSubscriberProfileField(f gensupport.Field[SubscriberProfile]) Subscriber }} } +// Encrypt seals one Profile document for an update of its stored column. func (f SubscriberProfileField) Encrypt(ctx context.Context, c *encrypt.Cipher, v SubscriberProfile) (eql.JSONSearch, error) { out, err := f.field.Encrypt(ctx, c, v) return eql.JSONSearch(out.EQL), err @@ -212,6 +217,8 @@ func (f SubscriberProfileField) ChildCount(ctx context.Context, c *encrypt.Ciphe return gensupport.JSONNodeToken(ctx, f.field, c, "child_count", path) } +// SubscriberProfileFieldAt names the declared ordered paths of Profile. Use an +// accessor's Selector with its ordering query. type SubscriberProfileFieldAt struct { Age SubscriberProfileFieldPath // $.age VisitsAt SubscriberProfileFieldPath // $.visits[*].at @@ -277,6 +284,7 @@ func (p SubscriberProfileFieldPath) Selector(ctx context.Context, c *encrypt.Cip return gensupport.JSONSelect[eql.JSONEntry](ctx, p.field, c, p.path) } +// SubscriberPrefsField provides document queries and reads for the Prefs field. type SubscriberPrefsField struct { field gensupport.Field[SubscriberPrefs] } @@ -285,6 +293,7 @@ func newSubscriberPrefsField(f gensupport.Field[SubscriberPrefs]) SubscriberPref return SubscriberPrefsField{field: f} } +// Encrypt seals one Prefs document for an update of its stored column. func (f SubscriberPrefsField) Encrypt(ctx context.Context, c *encrypt.Cipher, v SubscriberPrefs) (eql.JSONSearch, error) { out, err := f.field.Encrypt(ctx, c, v) return eql.JSONSearch(out.EQL), err @@ -353,6 +362,8 @@ func (f SubscriberPrefsField) ChildCount(ctx context.Context, c *encrypt.Cipher, return gensupport.JSONNodeToken(ctx, f.field, c, "child_count", path) } +// SubscriberLabelsField provides document queries and reads for the Labels +// field. type SubscriberLabelsField struct { field gensupport.Field[map[string]string] } @@ -361,6 +372,7 @@ func newSubscriberLabelsField(f gensupport.Field[map[string]string]) SubscriberL return SubscriberLabelsField{field: f} } +// Encrypt seals one Labels document for an update of its stored column. func (f SubscriberLabelsField) Encrypt(ctx context.Context, c *encrypt.Cipher, v map[string]string) (eql.JSONSearch, error) { out, err := f.field.Encrypt(ctx, c, v) return eql.JSONSearch(out.EQL), err diff --git a/languages/golang/stashgen/emit.go b/languages/golang/stashgen/emit.go index 96267932f..a537ab0c6 100644 --- a/languages/golang/stashgen/emit.go +++ b/languages/golang/stashgen/emit.go @@ -677,6 +677,12 @@ func (w *writer) fieldEntries(f *genFile) { return } w.nl() + for _, g := range sealed { + if g.isJSON() { + w.b.WriteString(wrapComment(fmt.Sprintf("%s provides operations for the sealed fields of %s. Each entry binds queries and encrypts updates using its field's declaration.", f.fieldsVar, f.typeName), 80)) + break + } + } w.p("var %s = struct {", f.fieldsVar) for _, g := range sealed { w.p("\t%s %s", g.GoName, g.fieldType) @@ -1113,6 +1119,7 @@ func (w *writer) jsonEntry(g genField) { ordered := len(g.jsonPaths) > 0 field := "gensupport.Field[" + g.valueExpr + "]" + w.b.WriteString(wrapComment(fmt.Sprintf("%s provides document queries and reads for the %s field.", g.fieldType, g.GoName), 80)) w.p("type %s struct {", g.fieldType) w.p("\tfield %s", field) if ordered { @@ -1134,6 +1141,7 @@ func (w *writer) jsonEntry(g genField) { } w.p("}") w.nl() + w.b.WriteString(wrapComment(fmt.Sprintf("Encrypt seals one %s document for an update of its stored column.", g.GoName), 80)) w.p("func (f %s) Encrypt(ctx context.Context, c *encrypt.Cipher, v %s) (%s, error) {", g.fieldType, g.valueExpr, g.outputType) w.sealedOutput(g, "f.field.Encrypt(ctx, c, v)") w.p("}") @@ -1198,6 +1206,7 @@ func (w *writer) jsonEntry(g genField) { return } w.nl() + w.b.WriteString(wrapComment(fmt.Sprintf("%sAt names the declared ordered paths of %s. Use an accessor's Selector with its ordering query.", g.fieldType, g.GoName), 80)) w.p("type %sAt struct {", g.fieldType) for _, p := range g.jsonPaths { w.p("\t%s %sPath // %s", p.name, g.fieldType, p.path) diff --git a/scripts/__tests__/go-eql-doc-capabilities.test.mjs b/scripts/__tests__/go-eql-doc-capabilities.test.mjs new file mode 100644 index 000000000..c84a622fb --- /dev/null +++ b/scripts/__tests__/go-eql-doc-capabilities.test.mjs @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const catalog = readFileSync('languages/golang/encrypt/eql/eql_gen.go', 'utf8') +const guide = readFileSync('languages/golang/cmd/stashgen/README.md', 'utf8') + +describe('Go EQL capability reference', () => { + it('lists exactly the EQL types the generated catalog says stashgen can produce', () => { + const producible = new Set( + [...catalog.matchAll(/\{Name: "([^"]+)"[^\n]*\bProducible: true\b/g)].map( + ([, name]) => name, + ), + ) + expect(producible.size).toBeGreaterThan(0) + + const tableStart = guide.indexOf('| `encrypt_into` | Go field |') + expect(tableStart).toBeGreaterThan(-1) + const rows = guide.slice(tableStart).split('\n').slice(2) + const documented = [] + for (const row of rows) { + if (!row.startsWith('|')) break + const firstCell = row.split('|')[1] + documented.push( + ...[...firstCell.matchAll(/`([A-Za-z][A-Za-z0-9]*)`/g)].map( + ([, name]) => name, + ), + ) + } + + expect(new Set(documented).size).toBe(documented.length) + expect(new Set(documented)).toEqual(producible) + }) +}) From be96a9e52e411a715f5a551ef573c26edbd683f5 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 27 Oct 2026 01:01:14 +1100 Subject: [PATCH 2/4] fix(go): close example query rows on error --- languages/golang/encrypt/example/json/main.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/languages/golang/encrypt/example/json/main.go b/languages/golang/encrypt/example/json/main.go index 6072d7540..58c60a328 100644 --- a/languages/golang/encrypt/example/json/main.go +++ b/languages/golang/encrypt/example/json/main.go @@ -114,17 +114,16 @@ func run(ctx context.Context) error { if err != nil { return fmt.Errorf("read encrypted visits: %w", err) } + defer rows.Close() var entries []eql.JSONEntry for rows.Next() { var entry eql.JSONEntry if err := rows.Scan(&entry); err != nil { - rows.Close() return fmt.Errorf("scan visit entry: %w", err) } entries = append(entries, entry) } if err := rows.Err(); err != nil { - rows.Close() return fmt.Errorf("iterate visit entries: %w", err) } if err := rows.Close(); err != nil { From 657d7d8623a2b36b5fa8b8177bba44fd955f0085 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 27 Oct 2026 01:09:25 +1100 Subject: [PATCH 3/4] docs(go): build embedded guests before source tests --- CONTRIBUTING.md | 2 ++ languages/golang/README.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44e0fb851..c288acd22 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,6 +87,8 @@ The Go module is in `languages/golang/`, outside the pnpm workspace. Its checkout, build those guests before running the Go suite: ```bash +mise run wasm:guest:build +mise run wasm:guest:build:eql mise run wasm:guest:build:deterministic mise run wasm:guest:build:eql:deterministic mise run wasm:auth-guest:build diff --git a/languages/golang/README.md b/languages/golang/README.md index 798498066..b5ffefe9a 100644 --- a/languages/golang/README.md +++ b/languages/golang/README.md @@ -37,6 +37,8 @@ From the repository root, install the toolchain pinned by `mise.toml` and build the guests before running Go tests: ```sh +mise run wasm:guest:build +mise run wasm:guest:build:eql mise run wasm:guest:build:deterministic mise run wasm:guest:build:eql:deterministic mise run wasm:auth-guest:build From 238ac9a427b98f25646b7e16fdc261d8e11aeaa0 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 27 Oct 2026 01:30:04 +1100 Subject: [PATCH 4/4] docs(go): align match release notes with two-letter queries --- languages/golang/CHANGELOG.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/languages/golang/CHANGELOG.md b/languages/golang/CHANGELOG.md index b0f38a551..48239517d 100644 --- a/languages/golang/CHANGELOG.md +++ b/languages/golang/CHANGELOG.md @@ -291,12 +291,13 @@ listed under **Breaking**. Releases are tagged `languages/golang/vX.Y.Z`. these types: it is not a tag word. - **`TextSearch`**: a `string` field tagged `encrypt_into=TextSearch` gets `Eq`, `Lt`, `Lte`, `Gt`, `Gte` and `Matches`, one column for all of them. - The stored value keeps all three terms. Text shorter than three - characters, an empty string included, is stored with an empty match + The stored value keeps all three terms. Text that cuts no match token, + such as an empty string or one letter, is stored with an empty match filter, where `TextMatch` refuses it with `ErrTerm`: `Eq`, `Lt`, `Lte`, `Gt` and `Gte` find the row, and no `Matches` query does. Each query carries only the term its operator reads, so `Eq` and `Lt` take a - two-letter value and `Matches` refuses one (`ErrTerm`). Needs the EQL v4 + one-letter value and `Matches` refuses one (`ErrTerm`). A two-letter + word has its own match token, as described above. Needs the EQL v4 SQL from this release, whose `eql_v4.query_text_search` accepts one term. - **Ordering methods on text.** A string field with `index=ore` or `index=ope` gets `Lt`, `Lte`, `Gt`, `Gte` and `Between`. Text orders by its