diff --git a/cmd/modusgraph-gen/internal/generator/generator_test.go b/cmd/modusgraph-gen/internal/generator/generator_test.go index ffffc48..d4893b8 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) { @@ -1066,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 1cb5600..666565a 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 }}] } @@ -57,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) { @@ -80,3 +92,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 + } + } + } +} 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..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 @@ -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] } @@ -57,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() @@ -79,3 +89,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..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 @@ -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] } @@ -57,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() @@ -79,3 +89,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..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 @@ -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] } @@ -57,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() @@ -79,3 +89,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..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 @@ -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] } @@ -57,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() @@ -79,3 +89,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..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 @@ -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] } @@ -57,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() @@ -79,3 +129,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..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 @@ -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] } @@ -57,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() @@ -79,3 +89,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..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 @@ -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] } @@ -57,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() @@ -79,3 +89,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..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 @@ -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] } @@ -57,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() @@ -79,3 +145,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..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 @@ -262,3 +262,90 @@ 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) + } +} + +// 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/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. 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. 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. diff --git a/typed/client.go b/typed/client.go index 0e8dd54..ffe80a2 100644 --- a/typed/client.go +++ b/typed/client.go @@ -58,35 +58,20 @@ 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 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() } 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 c604619..dcea794 100644 --- a/typed/query.go +++ b/typed/query.go @@ -6,12 +6,18 @@ 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 -// 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 @@ -20,10 +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 + q *dg.Query + 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. @@ -47,12 +69,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 } @@ -69,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 @@ -81,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 @@ -91,8 +149,157 @@ 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. 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 + 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). +// (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 508178d..69f7f89 100644 --- a/typed/query_test.go +++ b/typed/query_test.go @@ -526,3 +526,418 @@ 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) + } + } +} + +// 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) + } +}