From c7ce8b9552c3eccb03184fa3df6d6ec0a0820d46 Mon Sep 17 00:00:00 2001 From: Michael Welles Date: Thu, 21 May 2026 17:42:42 -0400 Subject: [PATCH 1/9] docs(specs): add IterNodes streaming query terminal design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design for typed.Query[T].IterNodes() — a streaming counterpart to Nodes() that pages results instead of materializing them. Records the decisions to respect caller-set Limit/Offset (Query[T] tracks its own bounds) and to unify Client.Iter onto the new terminal. --- .../2026-05-21-query-iternodes-design.md | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 docs/specs/2026-05-21-query-iternodes-design.md diff --git a/docs/specs/2026-05-21-query-iternodes-design.md b/docs/specs/2026-05-21-query-iternodes-design.md new file mode 100644 index 0000000..d9d07cf --- /dev/null +++ b/docs/specs/2026-05-21-query-iternodes-design.md @@ -0,0 +1,251 @@ +--- +date: 2026-05-21 +topic: query-iternodes +status: draft +--- + +# A Streaming `IterNodes()` Terminal for `typed.Query[T]` + +## Goal + +Give a built query a way to **stream** its results instead of only +materializing them. `typed.Query[T]` today has one collecting terminal, +`Nodes() ([]T, error)`, which loads the entire result set into a slice. Add a +second terminal, `IterNodes() iter.Seq2[*T, error]`, that pages through the +results transparently so a large or unbounded result set is never held in +memory at once. + +The generated wrapper `Query` gains a matching `IterNodes()` yielding +wrapped `*` values, so the streaming terminal is reachable from the same +call site as `Nodes()`: `client.Foo.Query(ctx).Filter(...).IterNodes()`. + +`typed.Client[T].Iter` — which already streams *all* records of a type — is +re-expressed in terms of the new terminal, removing a duplicated paging loop. + +## Non-Goals + +- A configurable page size. `IterNodes()` uses the existing fixed + `defaultPageSize = 50`, matching `Client.Iter`. +- A top-level `Client.Iter` (iterate-all from the generated wrapper client). + That is a separate, parallel gap; this change is scoped to the query + terminal. +- Cursor-based (`After`) iteration. `IterNodes()` is offset-paged. +- Changing `Nodes()` or `First()`. + +## Why This Approach + +Two design decisions, settled during brainstorming: + +**Respect caller-set `Limit`/`Offset` (not override them).** A built query may +already carry `.Limit(n)`/`.Offset(n)`. `IterNodes()` must itself drive +`Offset`/`Limit` to page, so it collides with any caller-set bounds. The chosen +behavior: a caller `Limit` caps the total rows streamed; a caller `Offset` is +the starting point. This is the intuitive reading of +`.Offset(100).Limit(500).IterNodes()` and avoids a silent-discard footgun. The +cost is that `Query[T]` must track `limit`/`offset` in its own struct fields — +it currently delegates straight to dgman, whose fields are unexported and +unreadable. This is a small, contained departure from "pure pass-through." + +**Unify `Client.Iter` onto the new terminal.** `Client[T].Iter` is structurally +"`IterNodes()` over an unfiltered query." Collapsing it removes a duplicated +paging loop — and the loop encodes subtle logic (offset advance, the +short-page stop condition, error-as-final-yield) that should have one tested +source of truth. The unification is also a correctness upgrade: see below. + +**The one-transaction snapshot.** `IterNodes()` pages by re-executing a single +`*dg.Query`. modusgraph creates that query's read-only transaction once and +reuses it across executions, so every page reads from one server snapshot — a +writer committing mid-iteration cannot make the stream skip or duplicate rows. +The current `Client.Iter` builds a fresh query (and fresh transaction) per +page and *does* have that hazard; its doc comment admits it. Unification fixes +that. The mirror-image caveat — one pinned snapshot has a server-side lifetime, +so an extremely long-paused iteration could outlive it on remote Dgraph — is a +doc note, irrelevant to the embedded file engine. + +## Design + +### `typed.Query[T]` — tracked bounds + `IterNodes()` + +`Query[T]` gains two fields. `0` means "unset" for both, consistent with +dgman, which emits the `first:`/`offset:` clauses only for non-zero values. + +```go +type Query[T any] struct { + q *dg.Query + limit int // caller-set row cap; 0 = unbounded + offset int // caller-set starting offset; 0 = none +} +``` + +`Limit` and `Offset` record the value locally as well as forwarding to dgman: + +```go +func (qb *Query[T]) Limit(n int) *Query[T] { + qb.limit = n + qb.q.First(n) + return qb +} + +func (qb *Query[T]) Offset(n int) *Query[T] { + qb.offset = n + qb.q.Offset(n) + return qb +} +``` + +`Nodes()`, `First()`, `Raw()`, and all other builder methods are unchanged — +they already execute `qb.q`, which carries the bounds via the dgman calls. + +New terminal: + +```go +// IterNodes executes the query and returns an iterator over matching records, +// paging transparently so a large result set is never materialized at once. +// +// IterNodes is a terminal operation: it drives Offset/Limit internally as it +// pages and leaves the builder spent — do not call another terminal on the +// same Query afterward. A Limit set on the query caps the total number of +// rows streamed; an Offset is the starting point. +// +// All pages execute against one read-only transaction, so the iteration reads +// a single consistent snapshot: a concurrent writer cannot make it skip or +// repeat rows. On error it yields a final (nil, err) and stops. +func (qb *Query[T]) IterNodes() iter.Seq2[*T, error] { + return func(yield func(*T, error) bool) { + remaining := qb.limit // 0 = unbounded + for off := qb.offset; ; off += defaultPageSize { + size := defaultPageSize + if remaining > 0 && remaining < size { + size = remaining // shrink the last page so it can't overshoot the cap + } + var page []T + if err := qb.q.Offset(off).First(size).Nodes(&page); err != nil { + yield(nil, err) + return + } + for i := range page { + if !yield(&page[i], nil) { + return // consumer broke out + } + } + if remaining > 0 { + if remaining -= len(page); remaining <= 0 { + return // hit the caller's Limit + } + } + if len(page) < size { + return // result set exhausted + } + } + } +} +``` + +The `Query[T]` type doc comment's terminal list (`Nodes, First`) gains +`IterNodes`. + +Behavior across the cases: + +- `q.IterNodes()` — `offset 0, limit 0` — streams every matching record, + 50 at a time, until a short page. +- `q.Offset(100).Limit(120).IterNodes()` — pages `100‑149`, `150‑199`, then a + final `size=20` page `200‑219`; stops at exactly 120 rows. +- `q.Filter(...).IterNodes()` — streams all matches. + +### `Client.Iter` unification + +`Client[T].Iter` collapses to a delegation: + +```go +// Iter returns an iterator over every T, paging transparently. All pages read +// one consistent read-only snapshot. On error it yields a final (nil, err). +func (c *Client[T]) Iter(ctx context.Context) iter.Seq2[*T, error] { + return c.Query(ctx).IterNodes() +} +``` + +A fresh `c.Query(ctx)` carries no bounds, so `IterNodes()` streams everything — +identical observable behavior to the prior loop. The `defaultPageSize` const +stays (now consumed by `IterNodes`). + +The current `Client.Iter` doc comment warns "a data set mutated mid-iteration +may skip or repeat rows." After unification that is false — the iteration +pages one snapshot. The doc comment is corrected to describe the +snapshot-consistent behavior (shown above). + +### Wrapper layer — generated `Query.IterNodes()` + +`cmd/modusgraph-gen/internal/generator/templates/wrapper_query.go.tmpl` gains a +generated `IterNodes()` — the streaming analogue of the existing generated +`Nodes()`: + +```go +// IterNodes streams the query's results as wrapped {{ $E }} values, paging +// transparently. Terminal operation; see typed.Query.IterNodes. +func (q *{{ $E }}Query) IterNodes() iter.Seq2[*{{ $E }}, error] { + return func(yield func(*{{ $E }}, error) bool) { + for s, err := range q.typed.IterNodes() { + if err != nil { + yield(nil, err) + return + } + if !yield(Wrap{{ $E }}(s), nil) { + return + } + } + } +} +``` + +The template adds `"iter"` to its import block. The inner `q.typed.IterNodes()` +yields `*schema.`, so `Wrap(s)` applies directly. The `movies` fixture is +regenerated. + +## Error handling + +`IterNodes()` is a pure pass-through of errors from the underlying +`modusgraph.Client` query execution. On the first page that errors, it yields +exactly one `(nil, err)` pair and stops — the established `Client.Iter` +contract. Builder methods never execute, so no error can arise before the +first `yield`. The wrapper `Query.IterNodes()` forwards the error pair +unchanged. + +## Testing + +- **`typed/query_test.go`** — behavioral tests against the local file-backed + client: unbounded `IterNodes()` streams all records; a caller `Limit` caps + the total; a caller `Offset` is the start; `Offset`+`Limit` yields the exact + window; a >50-record set forces multiple pages; a consumer `break` stops + iteration early; the error path; an empty result yields nothing. A regression + test that `Limit`/`Offset` still drive `Nodes()` correctly now that they also + set struct fields. A counting-logger test (`newCountingConn`) asserting an + N-record stream executes ⌈N/50⌉ queries — builder methods execute none. +- **`typed/client_test.go`** — the existing `Client.Iter` tests + (`TestClient_IterPagesThroughAllRecords`, `TestClient_IterStopsOnConsumerBreak`) + must still pass unchanged after the unification; they assert record counts, + which are unaffected. +- **`generator_test.go`** — `TestGenerate_WrapperQuery` gains an assertion that + the generated `Query` includes the `IterNodes` method. +- **`wrapper_query_e2e_test.go`** — a behavioral test that + `client.Film.Query(ctx)...IterNodes()` streams correctly wrapped `*Film` + values. + +## Migration / blast radius + +- **Modified:** `typed/query.go` (two struct fields, `Limit`/`Offset` bodies, + the `IterNodes` terminal, the type doc comment); `typed/client.go` + (`Client.Iter` collapses to a delegation, doc comment corrected); + `wrapper_query.go.tmpl` (generated `IterNodes` + `iter` import); + `generator_test.go` (one added assertion). +- **Regenerated:** the `movies` fixture — every `*_query_gen.go` gains an + `IterNodes` method. +- **New tests** in `typed/query_test.go` and `wrapper_query_e2e_test.go`. +- No change to `Nodes()`, `First()`, CRUD, or any other generated artifact. + `Client.Iter`'s signature and observable behavior are unchanged (its internal + paging and doc comment change). + +## Open decisions + +None. Naming (`IterNodes`), the caller-bounds policy (respect them), the +page size (fixed `defaultPageSize`), and the `Client.Iter` unification were all +settled during brainstorming. From e3536d736036c5f56499afb338ca7079b5e9212e Mon Sep 17 00:00:00 2001 From: Michael Welles Date: Thu, 21 May 2026 18:02:33 -0400 Subject: [PATCH 2/9] feat(typed): add streaming IterNodes terminal to Query[T] --- typed/query.go | 53 +++++++++- typed/query_test.go | 242 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 292 insertions(+), 3 deletions(-) diff --git a/typed/query.go b/typed/query.go index c604619..fa7204d 100644 --- a/typed/query.go +++ b/typed/query.go @@ -6,12 +6,14 @@ package typed import ( + "iter" + dg "github.com/dolan-in/dgman/v2" ) // Query is a fluent, type-safe query builder over records of type T. Builder -// methods return *Query[T] for chaining; terminal methods (Nodes, First) -// execute the query and decode typed results. +// methods return *Query[T] for chaining; terminal methods (Nodes, First, +// IterNodes) execute the query and decode typed results. // // A Query is single-use. Builder methods mutate the underlying query in place // and return the same *Query, so a Query value should be built as one chain @@ -23,7 +25,9 @@ import ( // Offset, After, and Cascade overwrite: the last call wins. OrderAsc and // OrderDesc accumulate: each call adds to the query. type Query[T any] struct { - q *dg.Query + q *dg.Query + limit int // caller-set row cap; 0 = unbounded + offset int // caller-set starting offset; 0 = none } // Filter adds a dgraph @filter expression. params bind to placeholders. @@ -47,12 +51,14 @@ func (qb *Query[T]) OrderDesc(clause string) *Query[T] { // Limit caps the number of results. dgman names this First; it is renamed // here so it does not collide with the First terminal. func (qb *Query[T]) Limit(n int) *Query[T] { + qb.limit = n qb.q.First(n) return qb } // Offset skips the first n results. func (qb *Query[T]) Offset(n int) *Query[T] { + qb.offset = n qb.q.Offset(n) return qb } @@ -91,6 +97,47 @@ func (qb *Query[T]) First() (*T, error) { return &out[0], nil } +// IterNodes executes the query and returns an iterator over matching records, +// paging transparently so a large result set is never materialized at once. +// +// IterNodes is a terminal operation: it drives Offset/Limit internally as it +// pages and leaves the builder spent — do not call another terminal on the +// same Query afterward. A Limit set on the query caps the total number of +// rows streamed; an Offset is the starting point. +// +// All pages execute against one read-only transaction, so the iteration reads +// a single consistent snapshot: a concurrent writer cannot make it skip or +// repeat rows. On error it yields a final (nil, err) and stops. +func (qb *Query[T]) IterNodes() iter.Seq2[*T, error] { + return func(yield func(*T, error) bool) { + remaining := qb.limit // 0 = unbounded + for off := qb.offset; ; off += defaultPageSize { + size := defaultPageSize + if remaining > 0 && remaining < size { + size = remaining // shrink the last page so it can't overshoot the cap + } + var page []T + if err := qb.q.Offset(off).First(size).Nodes(&page); err != nil { + yield(nil, err) + return + } + for i := range page { + if !yield(&page[i], nil) { + return // consumer broke out + } + } + if remaining > 0 { + if remaining -= len(page); remaining <= 0 { + return // hit the caller's Limit + } + } + if len(page) < size { + return // result set exhausted + } + } + } +} + // Raw returns the underlying dgman query for operations Query does not wrap // (Var, As, Name, RootFunc, GroupBy, Vars). func (qb *Query[T]) Raw() *dg.Query { diff --git a/typed/query_test.go b/typed/query_test.go index 508178d..294e22d 100644 --- a/typed/query_test.go +++ b/typed/query_test.go @@ -526,3 +526,245 @@ func TestQuery_SingleQueryPerTerminal(t *testing.T) { t.Fatalf("First executed %d queries, want exactly 1", got) } } + +func TestIterNodes_StreamsAll(t *testing.T) { + ctx := context.Background() + c := typed.NewClient[widget](newConn(t)) + const n = 125 // > defaultPageSize (50): forces multiple pages + for i := range n { + if err := c.Add(ctx, &widget{Name: "w", Qty: i}); err != nil { + t.Fatalf("Add %d: %v", i, err) + } + } + seen := 0 + for w, err := range c.Query(ctx).IterNodes() { + if err != nil { + t.Fatalf("IterNodes yielded error: %v", err) + } + if w == nil { + t.Fatal("IterNodes yielded a nil widget") + } + seen++ + } + if seen != n { + t.Fatalf("IterNodes streamed %d records, want %d", seen, n) + } +} + +func TestIterNodes_StopsOnConsumerBreak(t *testing.T) { + ctx := context.Background() + c := typed.NewClient[widget](newConn(t)) + const n = 125 + for i := range n { + if err := c.Add(ctx, &widget{Name: "w", Qty: i}); err != nil { + t.Fatalf("Add %d: %v", i, err) + } + } + seen := 0 + for _, err := range c.Query(ctx).IterNodes() { + if err != nil { + t.Fatalf("IterNodes yielded error: %v", err) + } + seen++ + if seen == 10 { + break + } + } + if seen != 10 { + t.Fatalf("IterNodes yielded %d records after break at 10, want 10", seen) + } +} + +func TestIterNodes_EmptyResult(t *testing.T) { + ctx := context.Background() + c := typed.NewClient[widget](newConn(t)) + seen := 0 + for _, err := range c.Query(ctx).IterNodes() { + if err != nil { + t.Fatalf("IterNodes over empty set yielded error: %v", err) + } + seen++ + } + if seen != 0 { + t.Fatalf("IterNodes over empty set yielded %d records, want 0", seen) + } +} + +func TestIterNodes_RespectsLimit(t *testing.T) { + ctx := context.Background() + c := typed.NewClient[widget](newConn(t)) + const n = 100 + for i := range n { + if err := c.Add(ctx, &widget{Name: "w", Qty: i}); err != nil { + t.Fatalf("Add %d: %v", i, err) + } + } + seen := 0 + for _, err := range c.Query(ctx).Limit(30).IterNodes() { + if err != nil { + t.Fatalf("IterNodes yielded error: %v", err) + } + seen++ + } + if seen != 30 { + t.Fatalf("Limit(30).IterNodes() streamed %d records, want 30", seen) + } +} + +func TestIterNodes_LimitExceedsResultSet(t *testing.T) { + ctx := context.Background() + c := typed.NewClient[widget](newConn(t)) + const n = 30 + for i := range n { + if err := c.Add(ctx, &widget{Name: "w", Qty: i}); err != nil { + t.Fatalf("Add %d: %v", i, err) + } + } + seen := 0 + for _, err := range c.Query(ctx).Limit(500).IterNodes() { + if err != nil { + t.Fatalf("IterNodes yielded error: %v", err) + } + seen++ + } + if seen != n { + t.Fatalf("Limit(500).IterNodes() over %d records streamed %d, want %d", n, seen, n) + } +} + +func TestIterNodes_RespectsOffset(t *testing.T) { + ctx := context.Background() + c := typed.NewClient[widget](newConn(t)) + // Qty values start at 1 (not 0) so omitempty never suppresses the field, + // keeping OrderAsc("qty") a true total order over all records. + const n = 10 + for i := range n { + if err := c.Add(ctx, &widget{Name: "w", Qty: i + 1}); err != nil { + t.Fatalf("Add %d: %v", i, err) + } + } + var got []int + for w, err := range c.Query(ctx).OrderAsc("qty").Offset(3).IterNodes() { + if err != nil { + t.Fatalf("IterNodes yielded error: %v", err) + } + got = append(got, w.Qty) + } + if len(got) != 7 { + t.Fatalf("Offset(3).IterNodes() streamed %d records, want 7", len(got)) + } + for i, q := range got { + if q != i+4 { // Qty=1..10; offset 3 skips 1,2,3 → starts at 4 + t.Fatalf("Offset(3).IterNodes()[%d] Qty = %d, want %d", i, q, i+4) + } + } +} + +func TestIterNodes_RespectsOffsetAndLimit(t *testing.T) { + ctx := context.Background() + c := typed.NewClient[widget](newConn(t)) + // Qty values start at 1 so omitempty never suppresses the field and + // OrderAsc("qty") is a strict total order across all 200 records. + const n = 200 + for i := range n { + if err := c.Add(ctx, &widget{Name: "w", Qty: i + 1}); err != nil { + t.Fatalf("Add %d: %v", i, err) + } + } + var got []int + for w, err := range c.Query(ctx).OrderAsc("qty").Offset(60).Limit(120).IterNodes() { + if err != nil { + t.Fatalf("IterNodes yielded error: %v", err) + } + got = append(got, w.Qty) + } + if len(got) != 120 { + t.Fatalf("Offset(60).Limit(120).IterNodes() streamed %d records, want 120", len(got)) + } + for i, q := range got { + if q != i+61 { // Qty=1..200; offset 60 skips 1..60 → starts at 61 + t.Fatalf("result[%d] Qty = %d, want %d", i, q, i+61) + } + } +} + +func TestIterNodes_OneQueryPerPage(t *testing.T) { + ctx := context.Background() + var queriesExecuted int + c := typed.NewClient[widget](newCountingConn(t, &queriesExecuted)) + const n = 125 // ceil(125/50) = 3 page queries + for i := range n { + if err := c.Add(ctx, &widget{Name: "w", Qty: i}); err != nil { + t.Fatalf("Add %d: %v", i, err) + } + } + // Obtaining the iterator runs no query — IterNodes is lazy. + seq := c.Query(ctx).IterNodes() + if queriesExecuted != 0 { + t.Fatalf("building the IterNodes iterator executed %d queries, want 0", queriesExecuted) + } + seen := 0 + for _, err := range seq { + if err != nil { + t.Fatalf("IterNodes yielded error: %v", err) + } + seen++ + } + if seen != n { + t.Fatalf("IterNodes streamed %d records, want %d", seen, n) + } + if queriesExecuted != 3 { + t.Fatalf("IterNodes over %d records ran %d queries, want 3", n, queriesExecuted) + } +} + +func TestIterNodes_YieldsErrorAndStops(t *testing.T) { + ctx := context.Background() + c := typed.NewClient[widget](newConn(t)) + if err := c.Add(ctx, &widget{Name: "w", Qty: 1}); err != nil { + t.Fatalf("Add: %v", err) + } + // A syntactically invalid @filter (unbalanced parenthesis) makes the page + // query fail at execution; IterNodes must yield one (nil, err) and stop. + gotErr := false + for w, err := range c.Query(ctx).Filter(`eq(name, "w"`).IterNodes() { + if err != nil { + gotErr = true + if w != nil { + t.Fatalf("error yield carried a non-nil widget: %+v", w) + } + break + } + t.Fatal("IterNodes over a malformed query yielded a record before erroring") + } + if !gotErr { + t.Fatal("IterNodes over a malformed query did not yield an error") + } +} + +func TestQuery_LimitOffsetStillDriveNodes(t *testing.T) { + ctx := context.Background() + c := typed.NewClient[widget](newConn(t)) + // Qty values start at 1 so omitempty never suppresses the field and + // OrderAsc("qty") is a strict total order across all records. + const n = 10 + for i := range n { + if err := c.Add(ctx, &widget{Name: "w", Qty: i + 1}); err != nil { + t.Fatalf("Add %d: %v", i, err) + } + } + // Regression: Limit/Offset now also set Query struct fields; confirm they + // still drive the Nodes terminal. + got, err := c.Query(ctx).OrderAsc("qty").Offset(2).Limit(3).Nodes() + if err != nil { + t.Fatalf("Nodes: %v", err) + } + if len(got) != 3 { + t.Fatalf("Offset(2).Limit(3).Nodes() returned %d records, want 3", len(got)) + } + for i, w := range got { + if w.Qty != i+3 { // Qty=1..10; offset 2 skips 1,2 → starts at 3 + t.Fatalf("Nodes()[%d] Qty = %d, want %d", i, w.Qty, i+3) + } + } +} From f54dc61a0ff66722f5c365bfe4bedd4dad92751c Mon Sep 17 00:00:00 2001 From: Michael Welles Date: Thu, 21 May 2026 18:08:46 -0400 Subject: [PATCH 3/9] refactor(typed): unify Client.Iter onto Query.IterNodes --- typed/client.go | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/typed/client.go b/typed/client.go index 0e8dd54..1a22055 100644 --- a/typed/client.go +++ b/typed/client.go @@ -64,29 +64,13 @@ func (c *Client[T]) Query(ctx context.Context) *Query[T] { return &Query[T]{q: c.conn.Query(ctx, &z)} } -// defaultPageSize is the page size Iter uses to walk results. +// defaultPageSize is the page size IterNodes uses to page through results. const defaultPageSize = 50 // Iter returns an iterator over every T, paging transparently so large result // sets are not materialized at once. It yields each record in turn; on error -// it yields a final (nil, err) and stops. Iteration is offset-paged, so a data -// set mutated mid-iteration may skip or repeat rows. +// it yields a final (nil, err) and stops. All pages execute against one +// read-only transaction, so the iteration reads a single consistent snapshot. func (c *Client[T]) Iter(ctx context.Context) iter.Seq2[*T, error] { - return func(yield func(*T, error) bool) { - for offset := 0; ; offset += defaultPageSize { - page, err := c.Query(ctx).Offset(offset).Limit(defaultPageSize).Nodes() - if err != nil { - yield(nil, err) - return - } - for i := range page { - if !yield(&page[i], nil) { - return - } - } - if len(page) < defaultPageSize { - return - } - } - } + return c.Query(ctx).IterNodes() } From 63938c74759c78e7667c07848224762a24d434af Mon Sep 17 00:00:00 2001 From: Michael Welles Date: Thu, 21 May 2026 18:13:24 -0400 Subject: [PATCH 4/9] feat(modusgraph-gen): emit IterNodes on generated wrapper queries --- .../internal/generator/generator_test.go | 2 ++ .../generator/templates/wrapper_query.go.tmpl | 22 +++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/cmd/modusgraph-gen/internal/generator/generator_test.go b/cmd/modusgraph-gen/internal/generator/generator_test.go index ffffc48..67bc9c0 100644 --- a/cmd/modusgraph-gen/internal/generator/generator_test.go +++ b/cmd/modusgraph-gen/internal/generator/generator_test.go @@ -1044,6 +1044,7 @@ func TestGenerate_WrapperQuery(t *testing.T) { for _, want := range []string{ `package entity`, `"github.com/matthewmcneely/modusgraph/typed"`, + `"iter"`, `type StudioQuery struct {`, `typed *typed.Query[schema.Studio]`, `func (q *StudioQuery) Filter(filter string, params ...any) *StudioQuery`, @@ -1055,6 +1056,7 @@ func TestGenerate_WrapperQuery(t *testing.T) { `func (q *StudioQuery) Cascade(predicates ...string) *StudioQuery`, `func (q *StudioQuery) Nodes() ([]*Studio, error)`, `func (q *StudioQuery) First() (*Studio, error)`, + `func (q *StudioQuery) IterNodes() iter.Seq2[*Studio, error]`, `return WrapStudio(s), nil`, } { if !strings.Contains(data, want) { diff --git a/cmd/modusgraph-gen/internal/generator/templates/wrapper_query.go.tmpl b/cmd/modusgraph-gen/internal/generator/templates/wrapper_query.go.tmpl index 1cb5600..11bbb1b 100644 --- a/cmd/modusgraph-gen/internal/generator/templates/wrapper_query.go.tmpl +++ b/cmd/modusgraph-gen/internal/generator/templates/wrapper_query.go.tmpl @@ -1,6 +1,8 @@ package {{ .EntityPackageName }} import ( + "iter" + "github.com/matthewmcneely/modusgraph/typed" "{{ .SchemaImportPath }}" @@ -10,8 +12,8 @@ import ( {{- $sAlias := .SchemaAlias }} // {{ $E }}Query is the wrapper-side fluent query builder for {{ $E }}. Builder -// methods return *{{ $E }}Query for chaining; terminal methods (Nodes, First) -// execute the query and wrap results. +// methods return *{{ $E }}Query for chaining; terminal methods (Nodes, First, +// IterNodes) execute the query and wrap results. type {{ $E }}Query struct { typed *typed.Query[{{ $sAlias }}.{{ $E }}] } @@ -80,3 +82,19 @@ func (q *{{ $E }}Query) First() (*{{ $E }}, error) { } return Wrap{{ $E }}(s), nil } + +// IterNodes streams the query's results as wrapped {{ $E }} values, paging +// transparently. It is a terminal operation; see typed.Query.IterNodes. +func (q *{{ $E }}Query) IterNodes() iter.Seq2[*{{ $E }}, error] { + return func(yield func(*{{ $E }}, error) bool) { + for s, err := range q.typed.IterNodes() { + if err != nil { + yield(nil, err) + return + } + if !yield(Wrap{{ $E }}(s), nil) { + return + } + } + } +} From b82ce92435d45171a46579d4a4e4b5d491c246ba Mon Sep 17 00:00:00 2001 From: Michael Welles Date: Thu, 21 May 2026 18:18:34 -0400 Subject: [PATCH 5/9] chore(modusgraph-gen): regenerate movies fixture with IterNodes --- .../parser/testdata/movies/actor_query_gen.go | 22 +++++++++++++-- .../movies/content_rating_query_gen.go | 22 +++++++++++++-- .../testdata/movies/country_query_gen.go | 22 +++++++++++++-- .../testdata/movies/director_query_gen.go | 22 +++++++++++++-- .../parser/testdata/movies/film_query_gen.go | 22 +++++++++++++-- .../parser/testdata/movies/genre_query_gen.go | 22 +++++++++++++-- .../testdata/movies/location_query_gen.go | 22 +++++++++++++-- .../testdata/movies/performance_query_gen.go | 22 +++++++++++++-- .../testdata/movies/rating_query_gen.go | 22 +++++++++++++-- .../testdata/movies/studio_query_gen.go | 22 +++++++++++++-- .../testdata/movies/wrapper_query_e2e_test.go | 28 +++++++++++++++++++ 11 files changed, 228 insertions(+), 20 deletions(-) diff --git a/cmd/modusgraph-gen/internal/parser/testdata/movies/actor_query_gen.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/actor_query_gen.go index 7582942..bc622a7 100644 --- a/cmd/modusgraph-gen/internal/parser/testdata/movies/actor_query_gen.go +++ b/cmd/modusgraph-gen/internal/parser/testdata/movies/actor_query_gen.go @@ -3,14 +3,16 @@ package movies import ( + "iter" + "github.com/matthewmcneely/modusgraph/typed" "github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen/internal/parser/testdata/movies/schema" ) // ActorQuery is the wrapper-side fluent query builder for Actor. Builder -// methods return *ActorQuery for chaining; terminal methods (Nodes, First) -// execute the query and wrap results. +// methods return *ActorQuery for chaining; terminal methods (Nodes, First, +// IterNodes) execute the query and wrap results. type ActorQuery struct { typed *typed.Query[schema.Actor] } @@ -79,3 +81,19 @@ func (q *ActorQuery) First() (*Actor, error) { } return WrapActor(s), nil } + +// IterNodes streams the query's results as wrapped Actor values, paging +// transparently. It is a terminal operation; see typed.Query.IterNodes. +func (q *ActorQuery) IterNodes() iter.Seq2[*Actor, error] { + return func(yield func(*Actor, error) bool) { + for s, err := range q.typed.IterNodes() { + if err != nil { + yield(nil, err) + return + } + if !yield(WrapActor(s), nil) { + return + } + } + } +} diff --git a/cmd/modusgraph-gen/internal/parser/testdata/movies/content_rating_query_gen.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/content_rating_query_gen.go index 7eb5ee4..a5ff6ff 100644 --- a/cmd/modusgraph-gen/internal/parser/testdata/movies/content_rating_query_gen.go +++ b/cmd/modusgraph-gen/internal/parser/testdata/movies/content_rating_query_gen.go @@ -3,14 +3,16 @@ package movies import ( + "iter" + "github.com/matthewmcneely/modusgraph/typed" "github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen/internal/parser/testdata/movies/schema" ) // ContentRatingQuery is the wrapper-side fluent query builder for ContentRating. Builder -// methods return *ContentRatingQuery for chaining; terminal methods (Nodes, First) -// execute the query and wrap results. +// methods return *ContentRatingQuery for chaining; terminal methods (Nodes, First, +// IterNodes) execute the query and wrap results. type ContentRatingQuery struct { typed *typed.Query[schema.ContentRating] } @@ -79,3 +81,19 @@ func (q *ContentRatingQuery) First() (*ContentRating, error) { } return WrapContentRating(s), nil } + +// IterNodes streams the query's results as wrapped ContentRating values, paging +// transparently. It is a terminal operation; see typed.Query.IterNodes. +func (q *ContentRatingQuery) IterNodes() iter.Seq2[*ContentRating, error] { + return func(yield func(*ContentRating, error) bool) { + for s, err := range q.typed.IterNodes() { + if err != nil { + yield(nil, err) + return + } + if !yield(WrapContentRating(s), nil) { + return + } + } + } +} diff --git a/cmd/modusgraph-gen/internal/parser/testdata/movies/country_query_gen.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/country_query_gen.go index b7f6c45..c0f0072 100644 --- a/cmd/modusgraph-gen/internal/parser/testdata/movies/country_query_gen.go +++ b/cmd/modusgraph-gen/internal/parser/testdata/movies/country_query_gen.go @@ -3,14 +3,16 @@ package movies import ( + "iter" + "github.com/matthewmcneely/modusgraph/typed" "github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen/internal/parser/testdata/movies/schema" ) // CountryQuery is the wrapper-side fluent query builder for Country. Builder -// methods return *CountryQuery for chaining; terminal methods (Nodes, First) -// execute the query and wrap results. +// methods return *CountryQuery for chaining; terminal methods (Nodes, First, +// IterNodes) execute the query and wrap results. type CountryQuery struct { typed *typed.Query[schema.Country] } @@ -79,3 +81,19 @@ func (q *CountryQuery) First() (*Country, error) { } return WrapCountry(s), nil } + +// IterNodes streams the query's results as wrapped Country values, paging +// transparently. It is a terminal operation; see typed.Query.IterNodes. +func (q *CountryQuery) IterNodes() iter.Seq2[*Country, error] { + return func(yield func(*Country, error) bool) { + for s, err := range q.typed.IterNodes() { + if err != nil { + yield(nil, err) + return + } + if !yield(WrapCountry(s), nil) { + return + } + } + } +} diff --git a/cmd/modusgraph-gen/internal/parser/testdata/movies/director_query_gen.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/director_query_gen.go index 1177d80..ef5af53 100644 --- a/cmd/modusgraph-gen/internal/parser/testdata/movies/director_query_gen.go +++ b/cmd/modusgraph-gen/internal/parser/testdata/movies/director_query_gen.go @@ -3,14 +3,16 @@ package movies import ( + "iter" + "github.com/matthewmcneely/modusgraph/typed" "github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen/internal/parser/testdata/movies/schema" ) // DirectorQuery is the wrapper-side fluent query builder for Director. Builder -// methods return *DirectorQuery for chaining; terminal methods (Nodes, First) -// execute the query and wrap results. +// methods return *DirectorQuery for chaining; terminal methods (Nodes, First, +// IterNodes) execute the query and wrap results. type DirectorQuery struct { typed *typed.Query[schema.Director] } @@ -79,3 +81,19 @@ func (q *DirectorQuery) First() (*Director, error) { } return WrapDirector(s), nil } + +// IterNodes streams the query's results as wrapped Director values, paging +// transparently. It is a terminal operation; see typed.Query.IterNodes. +func (q *DirectorQuery) IterNodes() iter.Seq2[*Director, error] { + return func(yield func(*Director, error) bool) { + for s, err := range q.typed.IterNodes() { + if err != nil { + yield(nil, err) + return + } + if !yield(WrapDirector(s), nil) { + return + } + } + } +} diff --git a/cmd/modusgraph-gen/internal/parser/testdata/movies/film_query_gen.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/film_query_gen.go index 48781b4..c0008a0 100644 --- a/cmd/modusgraph-gen/internal/parser/testdata/movies/film_query_gen.go +++ b/cmd/modusgraph-gen/internal/parser/testdata/movies/film_query_gen.go @@ -3,14 +3,16 @@ package movies import ( + "iter" + "github.com/matthewmcneely/modusgraph/typed" "github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen/internal/parser/testdata/movies/schema" ) // FilmQuery is the wrapper-side fluent query builder for Film. Builder -// methods return *FilmQuery for chaining; terminal methods (Nodes, First) -// execute the query and wrap results. +// methods return *FilmQuery for chaining; terminal methods (Nodes, First, +// IterNodes) execute the query and wrap results. type FilmQuery struct { typed *typed.Query[schema.Film] } @@ -79,3 +81,19 @@ func (q *FilmQuery) First() (*Film, error) { } return WrapFilm(s), nil } + +// IterNodes streams the query's results as wrapped Film values, paging +// transparently. It is a terminal operation; see typed.Query.IterNodes. +func (q *FilmQuery) IterNodes() iter.Seq2[*Film, error] { + return func(yield func(*Film, error) bool) { + for s, err := range q.typed.IterNodes() { + if err != nil { + yield(nil, err) + return + } + if !yield(WrapFilm(s), nil) { + return + } + } + } +} diff --git a/cmd/modusgraph-gen/internal/parser/testdata/movies/genre_query_gen.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/genre_query_gen.go index b44e939..9d3dc49 100644 --- a/cmd/modusgraph-gen/internal/parser/testdata/movies/genre_query_gen.go +++ b/cmd/modusgraph-gen/internal/parser/testdata/movies/genre_query_gen.go @@ -3,14 +3,16 @@ package movies import ( + "iter" + "github.com/matthewmcneely/modusgraph/typed" "github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen/internal/parser/testdata/movies/schema" ) // GenreQuery is the wrapper-side fluent query builder for Genre. Builder -// methods return *GenreQuery for chaining; terminal methods (Nodes, First) -// execute the query and wrap results. +// methods return *GenreQuery for chaining; terminal methods (Nodes, First, +// IterNodes) execute the query and wrap results. type GenreQuery struct { typed *typed.Query[schema.Genre] } @@ -79,3 +81,19 @@ func (q *GenreQuery) First() (*Genre, error) { } return WrapGenre(s), nil } + +// IterNodes streams the query's results as wrapped Genre values, paging +// transparently. It is a terminal operation; see typed.Query.IterNodes. +func (q *GenreQuery) IterNodes() iter.Seq2[*Genre, error] { + return func(yield func(*Genre, error) bool) { + for s, err := range q.typed.IterNodes() { + if err != nil { + yield(nil, err) + return + } + if !yield(WrapGenre(s), nil) { + return + } + } + } +} diff --git a/cmd/modusgraph-gen/internal/parser/testdata/movies/location_query_gen.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/location_query_gen.go index 73d8df4..340cc6d 100644 --- a/cmd/modusgraph-gen/internal/parser/testdata/movies/location_query_gen.go +++ b/cmd/modusgraph-gen/internal/parser/testdata/movies/location_query_gen.go @@ -3,14 +3,16 @@ package movies import ( + "iter" + "github.com/matthewmcneely/modusgraph/typed" "github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen/internal/parser/testdata/movies/schema" ) // LocationQuery is the wrapper-side fluent query builder for Location. Builder -// methods return *LocationQuery for chaining; terminal methods (Nodes, First) -// execute the query and wrap results. +// methods return *LocationQuery for chaining; terminal methods (Nodes, First, +// IterNodes) execute the query and wrap results. type LocationQuery struct { typed *typed.Query[schema.Location] } @@ -79,3 +81,19 @@ func (q *LocationQuery) First() (*Location, error) { } return WrapLocation(s), nil } + +// IterNodes streams the query's results as wrapped Location values, paging +// transparently. It is a terminal operation; see typed.Query.IterNodes. +func (q *LocationQuery) IterNodes() iter.Seq2[*Location, error] { + return func(yield func(*Location, error) bool) { + for s, err := range q.typed.IterNodes() { + if err != nil { + yield(nil, err) + return + } + if !yield(WrapLocation(s), nil) { + return + } + } + } +} diff --git a/cmd/modusgraph-gen/internal/parser/testdata/movies/performance_query_gen.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/performance_query_gen.go index 4d9f5a3..ad83caf 100644 --- a/cmd/modusgraph-gen/internal/parser/testdata/movies/performance_query_gen.go +++ b/cmd/modusgraph-gen/internal/parser/testdata/movies/performance_query_gen.go @@ -3,14 +3,16 @@ package movies import ( + "iter" + "github.com/matthewmcneely/modusgraph/typed" "github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen/internal/parser/testdata/movies/schema" ) // PerformanceQuery is the wrapper-side fluent query builder for Performance. Builder -// methods return *PerformanceQuery for chaining; terminal methods (Nodes, First) -// execute the query and wrap results. +// methods return *PerformanceQuery for chaining; terminal methods (Nodes, First, +// IterNodes) execute the query and wrap results. type PerformanceQuery struct { typed *typed.Query[schema.Performance] } @@ -79,3 +81,19 @@ func (q *PerformanceQuery) First() (*Performance, error) { } return WrapPerformance(s), nil } + +// IterNodes streams the query's results as wrapped Performance values, paging +// transparently. It is a terminal operation; see typed.Query.IterNodes. +func (q *PerformanceQuery) IterNodes() iter.Seq2[*Performance, error] { + return func(yield func(*Performance, error) bool) { + for s, err := range q.typed.IterNodes() { + if err != nil { + yield(nil, err) + return + } + if !yield(WrapPerformance(s), nil) { + return + } + } + } +} diff --git a/cmd/modusgraph-gen/internal/parser/testdata/movies/rating_query_gen.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/rating_query_gen.go index 543067a..8189602 100644 --- a/cmd/modusgraph-gen/internal/parser/testdata/movies/rating_query_gen.go +++ b/cmd/modusgraph-gen/internal/parser/testdata/movies/rating_query_gen.go @@ -3,14 +3,16 @@ package movies import ( + "iter" + "github.com/matthewmcneely/modusgraph/typed" "github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen/internal/parser/testdata/movies/schema" ) // RatingQuery is the wrapper-side fluent query builder for Rating. Builder -// methods return *RatingQuery for chaining; terminal methods (Nodes, First) -// execute the query and wrap results. +// methods return *RatingQuery for chaining; terminal methods (Nodes, First, +// IterNodes) execute the query and wrap results. type RatingQuery struct { typed *typed.Query[schema.Rating] } @@ -79,3 +81,19 @@ func (q *RatingQuery) First() (*Rating, error) { } return WrapRating(s), nil } + +// IterNodes streams the query's results as wrapped Rating values, paging +// transparently. It is a terminal operation; see typed.Query.IterNodes. +func (q *RatingQuery) IterNodes() iter.Seq2[*Rating, error] { + return func(yield func(*Rating, error) bool) { + for s, err := range q.typed.IterNodes() { + if err != nil { + yield(nil, err) + return + } + if !yield(WrapRating(s), nil) { + return + } + } + } +} diff --git a/cmd/modusgraph-gen/internal/parser/testdata/movies/studio_query_gen.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/studio_query_gen.go index 3782c08..23c172c 100644 --- a/cmd/modusgraph-gen/internal/parser/testdata/movies/studio_query_gen.go +++ b/cmd/modusgraph-gen/internal/parser/testdata/movies/studio_query_gen.go @@ -3,14 +3,16 @@ package movies import ( + "iter" + "github.com/matthewmcneely/modusgraph/typed" "github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen/internal/parser/testdata/movies/schema" ) // StudioQuery is the wrapper-side fluent query builder for Studio. Builder -// methods return *StudioQuery for chaining; terminal methods (Nodes, First) -// execute the query and wrap results. +// methods return *StudioQuery for chaining; terminal methods (Nodes, First, +// IterNodes) execute the query and wrap results. type StudioQuery struct { typed *typed.Query[schema.Studio] } @@ -79,3 +81,19 @@ func (q *StudioQuery) First() (*Studio, error) { } return WrapStudio(s), nil } + +// IterNodes streams the query's results as wrapped Studio values, paging +// transparently. It is a terminal operation; see typed.Query.IterNodes. +func (q *StudioQuery) IterNodes() iter.Seq2[*Studio, error] { + return func(yield func(*Studio, error) bool) { + for s, err := range q.typed.IterNodes() { + if err != nil { + yield(nil, err) + return + } + if !yield(WrapStudio(s), nil) { + return + } + } + } +} diff --git a/cmd/modusgraph-gen/internal/parser/testdata/movies/wrapper_query_e2e_test.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/wrapper_query_e2e_test.go index df5ef4e..cd7a15d 100644 --- a/cmd/modusgraph-gen/internal/parser/testdata/movies/wrapper_query_e2e_test.go +++ b/cmd/modusgraph-gen/internal/parser/testdata/movies/wrapper_query_e2e_test.go @@ -262,3 +262,31 @@ func TestWrapperQuery_SingleQuery(t *testing.T) { t.Fatalf("wrapper First executed %d queries, want exactly 1", got) } } + +// TestWrapperQuery_IterNodes inserts more films than the page size and +// verifies FilmQuery.IterNodes streams every one as a non-nil wrapped +// *movies.Film across multiple pages. Distinct release years give the +// initial_release_date order a total order, so paging is stable. +func TestWrapperQuery_IterNodes(t *testing.T) { + ctx := context.Background() + client := movies.NewClient(newConn(t)) + + const n = 125 // > the 50-record page size: forces multiple pages + for i := range n { + addFilm(ctx, t, client, "w", 1900+i) + } + + seen := 0 + for f, err := range client.Film.Query(ctx).OrderAsc("initial_release_date").IterNodes() { + if err != nil { + t.Fatalf("IterNodes yielded error: %v", err) + } + if f == nil { + t.Fatal("IterNodes yielded a nil *movies.Film") + } + seen++ + } + if seen != n { + t.Fatalf("wrapper IterNodes streamed %d films, want %d", seen, n) + } +} From c99f0e05bc32297f659cd3cec724ed8ebc88c3a6 Mon Sep 17 00:00:00 2001 From: Michael Welles Date: Thu, 21 May 2026 18:28:59 -0400 Subject: [PATCH 6/9] docs(typed): note Limit/Offset feed IterNodes paging --- typed/query.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/typed/query.go b/typed/query.go index fa7204d..7b0f2c3 100644 --- a/typed/query.go +++ b/typed/query.go @@ -24,6 +24,9 @@ import ( // Repeated builder calls do not all behave the same way. Filter, Limit, // Offset, After, and Cascade overwrite: the last call wins. OrderAsc and // OrderDesc accumulate: each call adds to the query. +// +// Limit and Offset additionally record the bounds that IterNodes pages +// within — a Limit caps the rows it streams, an Offset is its start. type Query[T any] struct { q *dg.Query limit int // caller-set row cap; 0 = unbounded From 7167059f4af133df28f8bfdcbeee6ad1701ec872 Mon Sep 17 00:00:00 2001 From: Michael Welles Date: Thu, 21 May 2026 18:41:51 -0400 Subject: [PATCH 7/9] docs(specs): add untyped query operations design --- ...6-05-21-query-untyped-operations-design.md | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 docs/specs/2026-05-21-query-untyped-operations-design.md diff --git a/docs/specs/2026-05-21-query-untyped-operations-design.md b/docs/specs/2026-05-21-query-untyped-operations-design.md new file mode 100644 index 0000000..621b0ea --- /dev/null +++ b/docs/specs/2026-05-21-query-untyped-operations-design.md @@ -0,0 +1,269 @@ +--- +date: 2026-05-21 +topic: query-untyped-operations +status: draft +--- + +# Wrapping the Untyped DQL Operations on `typed.Query[T]` + +## Goal + +`typed.Query[T]` (`typed/query.go`) wraps a subset of dgman's `*dg.Query` +builder: `Filter`, `OrderAsc`/`OrderDesc`, `Limit`, `Offset`, `After`, +`Cascade`. Six dgman builder operations are not wrapped — `Var`, `As`, `Name`, +`RootFunc`, `GroupBy`, `Vars` — and `Raw()`'s doc comment names exactly those +six as the gap it exists to bridge. + +Promote those six from the `Raw()` escape-hatch list into first-class methods +on `Query[T]`, so advanced DQL composition — custom root functions, query +variables, parameterized queries, grouped aggregation — is reachable without +dropping to the raw dgman query. + +## Non-Goals + +- The generated per-entity `Query` wrapper + (`cmd/modusgraph-gen/.../wrapper_query.go.tmpl`). This change is scoped to the + handwritten `typed.Query[T]` builder only, matching how the recent + `IterNodes` work was scoped (the template gained no `IterNodes`). +- A typed groupby-aggregation terminal (a `Groups() ([]Group, error)` + decoder). `@groupby` can group by multiple predicates with multiple + aggregations; a general decoder is its own feature. `GroupBy` here yields a + `*RawQuery`, and the caller decodes via `Raw()`. +- Multi-block query composition. `typed.Query[T]` wraps a single `*dg.Query`, + which emits one query block; `As`/`Var`/`Name` produce valid DQL but their + cross-block referencing purpose stays out of reach. +- Removing `Raw()`. It remains the escape hatch for operations still unwrapped + (`UID`, `Query`, `NodesAndCount`, `Model`). + +## Why This Approach + +The six operations split cleanly in two, and dgman's decode behavior — not +taste — decides the split. + +dgman's `nodes()` decoder strips a `{"":` prefix from the JSON +response, where `q.name` defaults to `"data"` (set by `txn.Get`). The block +name drives both query generation and response stripping, so it is used +**symmetrically**. + +**Safe — the query still yields `[]T`.** `RootFunc`, `As`, `Name`, and `Vars` +leave the `{"data":[...]}` response shape intact: + +- `RootFunc` changes only `(func: ...)`; the response key is unchanged. +- `As` prefixes the block with `x as`; the response key is still the block + name. +- `Name` renames the block, and dgman strips `{"":` symmetrically, so the + result still decodes. +- `Vars` adds a `query ` prefix and routes execution through + `tx.QueryWithVars`; the response key is unchanged. + +These four are thin pass-throughs returning `*Query[T]`, byte-for-byte in the +style of the existing `Filter`/`OrderAsc` methods. + +**Shape-changing — the query no longer yields `[]T`.** `Var` and `GroupBy` +change what the query returns: + +- `Var()` makes dgman emit `var` in place of the block name; a `var` block + returns **no data** at all. +- `GroupBy` adds `@groupby(...)`; the result is `{"data":[{"@groupby":[...]}]}` + — aggregation groups, not nodes. + +For both, `Nodes()`/`First()`/`IterNodes()` would decode nonsense. So `Var()` +and `GroupBy()` return a new type, `*RawQuery`, that exposes no typed node +terminal. `qb.Var().Nodes()` becomes a **compile error** rather than a silent +empty result — the central value of this design. + +## Design + +### Safe builders on `Query[T]` + +Four new methods, each a thin pass-through returning `*Query[T]`: + +```go +// RootFunc overrides the query root function. dgman's default is +// type(); RootFunc replaces it with an expression such as +// eq(name, "Alice") or has(email). +func (qb *Query[T]) RootFunc(rootFunc string) *Query[T] { + qb.q.RootFunc(rootFunc) + return qb +} + +// As assigns a dgraph query-variable name to the query block. +func (qb *Query[T]) As(varName string) *Query[T] { + qb.q.As(varName) + return qb +} + +// Name sets the query block name (the result key). It defaults to "data"; +// dgman uses the name symmetrically to generate and decode the query, so a +// renamed block still decodes into []T. +func (qb *Query[T]) Name(queryName string) *Query[T] { + qb.q.Name(queryName) + return qb +} + +// Vars supplies GraphQL variables for a parameterized query: funcDef is the +// query function definition (e.g. "getByName($n: string)") and vars binds +// each variable. The query then executes via dgraph's QueryWithVars path. +func (qb *Query[T]) Vars(funcDef string, vars map[string]string) *Query[T] { + qb.q.Vars(funcDef, vars) + return qb +} +``` + +Method names match dgman exactly; none collide with an existing `Query[T]` +method, so — unlike `First`→`Limit` — no rename is needed. + +### Shape-changing transitions and `RawQuery` + +`Var` and `GroupBy` return `*RawQuery`: + +```go +// Var marks the query block as a dgraph var block. A var block computes query +// variables and returns no data of its own, so Var transitions out of the +// typed query: it returns a *RawQuery, which exposes no node terminal. +func (qb *Query[T]) Var() *RawQuery { + qb.q.Var() + return &RawQuery{q: qb.q} +} + +// GroupBy adds an @groupby(predicate) aggregation. A grouped query returns +// aggregation groups rather than a slice of T, so GroupBy transitions out of +// the typed query: it returns a *RawQuery, which exposes no node terminal. +func (qb *Query[T]) GroupBy(predicate string) *RawQuery { + qb.q.GroupBy(predicate) + return &RawQuery{q: qb.q} +} +``` + +`RawQuery` is a new, non-generic type in package `typed`. Once a query has left +the typed-results world, `T` is meaningless, so carrying it would be an unused +type parameter. + +```go +// RawQuery is a query whose result is not a slice of T — produced by the +// shape-changing builders Query.Var and Query.GroupBy. A RawQuery deliberately +// exposes no typed node terminal: its result must be decoded by the caller +// through the underlying dgman query, obtained via Raw. +type RawQuery struct { + q *dg.Query +} + +// Raw returns the underlying dgman query, for the caller to execute and decode. +func (r *RawQuery) Raw() *dg.Query { return r.q } + +// String returns the generated DQL. +func (r *RawQuery) String() string { return r.q.String() } + +// Var marks the block as a var block. See Query.Var. +func (r *RawQuery) Var() *RawQuery { + r.q.Var() + return r +} + +// GroupBy adds an @groupby(predicate) aggregation. See Query.GroupBy. +func (r *RawQuery) GroupBy(predicate string) *RawQuery { + r.q.GroupBy(predicate) + return r +} +``` + +`RawQuery` re-exposes only `Var` and `GroupBy` — so the canonical +`.GroupBy(...).Var()` combination still chains — plus `Raw` and `String`. It +does not re-expose `Filter`/`Order`/`Limit`/etc.: those are set on `*Query[T]` +before the transition, or applied via `Raw()`. + +The natural call order is: safe builders on `*Query[T]`, then `Var()`/ +`GroupBy()` as the transition into `*RawQuery`. For example: + +```go +raw := client.Query(ctx).Filter(`ge(year, 2000)`).As("genres").GroupBy("genre") +// raw is *RawQuery; decode via raw.Raw() +``` + +### Doc comment updates + +`Raw()`'s comment names the six now-wrapped operations; it is replaced: + +```go +// Raw returns the underlying dgman query for operations Query does not wrap +// (for example UID, Query, NodesAndCount). +func (qb *Query[T]) Raw() *dg.Query { + return qb.q +} +``` + +The `Query[T]` type doc comment changes in two places: + +- The opening line "Builder methods return `*Query[T]` for chaining" gains a + trailing clause: "...except `Var` and `GroupBy`, which transition to + `*RawQuery`." +- The "repeated builder calls" paragraph adds `As`, `Name`, `RootFunc`, and + `Vars` to the overwrite list (last call wins), and a sentence noting that + `Var` and `GroupBy` change the result shape and so return `*RawQuery`. + +## Error handling + +The four safe builders set a single field on the dgman query and cannot fail; +they have no error path, exactly like the existing builder methods. `Vars` +changes the *execution* path (dgman uses `QueryWithVars` when variables are +set) — any resulting error surfaces at the terminal (`Nodes`/`First`/ +`IterNodes`), unchanged from how query-execution errors already surface. + +`Var()` and `GroupBy()` cannot fail and have no error path. A `*RawQuery` has +no terminal, so it produces no error itself; execution and error handling +belong to whoever runs `RawQuery.Raw()`. + +## Testing + +New tests in `typed/query_test.go`, following the file's conventions — +behavioral tests against `newConn(t)`, string assertions via +`.Raw().String()`. + +**Behavioral tests** (operation is safe to execute and decode): + +- `RootFunc` — a query with `RootFunc` set to an `eq(name, ...)` expression, + run through `Nodes()`, returns exactly the matching widget. +- `Name` — a query with `Name("widgets")` set, run through `Nodes()`, still + returns all records. This is the executable proof of the decode-symmetry + argument: a renamed block round-trips through dgman's prefix stripping. +- `Vars` — a parameterized query (`Vars("getByName($n: string)", {"$n": "b"})` + with `RootFunc("eq(name, $n)")`) executed via `Nodes()` returns the `b` + widget, exercising dgman's `QueryWithVars` path. Implementation-time check: if + the embedded file engine rejects `QueryWithVars`, this test falls back to a + `String()` assertion. + +**String-assertion tests** (`.Raw().String()` / `RawQuery.String()`): + +- `As` — output contains `x as data(`; plus an overwrite test (second `As` + wins). +- `Name` — output contains `widgets(func:`; plus an overwrite test. +- `RootFunc` — an overwrite test (second `RootFunc` wins). +- `Var` — `RawQuery.String()` contains `var(func:`. +- `GroupBy` — `RawQuery.String()` contains `@groupby(name)`. + +**`RawQuery` structural tests:** + +- `Var()` and `GroupBy()` return a non-nil `*RawQuery`; `Raw()` returns the + underlying `*dg.Query`; `String()` equals `Raw().String()`. +- The `.GroupBy("name").Var()` combination chains and emits both `@groupby` and + `var`. +- That `*RawQuery` exposes no `Nodes`/`First`/`IterNodes` is a compile-time + guarantee of the type, noted here rather than asserted at runtime. + +## Migration / blast radius + +- **Modified:** `typed/query.go` — four safe builder methods (`RootFunc`, `As`, + `Name`, `Vars`), two transition methods (`Var`, `GroupBy`), the new + `RawQuery` type, the `Raw()` doc comment, and the `Query[T]` type doc + comment. +- **New tests** in `typed/query_test.go`. +- No change to `Nodes()`, `First()`, `IterNodes()`, `Limit`/`Offset`, CRUD, the + generated `Query` wrapper, or any generated artifact. `Raw()`'s signature + and behavior are unchanged; only its doc comment changes. + +## Open decisions + +None. The layer scope (`typed.Query[T]` only), the safe/shape-changing split +(decided by dgman's decode behavior), the `RawQuery` transition type +(non-generic, `Var`/`GroupBy`/`Raw`/`String` only), and the decision not to +build a groupby decoder were all settled during brainstorming. From 0acc22b1d2f1254b3f85f509540b17b0e25cccbb Mon Sep 17 00:00:00 2001 From: Michael Welles Date: Thu, 21 May 2026 19:15:37 -0400 Subject: [PATCH 8/9] docs(specs): add edge-predicate query filter design --- .../2026-05-21-query-edge-filter-design.md | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 docs/specs/2026-05-21-query-edge-filter-design.md diff --git a/docs/specs/2026-05-21-query-edge-filter-design.md b/docs/specs/2026-05-21-query-edge-filter-design.md new file mode 100644 index 0000000..84d09f1 --- /dev/null +++ b/docs/specs/2026-05-21-query-edge-filter-design.md @@ -0,0 +1,174 @@ +--- +date: 2026-05-21 +topic: query-edge-filter +status: draft +--- + +# Edge-Predicate Filtering for Generated Query Builders + +## Goal + +Let a generated `Query` filter root records by a scalar predicate of a +*neighbouring* node reached over an edge — "people who have a dog named Fido" — +as a first-class, generated method: + +```go +client.Person.Query(ctx).WhereDogs(`eq(name, "Fido")`).Nodes() +``` + +Today `Query.Filter` (and the `typed.Query[T].Filter` it delegates to) +only constrains the root node's *own* predicates: the filter string lands in +dgraph's root `@filter`, which has no syntax for an edge target's scalar value. +There is no way, short of hand-written DQL through `Client.QueryRaw`, to express +"root has an edge whose target matches X." + +## Non-Goals + +- **A typed predicate DSL.** `WhereDogs(filter string, params ...any)` takes a + dgraph `@filter` string, exactly like the existing `Filter`. A type-safe + `WhereDogs(func(c *DogCriteria){ c.NameEq("Fido") })` face is future work; it + would layer over the same `WhereEdge` substrate this spec introduces. +- **Multi-hop filters** (root → edge → edge). The filter string constrains the + *immediate* edge target's own predicates. +- **Changing `Filter`, `Nodes`, `First`, `IterNodes`, or CRUD.** + +## Why This Approach + +**dgman emits one query block.** A `typed.Query[T]` wraps a single +`*dg.Query`, which dgman renders as one root `@filter` over an `expand(_all_)` +body (`query.go:generateQuery`). dgman exposes no way to attach a `@filter` to +an edge sub-block. So edge filtering cannot be a new dgman builder call — it +needs a genuinely separate execution path. + +**Two-step semi-join, executed by the substrate.** A query carrying edge +constraints runs as: + +1. **Pre-pass** — an `@cascade` query over `type(T)` whose body is `uid` plus + one filtered block per edge constraint. `@cascade` drops any node with an + empty block, so a survivor satisfies every constraint. The pre-pass returns + the surviving UIDs. +2. **Main query** — the existing `*dg.Query`, with its root function rewritten + to `uid()`. + +The alternative — a single hand-written two-block DQL query via `QueryRaw` — +was rejected: it would force re-implementing the result projection, and +`expand(_all_)` drops managed reverse edges (`reverse_test.go`), so edge-filtered +results would silently differ from normal ones. The two-step keeps step 2 on +the dgman path, so ordering, `Limit`/`Offset`/`After`, `IterNodes` paging, +`NodesAndCount`, and reverse-edge-aware projection all keep working untouched. + +**The cost** is a second read: the pre-pass and the main query run in separate +read-only transactions, so a writer committing between them is observable. This +is the same consistency class the package already tolerates, and is negligible +on the embedded file engine. It is documented, not eliminated. + +## Design + +### `typed.Query[T]` — the `WhereEdge` substrate + +`Query[T]` gains `conn`/`ctx` (to run the pre-pass) and an `edges` slice: + +```go +type Query[T any] struct { + q *dg.Query + conn modusgraph.Client + ctx context.Context + limit int + offset int + edges []edgeFilter +} + +type edgeFilter struct { + predicate string + filter string + params []any +} +``` + +New builder, accumulating (each call ANDs another constraint): + +```go +func (qb *Query[T]) WhereEdge(predicate, filter string, params ...any) *Query[T] +``` + +The three terminals (`Nodes`, `First`, `IterNodes`) call `resolveRoots()` +first. With no edge constraints it is a no-op. Otherwise it runs the pre-pass; +if zero roots match it reports so and the terminal returns an empty result +without running the main query; otherwise it rewrites the main query's root +function to `uid()`. + +### Pre-pass DQL + +For `WhereEdge("pets", `eq(name, $1)`, "Fido")` over `Owner`: + +```dql +{ + data(func: type(Owner)) @filter(has(dgraph.type)) @cascade { + uid + mg_e0 : pets @filter(eq(name, "Fido")) { uid } + } +} +``` + +Built by reconfiguring a fresh `conn.Query(ctx, &T{})` with `Cascade()` (bare +`@cascade`) and `Query(body, params...)` (dgman substitutes `$N`). Every block +is aliased `mg_e0`, `mg_e1`, … so two constraints on the same predicate do not +collide as duplicate fields. Each edge filter is written numbering its params +from `$1`; `shiftPlaceholders` renumbers them against the concatenated params +slice before they are joined into one body. + +### Generated face — `Query.Where` + +`wrapper_query.go.tmpl` emits one thin method per edge field, delegating to the +substrate — the same pattern `Filter`/`Cascade` already use: + +```go +func (q *OwnerQuery) WherePets(filter string, params ...any) *OwnerQuery { + q.typed.WhereEdge("pets", filter, params...) + return q +} +``` + +The method name is `Where` + the field's accessor name; the predicate string is +the field's resolved dgraph predicate. Generated for every edge field (multi, +singular, and reverse). No parser changes — `model.Field` already carries +`IsEdge`/`Predicate`. + +## Error handling + +`WhereEdge` never executes — it only appends. The pre-pass error (malformed +filter, transport failure) surfaces from the terminal: `Nodes`/`First` return +it; `IterNodes` yields one `(nil, err)` and stops. A pre-pass matching zero +roots is not an error — the terminal returns an empty result. + +## Testing + +- **`typed/query_test.go`** — new `owner`/`pet` test types (an edge pair). + Behavioral tests against the file engine: `WhereEdge` filters by edge target; + no match yields empty; `$N` params bind; `WhereEdge` composes with a root + `Filter`; two `WhereEdge` calls AND; `First` and `IterNodes` honor edge + constraints. +- **`generator_test.go`** — a two-type edge schema asserts `Where` is + generated and delegates to `typed.WhereEdge`, and that an edgeless type gets + no `Where*` method. +- **`wrapper_query_e2e_test.go`** — `client.Director.Query(ctx).WhereFilms(...)` + end-to-end against the file-backed client. + +## Migration / blast radius + +- **Modified:** `typed/query.go` (3 struct fields, `WhereEdge`, the + `resolveRoots`/`matchedUIDs`/`edgeMatchBody`/`shiftPlaceholders` helpers, + edge-aware terminals, doc comments); `typed/client.go` (`Query` passes + `conn`/`ctx`); `wrapper_query.go.tmpl` (generated `Where`). +- **Regenerated:** the `movies` fixture — every `*_query_gen.go` for an entity + with edges gains `Where` methods. +- **New tests** in `typed/query_test.go`, `generator_test.go`, + `wrapper_query_e2e_test.go`. +- No change to `Filter`, `Nodes`, `First`, `IterNodes`, CRUD, or any other + generated artifact. The pre-pass is inert unless `WhereEdge` is called. + +## Open decisions + +None. The string-filter API (over a typed DSL), the two-step semi-join (over a +two-block `QueryRaw`), and one-hop depth were settled before implementation. +The typed predicate DSL is recorded above as future work. From ea863b521ce52042464a0a47ce82273951f19d23 Mon Sep 17 00:00:00 2001 From: Michael Welles Date: Thu, 21 May 2026 19:15:37 -0400 Subject: [PATCH 9/9] feat(typed,modusgraph-gen): add WhereEdge edge-predicate query filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds typed.Query[T].WhereEdge — filter root records by a scalar predicate of a node reached over an outbound edge ("owners who have a pet named Fido"). dgman renders one query block with no per-edge @filter, so a query carrying edge constraints runs as a two-step semi-join: an @cascade pre-pass resolves the matching root UIDs, then the main query runs against uid(...), keeping ordering, pagination, IterNodes paging, and result projection on the normal dgman path. The generator emits one thin Query.Where method per edge field, delegating to the WhereEdge substrate. Regenerates the movies fixture; adds tests at the typed, generator, and wrapper-e2e layers. See docs/specs/2026-05-21-query-edge-filter-design.md. --- .../internal/generator/generator_test.go | 67 +++++++ .../generator/templates/wrapper_query.go.tmpl | 10 + .../parser/testdata/movies/actor_query_gen.go | 8 + .../movies/content_rating_query_gen.go | 8 + .../testdata/movies/country_query_gen.go | 8 + .../testdata/movies/director_query_gen.go | 8 + .../parser/testdata/movies/film_query_gen.go | 48 +++++ .../parser/testdata/movies/genre_query_gen.go | 8 + .../testdata/movies/rating_query_gen.go | 8 + .../testdata/movies/studio_query_gen.go | 64 +++++++ .../testdata/movies/wrapper_query_e2e_test.go | 59 ++++++ typed/client.go | 5 +- typed/client_test.go | 17 ++ typed/query.go | 169 ++++++++++++++++- typed/query_test.go | 173 ++++++++++++++++++ 15 files changed, 652 insertions(+), 8 deletions(-) diff --git a/cmd/modusgraph-gen/internal/generator/generator_test.go b/cmd/modusgraph-gen/internal/generator/generator_test.go index 67bc9c0..d4893b8 100644 --- a/cmd/modusgraph-gen/internal/generator/generator_test.go +++ b/cmd/modusgraph-gen/internal/generator/generator_test.go @@ -1068,6 +1068,73 @@ func TestGenerate_WrapperQuery(t *testing.T) { } } +// TestGenerate_WrapperQueryEdgeFilter checks that Query gains a +// Where method for each edge field — delegating to typed.WhereEdge — and +// that an entity with no edges gains none. +func TestGenerate_WrapperQueryEdgeFilter(t *testing.T) { + srcDir := t.TempDir() + if err := os.WriteFile(filepath.Join(srcDir, "go.mod"), + []byte("module example.com/test\n\ngo 1.25\n"), 0o644); err != nil { + t.Fatalf("writing go.mod: %v", err) + } + src := "package schema\n\n" + + "type Owner struct {\n" + + "\tUID string `json:\"uid,omitempty\"`\n" + + "\tDType []string `json:\"dgraph.type,omitempty\"`\n" + + "\tName string `json:\"name\"`\n" + + "\tPets []*Pet `json:\"pets,omitempty\"`\n" + + "}\n\n" + + "type Pet struct {\n" + + "\tUID string `json:\"uid,omitempty\"`\n" + + "\tDType []string `json:\"dgraph.type,omitempty\"`\n" + + "\tName string `json:\"name\"`\n" + + "}\n" + if err := os.WriteFile(filepath.Join(srcDir, "schema.go"), []byte(src), 0o644); err != nil { + t.Fatalf("writing schema.go: %v", err) + } + pkg, err := parser.Parse(srcDir) + if err != nil { + t.Fatalf("parse: %v", err) + } + entityDir := filepath.Join(t.TempDir(), "entity") + if err := os.MkdirAll(entityDir, 0o755); err != nil { + t.Fatalf("mkdir entityDir: %v", err) + } + cfg := Config{ + SchemaDir: srcDir, + SchemaClientDir: srcDir, + EntityDir: entityDir, + EntityClientDir: entityDir, + EntityPackageName: "entity", + EntityClientPackageName: "entity", + SchemaClientPackageName: "schema", + SchemaAlias: "schema", + SchemaImportPath: "example.com/test", + CLIName: "test", + } + if err := Generate(pkg, cfg); err != nil { + t.Fatalf("generate: %v", err) + } + + // Owner has a Pets edge → OwnerQuery gains WherePets, delegating to the + // typed substrate with the resolved predicate. + ownerQuery := mustReadGen(t, entityDir, "owner_query_gen.go") + for _, want := range []string{ + `func (q *OwnerQuery) WherePets(filter string, params ...any) *OwnerQuery`, + `q.typed.WhereEdge("pets", filter, params...)`, + } { + if !strings.Contains(ownerQuery, want) { + t.Errorf("owner_query_gen.go missing %q; got:\n%s", want, ownerQuery) + } + } + + // Pet has no edges → PetQuery must carry no Where* method. + petQuery := mustReadGen(t, entityDir, "pet_query_gen.go") + if strings.Contains(petQuery, "func (q *PetQuery) Where") { + t.Errorf("pet_query_gen.go has a Where* method but Pet has no edges:\n%s", petQuery) + } +} + func TestGenerate_NoIterFileEmitted(t *testing.T) { _, _, entityDir := generateFromMinimalSchema(t) if _, err := os.Stat(filepath.Join(entityDir, "iter_gen.go")); !os.IsNotExist(err) { diff --git a/cmd/modusgraph-gen/internal/generator/templates/wrapper_query.go.tmpl b/cmd/modusgraph-gen/internal/generator/templates/wrapper_query.go.tmpl index 11bbb1b..666565a 100644 --- a/cmd/modusgraph-gen/internal/generator/templates/wrapper_query.go.tmpl +++ b/cmd/modusgraph-gen/internal/generator/templates/wrapper_query.go.tmpl @@ -59,6 +59,16 @@ func (q *{{ $E }}Query) Cascade(predicates ...string) *{{ $E }}Query { q.typed.Cascade(predicates...) return q } +{{- range edgeFields .Entity.Fields }} + +// Where{{ accessorName . }} keeps only {{ $E }} records that have a {{ .Predicate }} +// edge whose target node matches the dgraph @filter expression. params bind to +// $N placeholders. Multiple Where* calls are combined with AND. +func (q *{{ $E }}Query) Where{{ accessorName . }}(filter string, params ...any) *{{ $E }}Query { + q.typed.WhereEdge("{{ .Predicate }}", filter, params...) + return q +} +{{- end }} // Nodes executes the query and returns wrapped {{ $E }} results. func (q *{{ $E }}Query) Nodes() ([]*{{ $E }}, error) { diff --git a/cmd/modusgraph-gen/internal/parser/testdata/movies/actor_query_gen.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/actor_query_gen.go index bc622a7..244ab2d 100644 --- a/cmd/modusgraph-gen/internal/parser/testdata/movies/actor_query_gen.go +++ b/cmd/modusgraph-gen/internal/parser/testdata/movies/actor_query_gen.go @@ -59,6 +59,14 @@ func (q *ActorQuery) Cascade(predicates ...string) *ActorQuery { return q } +// WhereFilms keeps only Actor records that have a actor.film +// edge whose target node matches the dgraph @filter expression. params bind to +// $N placeholders. Multiple Where* calls are combined with AND. +func (q *ActorQuery) WhereFilms(filter string, params ...any) *ActorQuery { + q.typed.WhereEdge("actor.film", filter, params...) + return q +} + // Nodes executes the query and returns wrapped Actor results. func (q *ActorQuery) Nodes() ([]*Actor, error) { recs, err := q.typed.Nodes() diff --git a/cmd/modusgraph-gen/internal/parser/testdata/movies/content_rating_query_gen.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/content_rating_query_gen.go index a5ff6ff..4ee6a36 100644 --- a/cmd/modusgraph-gen/internal/parser/testdata/movies/content_rating_query_gen.go +++ b/cmd/modusgraph-gen/internal/parser/testdata/movies/content_rating_query_gen.go @@ -59,6 +59,14 @@ func (q *ContentRatingQuery) Cascade(predicates ...string) *ContentRatingQuery { return q } +// WhereFilms keeps only ContentRating records that have a ~rated +// edge whose target node matches the dgraph @filter expression. params bind to +// $N placeholders. Multiple Where* calls are combined with AND. +func (q *ContentRatingQuery) WhereFilms(filter string, params ...any) *ContentRatingQuery { + q.typed.WhereEdge("~rated", filter, params...) + return q +} + // Nodes executes the query and returns wrapped ContentRating results. func (q *ContentRatingQuery) Nodes() ([]*ContentRating, error) { recs, err := q.typed.Nodes() diff --git a/cmd/modusgraph-gen/internal/parser/testdata/movies/country_query_gen.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/country_query_gen.go index c0f0072..d20b9f7 100644 --- a/cmd/modusgraph-gen/internal/parser/testdata/movies/country_query_gen.go +++ b/cmd/modusgraph-gen/internal/parser/testdata/movies/country_query_gen.go @@ -59,6 +59,14 @@ func (q *CountryQuery) Cascade(predicates ...string) *CountryQuery { return q } +// WhereFilms keeps only Country records that have a ~country +// edge whose target node matches the dgraph @filter expression. params bind to +// $N placeholders. Multiple Where* calls are combined with AND. +func (q *CountryQuery) WhereFilms(filter string, params ...any) *CountryQuery { + q.typed.WhereEdge("~country", filter, params...) + return q +} + // Nodes executes the query and returns wrapped Country results. func (q *CountryQuery) Nodes() ([]*Country, error) { recs, err := q.typed.Nodes() diff --git a/cmd/modusgraph-gen/internal/parser/testdata/movies/director_query_gen.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/director_query_gen.go index ef5af53..6b1ec71 100644 --- a/cmd/modusgraph-gen/internal/parser/testdata/movies/director_query_gen.go +++ b/cmd/modusgraph-gen/internal/parser/testdata/movies/director_query_gen.go @@ -59,6 +59,14 @@ func (q *DirectorQuery) Cascade(predicates ...string) *DirectorQuery { return q } +// WhereFilms keeps only Director records that have a director.film +// edge whose target node matches the dgraph @filter expression. params bind to +// $N placeholders. Multiple Where* calls are combined with AND. +func (q *DirectorQuery) WhereFilms(filter string, params ...any) *DirectorQuery { + q.typed.WhereEdge("director.film", filter, params...) + return q +} + // Nodes executes the query and returns wrapped Director results. func (q *DirectorQuery) Nodes() ([]*Director, error) { recs, err := q.typed.Nodes() diff --git a/cmd/modusgraph-gen/internal/parser/testdata/movies/film_query_gen.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/film_query_gen.go index c0008a0..73b3034 100644 --- a/cmd/modusgraph-gen/internal/parser/testdata/movies/film_query_gen.go +++ b/cmd/modusgraph-gen/internal/parser/testdata/movies/film_query_gen.go @@ -59,6 +59,54 @@ func (q *FilmQuery) Cascade(predicates ...string) *FilmQuery { return q } +// WhereGenres keeps only Film records that have a genre +// edge whose target node matches the dgraph @filter expression. params bind to +// $N placeholders. Multiple Where* calls are combined with AND. +func (q *FilmQuery) WhereGenres(filter string, params ...any) *FilmQuery { + q.typed.WhereEdge("genre", filter, params...) + return q +} + +// WhereCountries keeps only Film records that have a country +// edge whose target node matches the dgraph @filter expression. params bind to +// $N placeholders. Multiple Where* calls are combined with AND. +func (q *FilmQuery) WhereCountries(filter string, params ...any) *FilmQuery { + q.typed.WhereEdge("country", filter, params...) + return q +} + +// WhereRatings keeps only Film records that have a rating +// edge whose target node matches the dgraph @filter expression. params bind to +// $N placeholders. Multiple Where* calls are combined with AND. +func (q *FilmQuery) WhereRatings(filter string, params ...any) *FilmQuery { + q.typed.WhereEdge("rating", filter, params...) + return q +} + +// WhereContentRatings keeps only Film records that have a rated +// edge whose target node matches the dgraph @filter expression. params bind to +// $N placeholders. Multiple Where* calls are combined with AND. +func (q *FilmQuery) WhereContentRatings(filter string, params ...any) *FilmQuery { + q.typed.WhereEdge("rated", filter, params...) + return q +} + +// WhereStarring keeps only Film records that have a starring +// edge whose target node matches the dgraph @filter expression. params bind to +// $N placeholders. Multiple Where* calls are combined with AND. +func (q *FilmQuery) WhereStarring(filter string, params ...any) *FilmQuery { + q.typed.WhereEdge("starring", filter, params...) + return q +} + +// WhereDirectors keeps only Film records that have a directors +// edge whose target node matches the dgraph @filter expression. params bind to +// $N placeholders. Multiple Where* calls are combined with AND. +func (q *FilmQuery) WhereDirectors(filter string, params ...any) *FilmQuery { + q.typed.WhereEdge("directors", filter, params...) + return q +} + // Nodes executes the query and returns wrapped Film results. func (q *FilmQuery) Nodes() ([]*Film, error) { recs, err := q.typed.Nodes() diff --git a/cmd/modusgraph-gen/internal/parser/testdata/movies/genre_query_gen.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/genre_query_gen.go index 9d3dc49..0bc79e7 100644 --- a/cmd/modusgraph-gen/internal/parser/testdata/movies/genre_query_gen.go +++ b/cmd/modusgraph-gen/internal/parser/testdata/movies/genre_query_gen.go @@ -59,6 +59,14 @@ func (q *GenreQuery) Cascade(predicates ...string) *GenreQuery { return q } +// WhereFilms keeps only Genre records that have a ~genre +// edge whose target node matches the dgraph @filter expression. params bind to +// $N placeholders. Multiple Where* calls are combined with AND. +func (q *GenreQuery) WhereFilms(filter string, params ...any) *GenreQuery { + q.typed.WhereEdge("~genre", filter, params...) + return q +} + // Nodes executes the query and returns wrapped Genre results. func (q *GenreQuery) Nodes() ([]*Genre, error) { recs, err := q.typed.Nodes() diff --git a/cmd/modusgraph-gen/internal/parser/testdata/movies/rating_query_gen.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/rating_query_gen.go index 8189602..b76679d 100644 --- a/cmd/modusgraph-gen/internal/parser/testdata/movies/rating_query_gen.go +++ b/cmd/modusgraph-gen/internal/parser/testdata/movies/rating_query_gen.go @@ -59,6 +59,14 @@ func (q *RatingQuery) Cascade(predicates ...string) *RatingQuery { return q } +// WhereFilms keeps only Rating records that have a ~rating +// edge whose target node matches the dgraph @filter expression. params bind to +// $N placeholders. Multiple Where* calls are combined with AND. +func (q *RatingQuery) WhereFilms(filter string, params ...any) *RatingQuery { + q.typed.WhereEdge("~rating", filter, params...) + return q +} + // Nodes executes the query and returns wrapped Rating results. func (q *RatingQuery) Nodes() ([]*Rating, error) { recs, err := q.typed.Nodes() diff --git a/cmd/modusgraph-gen/internal/parser/testdata/movies/studio_query_gen.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/studio_query_gen.go index 23c172c..9485f70 100644 --- a/cmd/modusgraph-gen/internal/parser/testdata/movies/studio_query_gen.go +++ b/cmd/modusgraph-gen/internal/parser/testdata/movies/studio_query_gen.go @@ -59,6 +59,70 @@ func (q *StudioQuery) Cascade(predicates ...string) *StudioQuery { return q } +// WhereFounder keeps only Studio records that have a founder +// edge whose target node matches the dgraph @filter expression. params bind to +// $N placeholders. Multiple Where* calls are combined with AND. +func (q *StudioQuery) WhereFounder(filter string, params ...any) *StudioQuery { + q.typed.WhereEdge("founder", filter, params...) + return q +} + +// WhereHeadquarters keeps only Studio records that have a headquarters +// edge whose target node matches the dgraph @filter expression. params bind to +// $N placeholders. Multiple Where* calls are combined with AND. +func (q *StudioQuery) WhereHeadquarters(filter string, params ...any) *StudioQuery { + q.typed.WhereEdge("headquarters", filter, params...) + return q +} + +// WhereCurrentHead keeps only Studio records that have a currentHead +// edge whose target node matches the dgraph @filter expression. params bind to +// $N placeholders. Multiple Where* calls are combined with AND. +func (q *StudioQuery) WhereCurrentHead(filter string, params ...any) *StudioQuery { + q.typed.WhereEdge("currentHead", filter, params...) + return q +} + +// WhereCeo keeps only Studio records that have a ceo +// edge whose target node matches the dgraph @filter expression. params bind to +// $N placeholders. Multiple Where* calls are combined with AND. +func (q *StudioQuery) WhereCeo(filter string, params ...any) *StudioQuery { + q.typed.WhereEdge("ceo", filter, params...) + return q +} + +// WhereHomeBase keeps only Studio records that have a homeBase +// edge whose target node matches the dgraph @filter expression. params bind to +// $N placeholders. Multiple Where* calls are combined with AND. +func (q *StudioQuery) WhereHomeBase(filter string, params ...any) *StudioQuery { + q.typed.WhereEdge("homeBase", filter, params...) + return q +} + +// WhereParentCompany keeps only Studio records that have a parentCompany +// edge whose target node matches the dgraph @filter expression. params bind to +// $N placeholders. Multiple Where* calls are combined with AND. +func (q *StudioQuery) WhereParentCompany(filter string, params ...any) *StudioQuery { + q.typed.WhereEdge("parentCompany", filter, params...) + return q +} + +// WhereFilms keeps only Studio records that have a films +// edge whose target node matches the dgraph @filter expression. params bind to +// $N placeholders. Multiple Where* calls are combined with AND. +func (q *StudioQuery) WhereFilms(filter string, params ...any) *StudioQuery { + q.typed.WhereEdge("films", filter, params...) + return q +} + +// WhereAdvisors keeps only Studio records that have a advisors +// edge whose target node matches the dgraph @filter expression. params bind to +// $N placeholders. Multiple Where* calls are combined with AND. +func (q *StudioQuery) WhereAdvisors(filter string, params ...any) *StudioQuery { + q.typed.WhereEdge("advisors", filter, params...) + return q +} + // Nodes executes the query and returns wrapped Studio results. func (q *StudioQuery) Nodes() ([]*Studio, error) { recs, err := q.typed.Nodes() diff --git a/cmd/modusgraph-gen/internal/parser/testdata/movies/wrapper_query_e2e_test.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/wrapper_query_e2e_test.go index cd7a15d..da928f2 100644 --- a/cmd/modusgraph-gen/internal/parser/testdata/movies/wrapper_query_e2e_test.go +++ b/cmd/modusgraph-gen/internal/parser/testdata/movies/wrapper_query_e2e_test.go @@ -290,3 +290,62 @@ func TestWrapperQuery_IterNodes(t *testing.T) { t.Fatalf("wrapper IterNodes streamed %d films, want %d", seen, n) } } + +// TestWrapperQuery_WhereEdgeFiltersByEdgeTarget inserts two directors linked to +// disjoint film sets, then verifies the generated DirectorQuery.WhereFilms +// filters directors by a scalar of the Film reached over the director.film +// edge — a constraint the root-only Filter cannot express. This proves the +// generated Where method routes through typed.Query.WhereEdge end to end. +func TestWrapperQuery_WhereEdgeFiltersByEdgeTarget(t *testing.T) { + ctx := context.Background() + client := movies.NewClient(newConn(t)) + + // Insert four films first so the director edges link persisted nodes. + films := map[string]*moviesSchema.Film{ + "Inception": {Name: "Inception"}, + "Dunkirk": {Name: "Dunkirk"}, + "Jaws": {Name: "Jaws"}, + "E.T.": {Name: "E.T."}, + } + for name, f := range films { + if err := client.Film.Add(ctx, movies.WrapFilm(f)); err != nil { + t.Fatalf("Film.Add(%q): %v", name, err) + } + } + directors := []*moviesSchema.Director{ + {Name: "Christopher Nolan", Films: []*moviesSchema.Film{films["Inception"], films["Dunkirk"]}}, + {Name: "Steven Spielberg", Films: []*moviesSchema.Film{films["Jaws"], films["E.T."]}}, + } + for _, d := range directors { + if err := client.Director.Add(ctx, movies.WrapDirector(d)); err != nil { + t.Fatalf("Director.Add(%q): %v", d.Name, err) + } + } + + // Inception was directed only by Nolan. + got, err := client.Director.Query(ctx).WhereFilms(`eq(name, "Inception")`).Nodes() + if err != nil { + t.Fatalf("WhereFilms Nodes: %v", err) + } + if len(got) != 1 || got[0].Name() != "Christopher Nolan" { + t.Fatalf("WhereFilms(name=Inception) returned %d directors, want exactly [Christopher Nolan]", len(got)) + } + + // Jaws was directed only by Spielberg. + got, err = client.Director.Query(ctx).WhereFilms(`eq(name, "Jaws")`).Nodes() + if err != nil { + t.Fatalf("WhereFilms Nodes: %v", err) + } + if len(got) != 1 || got[0].Name() != "Steven Spielberg" { + t.Fatalf("WhereFilms(name=Jaws) returned %d directors, want exactly [Steven Spielberg]", len(got)) + } + + // No film is named Solaris → no director matches. + got, err = client.Director.Query(ctx).WhereFilms(`eq(name, "Solaris")`).Nodes() + if err != nil { + t.Fatalf("WhereFilms Nodes: %v", err) + } + if len(got) != 0 { + t.Fatalf("WhereFilms(name=Solaris) returned %d directors, want none", len(got)) + } +} diff --git a/typed/client.go b/typed/client.go index 1a22055..ffe80a2 100644 --- a/typed/client.go +++ b/typed/client.go @@ -58,10 +58,11 @@ func (c *Client[T]) Delete(ctx context.Context, uid string) error { return c.conn.Delete(ctx, []string{uid}) } -// Query returns a typed query builder for T. +// Query returns a typed query builder for T. conn and ctx are carried so the +// builder can run a WhereEdge pre-pass (see Query.WhereEdge) if one is needed. func (c *Client[T]) Query(ctx context.Context) *Query[T] { var z T - return &Query[T]{q: c.conn.Query(ctx, &z)} + return &Query[T]{q: c.conn.Query(ctx, &z), conn: c.conn, ctx: ctx} } // defaultPageSize is the page size IterNodes uses to page through results. diff --git a/typed/client_test.go b/typed/client_test.go index 3f187a2..6fa2b1d 100644 --- a/typed/client_test.go +++ b/typed/client_test.go @@ -21,6 +21,23 @@ type widget struct { Qty int `json:"qty,omitempty" dgraph:"index=int"` } +// owner and pet exercise Query.WhereEdge: owner has an outbound "pets" edge to +// pet, and pet's Name carries an index so eq(name, ...) resolves inside an edge +// filter. The pair is the typed-package analogue of the Person/Dog example in +// docs/specs/2026-05-21-query-edge-filter-design.md. +type owner struct { + UID string `json:"uid,omitempty"` + DType []string `json:"dgraph.type,omitempty"` + Name string `json:"name,omitempty" dgraph:"index=exact"` + Pets []*pet `json:"pets,omitempty"` +} + +type pet struct { + UID string `json:"uid,omitempty"` + DType []string `json:"dgraph.type,omitempty"` + Name string `json:"name,omitempty" dgraph:"index=exact"` +} + // newConn builds a local file-backed modusgraph client for a test. func newConn(t *testing.T) modusgraph.Client { t.Helper() diff --git a/typed/query.go b/typed/query.go index 7b0f2c3..dcea794 100644 --- a/typed/query.go +++ b/typed/query.go @@ -6,9 +6,13 @@ package typed import ( + "context" "iter" + "strconv" + "strings" dg "github.com/dolan-in/dgman/v2" + "github.com/matthewmcneely/modusgraph" ) // Query is a fluent, type-safe query builder over records of type T. Builder @@ -22,15 +26,26 @@ import ( // keeps mutating — the same underlying query. // // Repeated builder calls do not all behave the same way. Filter, Limit, -// Offset, After, and Cascade overwrite: the last call wins. OrderAsc and -// OrderDesc accumulate: each call adds to the query. +// Offset, After, and Cascade overwrite: the last call wins. OrderAsc, +// OrderDesc, and WhereEdge accumulate: each call adds to the query. // // Limit and Offset additionally record the bounds that IterNodes pages // within — a Limit caps the rows it streams, an Offset is its start. type Query[T any] struct { q *dg.Query - limit int // caller-set row cap; 0 = unbounded - offset int // caller-set starting offset; 0 = none + conn modusgraph.Client // runs the WhereEdge pre-pass; set by Client.Query + ctx context.Context // carried for the WhereEdge pre-pass query + limit int // caller-set row cap; 0 = unbounded + offset int // caller-set starting offset; 0 = none + edges []edgeFilter // accumulated WhereEdge constraints; empty = none +} + +// edgeFilter is one accumulated WhereEdge constraint: a dgraph @filter +// expression scoped to an outbound edge predicate of T. +type edgeFilter struct { + predicate string + filter string + params []any } // Filter adds a dgraph @filter expression. params bind to placeholders. @@ -78,8 +93,35 @@ func (qb *Query[T]) Cascade(predicates ...string) *Query[T] { return qb } +// WhereEdge constrains results to records that have at least one `predicate` +// edge whose target node satisfies the dgraph @filter expression. params bind +// to $N placeholders within filter, exactly as Filter binds them. +// +// Where Filter constrains T's own scalar predicates, WhereEdge constrains a +// neighbouring node reached over an edge. dgraph's root @filter cannot express +// that, so a query carrying WhereEdge constraints executes in two steps: a +// pre-pass resolves the UIDs of roots that satisfy every constraint, then the +// main query runs against uid(...) — keeping ordering, pagination, and result +// projection on the normal path. See +// docs/specs/2026-05-21-query-edge-filter-design.md. +// +// WhereEdge accumulates: multiple calls AND together (a record must satisfy +// every edge constraint). It is the substrate behind the generated +// Query.Where methods. +func (qb *Query[T]) WhereEdge(predicate, filter string, params ...any) *Query[T] { + qb.edges = append(qb.edges, edgeFilter{predicate: predicate, filter: filter, params: params}) + return qb +} + // Nodes executes the query and returns all matching records. func (qb *Query[T]) Nodes() ([]T, error) { + matched, err := qb.resolveRoots() + if err != nil { + return nil, err + } + if !matched { + return nil, nil + } var out []T if err := qb.q.Nodes(&out); err != nil { return nil, err @@ -90,6 +132,13 @@ func (qb *Query[T]) Nodes() ([]T, error) { // First executes the query with an implicit Limit(1) and returns the first // record, or (nil, nil) if the query matched no rows. func (qb *Query[T]) First() (*T, error) { + matched, err := qb.resolveRoots() + if err != nil { + return nil, err + } + if !matched { + return nil, nil + } var out []T if err := qb.q.First(1).Nodes(&out); err != nil { return nil, err @@ -110,9 +159,19 @@ func (qb *Query[T]) First() (*T, error) { // // All pages execute against one read-only transaction, so the iteration reads // a single consistent snapshot: a concurrent writer cannot make it skip or -// repeat rows. On error it yields a final (nil, err) and stops. +// repeat rows. A WhereEdge pre-pass, when present, runs once before paging +// begins, in its own transaction. On error it yields a final (nil, err) and +// stops. func (qb *Query[T]) IterNodes() iter.Seq2[*T, error] { return func(yield func(*T, error) bool) { + matched, err := qb.resolveRoots() + if err != nil { + yield(nil, err) + return + } + if !matched { + return // edge constraints present, but no root matched + } remaining := qb.limit // 0 = unbounded for off := qb.offset; ; off += defaultPageSize { size := defaultPageSize @@ -142,7 +201,105 @@ func (qb *Query[T]) IterNodes() iter.Seq2[*T, error] { } // Raw returns the underlying dgman query for operations Query does not wrap -// (Var, As, Name, RootFunc, GroupBy, Vars). +// (Var, As, Name, RootFunc, GroupBy, Vars). Raw does not carry WhereEdge +// constraints — those are resolved only when a terminal runs. func (qb *Query[T]) Raw() *dg.Query { return qb.q } + +// resolveRoots runs the WhereEdge pre-pass when the query carries edge +// constraints, rewriting the main query's root function to the matching UIDs. +// It returns matched=false when constraints are present but no root satisfied +// them — callers then return an empty result without running the main query. +// With no edge constraints it is a no-op returning matched=true. +func (qb *Query[T]) resolveRoots() (matched bool, err error) { + if len(qb.edges) == 0 { + return true, nil + } + uids, err := qb.matchedUIDs() + if err != nil { + return false, err + } + if len(uids) == 0 { + return false, nil + } + qb.q.RootFunc("uid(" + strings.Join(uids, ", ") + ")") + return true, nil +} + +// matchedUIDs runs the pre-pass: an @cascade query over T that keeps only +// nodes whose every WhereEdge predicate has a target matching its filter, and +// returns those nodes' UIDs. +func (qb *Query[T]) matchedUIDs() ([]string, error) { + var z T + pre := qb.conn.Query(qb.ctx, &z) + body, params := qb.edgeMatchBody() + pre.Cascade().Query(body, params...) + + var rows []struct { + UID string `json:"uid"` + } + if err := pre.Nodes(&rows); err != nil { + return nil, err + } + uids := make([]string, len(rows)) + for i := range rows { + uids[i] = rows[i].UID + } + return uids, nil +} + +// edgeMatchBody renders the selection set for the pre-pass: uid plus one +// aliased, filtered block per WhereEdge constraint. The caller adds a bare +// @cascade, which then drops any node with an empty block — so a survivor +// satisfies every constraint. Blocks are aliased mg_e0, mg_e1, ... so two +// constraints on the same predicate do not collide as duplicate fields. Each +// fragment's $N placeholders are shifted to stay bound to its own params once +// every fragment's params are concatenated into one slice. +func (qb *Query[T]) edgeMatchBody() (body string, params []any) { + var b strings.Builder + b.WriteString("{\n\tuid\n") + for i, e := range qb.edges { + b.WriteString("\tmg_e") + b.WriteString(strconv.Itoa(i)) + b.WriteString(" : ") + b.WriteString(e.predicate) + b.WriteString(" @filter(") + b.WriteString(shiftPlaceholders(e.filter, len(params))) + b.WriteString(") { uid }\n") + params = append(params, e.params...) + } + b.WriteString("}") + return b.String(), params +} + +// shiftPlaceholders rewrites dgman ordinal placeholders ($1, $2, ...) in expr, +// adding delta to each index. WhereEdge filters are written independently, each +// numbering its params from $1; concatenating them into one pre-pass body +// needs every fragment renumbered against the combined params slice. A '$' not +// followed by a digit is left as-is, matching dgman's parseQueryWithParams. +func shiftPlaceholders(expr string, delta int) string { + if delta == 0 || !strings.ContainsRune(expr, '$') { + return expr + } + var b strings.Builder + for i := 0; i < len(expr); i++ { + if expr[i] != '$' { + b.WriteByte(expr[i]) + continue + } + j := i + 1 + for j < len(expr) && expr[j] >= '0' && expr[j] <= '9' { + j++ + } + if j == i+1 { // '$' not followed by digits — leave verbatim + b.WriteByte('$') + continue + } + n, _ := strconv.Atoi(expr[i+1 : j]) + b.WriteByte('$') + b.WriteString(strconv.Itoa(n + delta)) + i = j - 1 + } + return b.String() +} diff --git a/typed/query_test.go b/typed/query_test.go index 294e22d..69f7f89 100644 --- a/typed/query_test.go +++ b/typed/query_test.go @@ -768,3 +768,176 @@ func TestQuery_LimitOffsetStillDriveNodes(t *testing.T) { } } } + +// seedOwners inserts owner/pet pairs over conn for the WhereEdge tests. Each +// map entry is one owner owning one pet of the given name; the pet is inserted +// first so the owner's edge links an already-persisted node. It returns an +// owner client bound to conn. +func seedOwners(ctx context.Context, t *testing.T, conn modusgraph.Client, ownerToPet map[string]string) *typed.Client[owner] { + t.Helper() + pets := typed.NewClient[pet](conn) + owners := typed.NewClient[owner](conn) + for ownerName, petName := range ownerToPet { + p := &pet{Name: petName} + if err := pets.Add(ctx, p); err != nil { + t.Fatalf("Add pet %q: %v", petName, err) + } + if err := owners.Add(ctx, &owner{Name: ownerName, Pets: []*pet{p}}); err != nil { + t.Fatalf("Add owner %q: %v", ownerName, err) + } + } + return owners +} + +func TestQuery_WhereEdgeFiltersByEdgeTarget(t *testing.T) { + ctx := context.Background() + owners := seedOwners(ctx, t, newConn(t), map[string]string{ + "Alice": "Fido", + "Bob": "Rex", + "Carol": "Fido", + }) + + // WhereEdge constrains owners by a scalar of the pet reached over the + // "pets" edge — something a root Filter cannot express. + got, err := owners.Query(ctx).WhereEdge("pets", `eq(name, "Fido")`).Nodes() + if err != nil { + t.Fatalf("WhereEdge Nodes: %v", err) + } + if len(got) != 2 { + t.Fatalf("WhereEdge(pets, name=Fido) returned %d owners, want 2 (Alice, Carol)", len(got)) + } + for _, o := range got { + if o.Name != "Alice" && o.Name != "Carol" { + t.Fatalf("WhereEdge returned %q, want only Fido owners (Alice, Carol)", o.Name) + } + } +} + +func TestQuery_WhereEdgeNoMatchReturnsEmpty(t *testing.T) { + ctx := context.Background() + owners := seedOwners(ctx, t, newConn(t), map[string]string{"Alice": "Fido", "Bob": "Rex"}) + + // No pet is named Nemo: the pre-pass matches zero roots, so Nodes returns + // an empty result — not an error — and never runs the main query. + got, err := owners.Query(ctx).WhereEdge("pets", `eq(name, "Nemo")`).Nodes() + if err != nil { + t.Fatalf("WhereEdge Nodes: unexpected error %v", err) + } + if len(got) != 0 { + t.Fatalf("WhereEdge for an unowned pet name returned %d owners, want 0", len(got)) + } +} + +func TestQuery_WhereEdgeBindsParams(t *testing.T) { + ctx := context.Background() + owners := seedOwners(ctx, t, newConn(t), map[string]string{"Alice": "Fido", "Bob": "Rex"}) + + // The $1 placeholder in a WhereEdge filter binds exactly as it does for Filter. + got, err := owners.Query(ctx).WhereEdge("pets", "eq(name, $1)", "Rex").Nodes() + if err != nil { + t.Fatalf("WhereEdge Nodes: %v", err) + } + if len(got) != 1 || got[0].Name != "Bob" { + t.Fatalf("WhereEdge(pets, name=$1, Rex) returned %+v, want [Bob]", got) + } +} + +func TestQuery_WhereEdgeCombinesWithFilter(t *testing.T) { + ctx := context.Background() + // Alice and Carol both own a Fido; a root Filter on name narrows to Alice. + owners := seedOwners(ctx, t, newConn(t), map[string]string{ + "Alice": "Fido", + "Bob": "Rex", + "Carol": "Fido", + }) + + got, err := owners.Query(ctx). + Filter(`eq(name, "Alice")`). + WhereEdge("pets", `eq(name, "Fido")`). + Nodes() + if err != nil { + t.Fatalf("Filter+WhereEdge Nodes: %v", err) + } + if len(got) != 1 || got[0].Name != "Alice" { + t.Fatalf("Filter(name=Alice)+WhereEdge(pets,name=Fido) returned %+v, want [Alice]", got) + } +} + +func TestQuery_WhereEdgeMultipleConstraintsAnd(t *testing.T) { + ctx := context.Background() + conn := newConn(t) + pets := typed.NewClient[pet](conn) + owners := typed.NewClient[owner](conn) + + // Alice owns both Fido and Rex; Bob owns only Fido. + fido, rex := &pet{Name: "Fido"}, &pet{Name: "Rex"} + for _, p := range []*pet{fido, rex} { + if err := pets.Add(ctx, p); err != nil { + t.Fatalf("Add pet %q: %v", p.Name, err) + } + } + if err := owners.Add(ctx, &owner{Name: "Alice", Pets: []*pet{fido, rex}}); err != nil { + t.Fatalf("Add Alice: %v", err) + } + if err := owners.Add(ctx, &owner{Name: "Bob", Pets: []*pet{fido}}); err != nil { + t.Fatalf("Add Bob: %v", err) + } + + // Two WhereEdge calls AND together: only an owner of BOTH pets survives. + got, err := owners.Query(ctx). + WhereEdge("pets", `eq(name, "Fido")`). + WhereEdge("pets", `eq(name, "Rex")`). + Nodes() + if err != nil { + t.Fatalf("two-WhereEdge Nodes: %v", err) + } + if len(got) != 1 || got[0].Name != "Alice" { + t.Fatalf("WhereEdge(Fido) AND WhereEdge(Rex) returned %+v, want [Alice]", got) + } +} + +func TestQuery_WhereEdgeFirst(t *testing.T) { + ctx := context.Background() + owners := seedOwners(ctx, t, newConn(t), map[string]string{"Alice": "Fido", "Bob": "Rex"}) + + // First runs the pre-pass too: it returns the Rex owner, never a Fido one. + got, err := owners.Query(ctx).WhereEdge("pets", `eq(name, "Rex")`).First() + if err != nil { + t.Fatalf("WhereEdge First: %v", err) + } + if got == nil || got.Name != "Bob" { + t.Fatalf("WhereEdge(pets,name=Rex).First() = %+v, want Bob", got) + } + + // First with an edge constraint nothing satisfies is (nil, nil). + none, err := owners.Query(ctx).WhereEdge("pets", `eq(name, "Nemo")`).First() + if err != nil { + t.Fatalf("WhereEdge First no-match: unexpected error %v", err) + } + if none != nil { + t.Fatalf("WhereEdge First with no match = %+v, want nil", none) + } +} + +func TestQuery_WhereEdgeIterNodes(t *testing.T) { + ctx := context.Background() + owners := seedOwners(ctx, t, newConn(t), map[string]string{ + "Alice": "Fido", + "Bob": "Rex", + "Carol": "Fido", + }) + + seen := 0 + for o, err := range owners.Query(ctx).WhereEdge("pets", `eq(name, "Fido")`).IterNodes() { + if err != nil { + t.Fatalf("WhereEdge IterNodes yielded error: %v", err) + } + if o.Name != "Alice" && o.Name != "Carol" { + t.Fatalf("WhereEdge IterNodes yielded %q, want a Fido owner", o.Name) + } + seen++ + } + if seen != 2 { + t.Fatalf("WhereEdge IterNodes streamed %d owners, want 2", seen) + } +}