From 5262ffdf02a17623fb3c9c5e29bda4cf81e6830f Mon Sep 17 00:00:00 2001 From: Michael Welles Date: Tue, 17 Feb 2026 23:33:48 -0500 Subject: [PATCH 1/6] Point go.mod at forked dgman, add []T slice support tests and predicate= e2e tests - Add replace directive for github.com/dolan-in/dgman/v2 => ../dgman to pull in predicate= fixes from the dgman fork. - Add slice_test.go with tests proving []T value-type slices work: - TestValueTypeSliceInsertAndQuery: insert/get with []Genre - TestPointerTypeSliceInsertAndQuery: baseline with []*Genre - TestValueTypeSliceParity: confirms []T and []*T produce equivalent results - TestValueTypeSliceUpdate: update with additional []Genre elements - TestValueTypeSliceEmpty: nil/empty slice handling - Add predicate_test.go with e2e tests for predicate= tag support: - TestPredicateInsertAndGet: round-trip with predicate= fields - TestPredicateUpdate: update with predicate= fields - TestPredicateUpsert: upsert with predicate= (skipped, depends on dgman read fix) - TestPredicateQuery: filter queries using predicate names --- go.mod | 2 + predicate_test.go | 303 +++++++++++++++++++++++++++++++++++ slice_test.go | 392 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 697 insertions(+) create mode 100644 predicate_test.go create mode 100644 slice_test.go diff --git a/go.mod b/go.mod index 5ec767e..89c942e 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/matthewmcneely/modusgraph go 1.25.6 +replace github.com/dolan-in/dgman/v2 => ../dgman + require ( github.com/cavaliergopher/grab/v3 v3.0.1 github.com/dgraph-io/badger/v4 v4.9.0 diff --git a/predicate_test.go b/predicate_test.go new file mode 100644 index 0000000..416ebb9 --- /dev/null +++ b/predicate_test.go @@ -0,0 +1,303 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package modusgraph_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// NOTE: These tests exercise the predicate= tag through modusgraph's client API. +// They depend on the dgman fork (mlwelles/dgman) containing the predicate= fixes +// for both the write path (filterStruct using schema.Predicate as map key) and +// the read path (remapping JSON keys from predicate names to json tag names). +// Until the dgman fork fixes land, these tests will fail because: +// - MutateBasic writes data under the json tag name instead of the predicate name +// - Query/Get returns zero values for fields where predicate != json tag + +// PredicateFilm is a test struct where the dgraph predicate name differs from +// the json tag name. This exercises the predicate= fix in dgman. +type PredicateFilm struct { + Title string `json:"title,omitempty" dgraph:"predicate=film_title index=exact"` + ReleaseDate time.Time `json:"releaseDate,omitzero" dgraph:"predicate=release_date index=day"` + Rating float64 `json:"rating,omitempty" dgraph:"predicate=film_rating index=float"` + + UID string `json:"uid,omitempty"` + DType []string `json:"dgraph.type,omitempty"` +} + +// TestPredicateInsertAndGet tests that Insert + Get round-trips correctly +// when predicate= differs from the json tag. +func TestPredicateInsertAndGet(t *testing.T) { + testCases := []struct { + name string + uri string + skip bool + }{ + { + name: "PredicateInsertGetWithFileURI", + uri: "file://" + GetTempDir(t), + }, + { + name: "PredicateInsertGetWithDgraphURI", + uri: "dgraph://" + os.Getenv("MODUSGRAPH_TEST_ADDR"), + skip: os.Getenv("MODUSGRAPH_TEST_ADDR") == "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.skip { + t.Skipf("Skipping %s: MODUSGRAPH_TEST_ADDR not set", tc.name) + return + } + + client, cleanup := CreateTestClient(t, tc.uri) + defer cleanup() + + ctx := context.Background() + releaseDate := time.Date(1999, 3, 31, 0, 0, 0, 0, time.UTC) + + film := PredicateFilm{ + Title: "The Matrix", + ReleaseDate: releaseDate, + Rating: 8.7, + } + + err := client.Insert(ctx, &film) + require.NoError(t, err, "Insert should succeed") + require.NotEmpty(t, film.UID, "UID should be assigned") + + // Get the film back by UID + var retrieved PredicateFilm + err = client.Get(ctx, &retrieved, film.UID) + require.NoError(t, err, "Get should succeed") + + // These assertions verify the predicate= fix: data stored under + // the predicate name (film_title, release_date, film_rating) should + // be correctly mapped back to the json tag fields. + assert.Equal(t, "The Matrix", retrieved.Title, + "Title should round-trip correctly (predicate=film_title)") + assert.Equal(t, releaseDate, retrieved.ReleaseDate, + "ReleaseDate should round-trip correctly (predicate=release_date)") + assert.Equal(t, 8.7, retrieved.Rating, + "Rating should round-trip correctly (predicate=film_rating)") + }) + } +} + +// TestPredicateUpdate tests that Update works correctly with predicate= fields. +func TestPredicateUpdate(t *testing.T) { + testCases := []struct { + name string + uri string + skip bool + }{ + { + name: "PredicateUpdateWithFileURI", + uri: "file://" + GetTempDir(t), + }, + { + name: "PredicateUpdateWithDgraphURI", + uri: "dgraph://" + os.Getenv("MODUSGRAPH_TEST_ADDR"), + skip: os.Getenv("MODUSGRAPH_TEST_ADDR") == "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.skip { + t.Skipf("Skipping %s: MODUSGRAPH_TEST_ADDR not set", tc.name) + return + } + + client, cleanup := CreateTestClient(t, tc.uri) + defer cleanup() + + ctx := context.Background() + releaseDate := time.Date(1999, 3, 31, 0, 0, 0, 0, time.UTC) + + film := PredicateFilm{ + Title: "The Matrix", + ReleaseDate: releaseDate, + Rating: 8.7, + } + + err := client.Insert(ctx, &film) + require.NoError(t, err, "Insert should succeed") + + // Update the rating + film.Rating = 9.0 + err = client.Update(ctx, &film) + require.NoError(t, err, "Update should succeed") + + var retrieved PredicateFilm + err = client.Get(ctx, &retrieved, film.UID) + require.NoError(t, err, "Get should succeed after update") + assert.Equal(t, 9.0, retrieved.Rating, + "Rating should be updated via predicate=film_rating") + assert.Equal(t, "The Matrix", retrieved.Title, + "Title should still be correct after update") + }) + } +} + +// TestPredicateUpsert tests that Upsert works correctly with predicate= fields. +// NOTE: This test depends on the dgman fork (mlwelles/dgman) containing the +// predicate= fixes for the read path. Upsert uses the do() path which correctly +// writes data under predicate names, but the read path currently maps by json tags. +// This test will fail until the dgman fork read-path fix is applied. +func TestPredicateUpsert(t *testing.T) { + t.Skip("Depends on dgman fork predicate= read-path fix (not yet applied)") + + testCases := []struct { + name string + uri string + skip bool + }{ + { + name: "PredicateUpsertWithFileURI", + uri: "file://" + GetTempDir(t), + }, + { + name: "PredicateUpsertWithDgraphURI", + uri: "dgraph://" + os.Getenv("MODUSGRAPH_TEST_ADDR"), + skip: os.Getenv("MODUSGRAPH_TEST_ADDR") == "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.skip { + t.Skipf("Skipping %s: MODUSGRAPH_TEST_ADDR not set", tc.name) + return + } + + client, cleanup := CreateTestClient(t, tc.uri) + defer cleanup() + + ctx := context.Background() + releaseDate := time.Date(1999, 3, 31, 0, 0, 0, 0, time.UTC) + + // First upsert creates the node + film := PredicateFilm{ + Title: "The Matrix", + ReleaseDate: releaseDate, + Rating: 8.7, + } + + err := client.Upsert(ctx, &film, "film_title") + require.NoError(t, err, "Upsert (create) should succeed") + require.NotEmpty(t, film.UID, "UID should be assigned") + firstUID := film.UID + + // Second upsert updates the existing node + film2 := PredicateFilm{ + Title: "The Matrix", + ReleaseDate: releaseDate, + Rating: 9.1, + } + err = client.Upsert(ctx, &film2, "film_title") + require.NoError(t, err, "Upsert (update) should succeed") + assert.Equal(t, firstUID, film2.UID, + "Upsert should reuse the same UID") + + // Verify the update + var retrieved PredicateFilm + err = client.Get(ctx, &retrieved, firstUID) + require.NoError(t, err, "Get should succeed after upsert") + assert.Equal(t, "The Matrix", retrieved.Title) + assert.Equal(t, 9.1, retrieved.Rating, + "Rating should be updated after upsert") + }) + } +} + +// TestPredicateQuery tests that Query with filters works correctly +// when predicates differ from json tags. +func TestPredicateQuery(t *testing.T) { + testCases := []struct { + name string + uri string + skip bool + }{ + { + name: "PredicateQueryWithFileURI", + uri: "file://" + GetTempDir(t), + }, + { + name: "PredicateQueryWithDgraphURI", + uri: "dgraph://" + os.Getenv("MODUSGRAPH_TEST_ADDR"), + skip: os.Getenv("MODUSGRAPH_TEST_ADDR") == "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.skip { + t.Skipf("Skipping %s: MODUSGRAPH_TEST_ADDR not set", tc.name) + return + } + + client, cleanup := CreateTestClient(t, tc.uri) + defer cleanup() + + ctx := context.Background() + + // Insert multiple films with different release dates + films := []*PredicateFilm{ + { + Title: "Film A", + ReleaseDate: time.Date(1985, 1, 1, 0, 0, 0, 0, time.UTC), + Rating: 7.5, + }, + { + Title: "Film B", + ReleaseDate: time.Date(1995, 6, 15, 0, 0, 0, 0, time.UTC), + Rating: 8.0, + }, + { + Title: "Film C", + ReleaseDate: time.Date(2005, 12, 25, 0, 0, 0, 0, time.UTC), + Rating: 9.0, + }, + } + err := client.Insert(ctx, films) + require.NoError(t, err, "Insert films should succeed") + + // Query using predicate names (not json tag names) in filter. + // The filter references release_date (the Dgraph predicate name). + var results []PredicateFilm + err = client.Query(ctx, PredicateFilm{}). + Filter(`ge(release_date, "1990-01-01T00:00:00Z")`). + Nodes(&results) + require.NoError(t, err, "Query with predicate filter should succeed") + require.Len(t, results, 2, + "Should find 2 films with release_date >= 1990") + + titles := make([]string, len(results)) + for i, r := range results { + titles[i] = r.Title + } + assert.ElementsMatch(t, []string{"Film B", "Film C"}, titles, + "Should find the correct films") + + // Verify that all queried films have their predicate= fields populated + for _, r := range results { + assert.NotEmpty(t, r.Title, "Title should be populated") + assert.False(t, r.ReleaseDate.IsZero(), + "ReleaseDate should be populated (predicate=release_date)") + assert.NotZero(t, r.Rating, + "Rating should be populated (predicate=film_rating)") + } + }) + } +} diff --git a/slice_test.go b/slice_test.go new file mode 100644 index 0000000..5f04be4 --- /dev/null +++ b/slice_test.go @@ -0,0 +1,392 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package modusgraph_test + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Genre is a simple struct used as a value-type slice element. +type Genre struct { + Name string `json:"name,omitempty" dgraph:"index=exact"` + + UID string `json:"uid,omitempty"` + DType []string `json:"dgraph.type,omitempty"` +} + +// MovieWithValueSlice uses []Genre (value-type slice) for its genres field. +type MovieWithValueSlice struct { + Title string `json:"title,omitempty" dgraph:"index=exact"` + Genres []Genre `json:"genres,omitempty"` + + UID string `json:"uid,omitempty"` + DType []string `json:"dgraph.type,omitempty"` +} + +// MovieWithPointerSlice uses []*Genre (pointer-type slice) for its genres field. +type MovieWithPointerSlice struct { + Title string `json:"title,omitempty" dgraph:"index=exact"` + Genres []*Genre `json:"genres,omitempty"` + + UID string `json:"uid,omitempty"` + DType []string `json:"dgraph.type,omitempty"` +} + +// TestValueTypeSliceInsertAndQuery tests that []T (value-type slice) fields +// round-trip correctly through insert and query. +func TestValueTypeSliceInsertAndQuery(t *testing.T) { + testCases := []struct { + name string + uri string + skip bool + }{ + { + name: "ValueTypeSliceWithFileURI", + uri: "file://" + GetTempDir(t), + }, + { + name: "ValueTypeSliceWithDgraphURI", + uri: "dgraph://" + os.Getenv("MODUSGRAPH_TEST_ADDR"), + skip: os.Getenv("MODUSGRAPH_TEST_ADDR") == "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.skip { + t.Skipf("Skipping %s: MODUSGRAPH_TEST_ADDR not set", tc.name) + return + } + + client, cleanup := CreateTestClient(t, tc.uri) + defer cleanup() + + ctx := context.Background() + + // Insert a movie with value-type []Genre slice + movie := MovieWithValueSlice{ + Title: "The Matrix", + Genres: []Genre{ + {Name: "Action"}, + {Name: "Sci-Fi"}, + }, + } + + err := client.Insert(ctx, &movie) + require.NoError(t, err, "Insert with []Genre should succeed") + require.NotEmpty(t, movie.UID, "Movie UID should be assigned") + // Verify that nested Genre UIDs were assigned + for i, g := range movie.Genres { + require.NotEmpty(t, g.UID, "Genre[%d] UID should be assigned", i) + } + + // Query the movie back and verify genres are populated + var retrieved MovieWithValueSlice + err = client.Get(ctx, &retrieved, movie.UID) + require.NoError(t, err, "Get should succeed") + require.Equal(t, "The Matrix", retrieved.Title, "Title should match") + require.Len(t, retrieved.Genres, 2, "Should have 2 genres") + + genreNames := []string{retrieved.Genres[0].Name, retrieved.Genres[1].Name} + assert.ElementsMatch(t, []string{"Action", "Sci-Fi"}, genreNames, + "Genre names should match") + + // Also verify via Query + var results []MovieWithValueSlice + err = client.Query(ctx, MovieWithValueSlice{}). + Filter(`eq(title, "The Matrix")`). + Nodes(&results) + require.NoError(t, err, "Query should succeed") + require.Len(t, results, 1, "Should find 1 movie") + require.Len(t, results[0].Genres, 2, "Queried movie should have 2 genres") + }) + } +} + +// TestPointerTypeSliceInsertAndQuery tests that []*T (pointer-type slice) fields +// round-trip correctly through insert and query (baseline for comparison). +func TestPointerTypeSliceInsertAndQuery(t *testing.T) { + testCases := []struct { + name string + uri string + skip bool + }{ + { + name: "PointerTypeSliceWithFileURI", + uri: "file://" + GetTempDir(t), + }, + { + name: "PointerTypeSliceWithDgraphURI", + uri: "dgraph://" + os.Getenv("MODUSGRAPH_TEST_ADDR"), + skip: os.Getenv("MODUSGRAPH_TEST_ADDR") == "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.skip { + t.Skipf("Skipping %s: MODUSGRAPH_TEST_ADDR not set", tc.name) + return + } + + client, cleanup := CreateTestClient(t, tc.uri) + defer cleanup() + + ctx := context.Background() + + // Insert a movie with pointer-type []*Genre slice + movie := MovieWithPointerSlice{ + Title: "Inception", + Genres: []*Genre{ + {Name: "Thriller"}, + {Name: "Sci-Fi"}, + }, + } + + err := client.Insert(ctx, &movie) + require.NoError(t, err, "Insert with []*Genre should succeed") + require.NotEmpty(t, movie.UID, "Movie UID should be assigned") + for i, g := range movie.Genres { + require.NotEmpty(t, g.UID, "Genre[%d] UID should be assigned", i) + } + + // Query the movie back and verify genres are populated + var retrieved MovieWithPointerSlice + err = client.Get(ctx, &retrieved, movie.UID) + require.NoError(t, err, "Get should succeed") + require.Equal(t, "Inception", retrieved.Title, "Title should match") + require.Len(t, retrieved.Genres, 2, "Should have 2 genres") + + genreNames := []string{retrieved.Genres[0].Name, retrieved.Genres[1].Name} + assert.ElementsMatch(t, []string{"Thriller", "Sci-Fi"}, genreNames, + "Genre names should match") + + // Also verify via Query + var results []MovieWithPointerSlice + err = client.Query(ctx, MovieWithPointerSlice{}). + Filter(`eq(title, "Inception")`). + Nodes(&results) + require.NoError(t, err, "Query should succeed") + require.Len(t, results, 1, "Should find 1 movie") + require.Len(t, results[0].Genres, 2, "Queried movie should have 2 genres") + }) + } +} + +// TestValueTypeSliceParity compares behavior of []T and []*T to confirm they +// produce equivalent results when round-tripped through modusgraph. +func TestValueTypeSliceParity(t *testing.T) { + testCases := []struct { + name string + uri string + skip bool + }{ + { + name: "SliceParityWithFileURI", + uri: "file://" + GetTempDir(t), + }, + { + name: "SliceParityWithDgraphURI", + uri: "dgraph://" + os.Getenv("MODUSGRAPH_TEST_ADDR"), + skip: os.Getenv("MODUSGRAPH_TEST_ADDR") == "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.skip { + t.Skipf("Skipping %s: MODUSGRAPH_TEST_ADDR not set", tc.name) + return + } + + client, cleanup := CreateTestClient(t, tc.uri) + defer cleanup() + + ctx := context.Background() + + // Insert via value-type slice + valueMovie := MovieWithValueSlice{ + Title: "Value Movie", + Genres: []Genre{ + {Name: "Drama"}, + {Name: "Comedy"}, + {Name: "Romance"}, + }, + } + err := client.Insert(ctx, &valueMovie) + require.NoError(t, err, "Insert with []Genre should succeed") + require.NotEmpty(t, valueMovie.UID) + + // Insert via pointer-type slice + ptrMovie := MovieWithPointerSlice{ + Title: "Pointer Movie", + Genres: []*Genre{ + {Name: "Horror"}, + {Name: "Mystery"}, + {Name: "Thriller"}, + }, + } + err = client.Insert(ctx, &ptrMovie) + require.NoError(t, err, "Insert with []*Genre should succeed") + require.NotEmpty(t, ptrMovie.UID) + + // Query back both + var valueResult MovieWithValueSlice + err = client.Get(ctx, &valueResult, valueMovie.UID) + require.NoError(t, err, "Get value movie should succeed") + + var ptrResult MovieWithPointerSlice + err = client.Get(ctx, &ptrResult, ptrMovie.UID) + require.NoError(t, err, "Get pointer movie should succeed") + + // Both should have 3 genres + require.Len(t, valueResult.Genres, 3, + "Value-type movie should have 3 genres") + require.Len(t, ptrResult.Genres, 3, + "Pointer-type movie should have 3 genres") + + // Verify all genre UIDs are non-empty + for i, g := range valueResult.Genres { + assert.NotEmpty(t, g.UID, "Value genre[%d] should have UID", i) + assert.NotEmpty(t, g.Name, "Value genre[%d] should have Name", i) + } + for i, g := range ptrResult.Genres { + assert.NotEmpty(t, g.UID, "Pointer genre[%d] should have UID", i) + assert.NotEmpty(t, g.Name, "Pointer genre[%d] should have Name", i) + } + + // Verify the genre names are as expected + valueGenreNames := make([]string, len(valueResult.Genres)) + for i, g := range valueResult.Genres { + valueGenreNames[i] = g.Name + } + assert.ElementsMatch(t, []string{"Drama", "Comedy", "Romance"}, + valueGenreNames, "Value-type genre names should match") + + ptrGenreNames := make([]string, len(ptrResult.Genres)) + for i, g := range ptrResult.Genres { + ptrGenreNames[i] = g.Name + } + assert.ElementsMatch(t, []string{"Horror", "Mystery", "Thriller"}, + ptrGenreNames, "Pointer-type genre names should match") + }) + } +} + +// TestValueTypeSliceUpdate tests that updating a struct with []T slice works. +func TestValueTypeSliceUpdate(t *testing.T) { + testCases := []struct { + name string + uri string + skip bool + }{ + { + name: "ValueTypeSliceUpdateWithFileURI", + uri: "file://" + GetTempDir(t), + }, + { + name: "ValueTypeSliceUpdateWithDgraphURI", + uri: "dgraph://" + os.Getenv("MODUSGRAPH_TEST_ADDR"), + skip: os.Getenv("MODUSGRAPH_TEST_ADDR") == "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.skip { + t.Skipf("Skipping %s: MODUSGRAPH_TEST_ADDR not set", tc.name) + return + } + + client, cleanup := CreateTestClient(t, tc.uri) + defer cleanup() + + ctx := context.Background() + + // Insert initial movie with value-type genres + movie := MovieWithValueSlice{ + Title: "Test Movie", + Genres: []Genre{ + {Name: "Action"}, + }, + } + err := client.Insert(ctx, &movie) + require.NoError(t, err, "Insert should succeed") + require.NotEmpty(t, movie.UID) + + // Update: add a new genre + movie.Genres = append(movie.Genres, Genre{Name: "Adventure"}) + err = client.Update(ctx, &movie) + require.NoError(t, err, "Update with additional genre should succeed") + + // Verify the update + var updated MovieWithValueSlice + err = client.Get(ctx, &updated, movie.UID) + require.NoError(t, err, "Get should succeed after update") + require.Len(t, updated.Genres, 2, "Should have 2 genres after update") + + genreNames := make([]string, len(updated.Genres)) + for i, g := range updated.Genres { + genreNames[i] = g.Name + } + assert.ElementsMatch(t, []string{"Action", "Adventure"}, genreNames, + "Genre names should match after update") + }) + } +} + +// TestValueTypeSliceEmpty tests behavior with empty []T slices. +func TestValueTypeSliceEmpty(t *testing.T) { + testCases := []struct { + name string + uri string + skip bool + }{ + { + name: "EmptyValueSliceWithFileURI", + uri: "file://" + GetTempDir(t), + }, + { + name: "EmptyValueSliceWithDgraphURI", + uri: "dgraph://" + os.Getenv("MODUSGRAPH_TEST_ADDR"), + skip: os.Getenv("MODUSGRAPH_TEST_ADDR") == "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.skip { + t.Skipf("Skipping %s: MODUSGRAPH_TEST_ADDR not set", tc.name) + return + } + + client, cleanup := CreateTestClient(t, tc.uri) + defer cleanup() + + ctx := context.Background() + + // Insert a movie with no genres (nil slice) + movie := MovieWithValueSlice{ + Title: "No Genre Movie", + } + err := client.Insert(ctx, &movie) + require.NoError(t, err, "Insert with nil genres should succeed") + require.NotEmpty(t, movie.UID) + + // Query back + var retrieved MovieWithValueSlice + err = client.Get(ctx, &retrieved, movie.UID) + require.NoError(t, err, "Get should succeed") + require.Equal(t, "No Genre Movie", retrieved.Title) + assert.Empty(t, retrieved.Genres, "Genres should be empty") + }) + } +} From c31d12f030348e28ecf9b3e350dcbb6634fb7bac Mon Sep 17 00:00:00 2001 From: Michael Welles Date: Wed, 18 Feb 2026 00:03:05 -0500 Subject: [PATCH 2/6] Enable TestPredicateUpsert and add unique tag to test struct Add unique to PredicateFilm.Title dgraph tag (required for dgman upsert deduplication) and remove t.Skip now that the dgman fork fixes both read and write paths for predicate= fields. --- predicate_test.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/predicate_test.go b/predicate_test.go index 416ebb9..e8b9bdd 100644 --- a/predicate_test.go +++ b/predicate_test.go @@ -26,7 +26,7 @@ import ( // PredicateFilm is a test struct where the dgraph predicate name differs from // the json tag name. This exercises the predicate= fix in dgman. type PredicateFilm struct { - Title string `json:"title,omitempty" dgraph:"predicate=film_title index=exact"` + Title string `json:"title,omitempty" dgraph:"predicate=film_title index=exact unique"` ReleaseDate time.Time `json:"releaseDate,omitzero" dgraph:"predicate=release_date index=day"` Rating float64 `json:"rating,omitempty" dgraph:"predicate=film_rating index=float"` @@ -151,13 +151,7 @@ func TestPredicateUpdate(t *testing.T) { } // TestPredicateUpsert tests that Upsert works correctly with predicate= fields. -// NOTE: This test depends on the dgman fork (mlwelles/dgman) containing the -// predicate= fixes for the read path. Upsert uses the do() path which correctly -// writes data under predicate names, but the read path currently maps by json tags. -// This test will fail until the dgman fork read-path fix is applied. func TestPredicateUpsert(t *testing.T) { - t.Skip("Depends on dgman fork predicate= read-path fix (not yet applied)") - testCases := []struct { name string uri string From 35be3ddee60e3ac118013c74983d59844b28db7e Mon Sep 17 00:00:00 2001 From: Michael Welles Date: Wed, 18 Feb 2026 11:29:25 -0500 Subject: [PATCH 3/6] Remove local replace directive for upstream PR The replace github.com/dolan-in/dgman/v2 => ../dgman directive was needed for local development with the forked dgman. Remove it for the upstream PR since the upstream repo should use the published dgman module. --- go.mod | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.mod b/go.mod index 89c942e..5ec767e 100644 --- a/go.mod +++ b/go.mod @@ -2,8 +2,6 @@ module github.com/matthewmcneely/modusgraph go 1.25.6 -replace github.com/dolan-in/dgman/v2 => ../dgman - require ( github.com/cavaliergopher/grab/v3 v3.0.1 github.com/dgraph-io/badger/v4 v4.9.0 From 0ed3de2112bb99d2b44de7f0b767c2fe02fed33c Mon Sep 17 00:00:00 2001 From: Michael Welles Date: Wed, 18 Feb 2026 12:02:50 -0500 Subject: [PATCH 4/6] Add reverse edge tests with predicate=~ pattern Test that forward edges (predicate=written_by) and reverse edges (predicate=~written_by) work correctly together through Insert, Get, and Query operations. This covers the pattern used by modusGraphGen for entities like Film/Genre where the reverse edge is declared on the parent entity. --- predicate_test.go | 212 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) diff --git a/predicate_test.go b/predicate_test.go index e8b9bdd..74384d8 100644 --- a/predicate_test.go +++ b/predicate_test.go @@ -34,6 +34,29 @@ type PredicateFilm struct { DType []string `json:"dgraph.type,omitempty"` } +// PredicateBook and PredicateAuthor test forward and reverse edges using the +// predicate= tag. This mirrors the pattern used by modusGraphGen where: +// - PredicateBook has a forward edge: predicate=written_by reverse +// - PredicateAuthor has a reverse edge: predicate=~written_by reverse +// The forward edge creates an @reverse index in Dgraph, and the reverse edge +// declares a managed reverse that dgman expands in queries automatically. +type PredicateBook struct { + Title string `json:"bookTitle,omitempty" dgraph:"predicate=book_title index=exact unique"` + Year int `json:"bookYear,omitempty" dgraph:"predicate=book_year index=int"` + Author *PredicateAuthor `json:"author,omitempty" dgraph:"predicate=written_by reverse"` + + UID string `json:"uid,omitempty"` + DType []string `json:"dgraph.type,omitempty"` +} + +type PredicateAuthor struct { + Name string `json:"authorName,omitempty" dgraph:"predicate=author_name index=exact unique"` + Books []PredicateBook `json:"books,omitempty" dgraph:"predicate=~written_by reverse"` + + UID string `json:"uid,omitempty"` + DType []string `json:"dgraph.type,omitempty"` +} + // TestPredicateInsertAndGet tests that Insert + Get round-trips correctly // when predicate= differs from the json tag. func TestPredicateInsertAndGet(t *testing.T) { @@ -295,3 +318,192 @@ func TestPredicateQuery(t *testing.T) { }) } } + +// TestPredicateReverseEdge tests that forward edges with predicate= +// and reverse edges with predicate=~ work correctly together. +// This mirrors the pattern used by modusGraphGen (e.g. Film.genre / Genre.~genre). +func TestPredicateReverseEdge(t *testing.T) { + testCases := []struct { + name string + uri string + skip bool + }{ + { + name: "PredicateReverseWithFileURI", + uri: "file://" + GetTempDir(t), + }, + { + name: "PredicateReverseWithDgraphURI", + uri: "dgraph://" + os.Getenv("MODUSGRAPH_TEST_ADDR"), + skip: os.Getenv("MODUSGRAPH_TEST_ADDR") == "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.skip { + t.Skipf("Skipping %s: MODUSGRAPH_TEST_ADDR not set", tc.name) + return + } + + client, cleanup := CreateTestClient(t, tc.uri) + defer cleanup() + + ctx := context.Background() + + // Create an author first + author := &PredicateAuthor{ + Name: "Tolkien", + } + err := client.Insert(ctx, author) + require.NoError(t, err, "Insert author should succeed") + require.NotEmpty(t, author.UID, "Author UID should be assigned") + + // Create books with the forward edge to the author + book1 := &PredicateBook{ + Title: "The Hobbit", + Year: 1937, + Author: author, + } + book2 := &PredicateBook{ + Title: "The Lord of the Rings", + Year: 1954, + Author: author, + } + + err = client.Insert(ctx, book1) + require.NoError(t, err, "Insert book1 should succeed") + require.NotEmpty(t, book1.UID, "Book1 UID should be assigned") + + err = client.Insert(ctx, book2) + require.NoError(t, err, "Insert book2 should succeed") + require.NotEmpty(t, book2.UID, "Book2 UID should be assigned") + + // Get a book back and verify the forward edge (Author) is populated + var gotBook PredicateBook + err = client.Get(ctx, &gotBook, book1.UID) + require.NoError(t, err, "Get book should succeed") + + assert.Equal(t, "The Hobbit", gotBook.Title, + "Title should round-trip (predicate=book_title)") + assert.Equal(t, 1937, gotBook.Year, + "Year should round-trip (predicate=book_year)") + require.NotNil(t, gotBook.Author, + "Author forward edge should be populated (predicate=written_by)") + assert.Equal(t, "Tolkien", gotBook.Author.Name, + "Author name should round-trip (predicate=author_name)") + + // Get the author back and verify the reverse edge (Books) is populated + var gotAuthor PredicateAuthor + err = client.Get(ctx, &gotAuthor, author.UID) + require.NoError(t, err, "Get author should succeed") + + assert.Equal(t, "Tolkien", gotAuthor.Name, + "Author name should round-trip (predicate=author_name)") + require.Len(t, gotAuthor.Books, 2, + "Author should have 2 books via reverse edge (predicate=~written_by)") + + bookTitles := make(map[string]bool) + for _, b := range gotAuthor.Books { + bookTitles[b.Title] = true + } + assert.True(t, bookTitles["The Hobbit"], + "Reverse edge should include The Hobbit") + assert.True(t, bookTitles["The Lord of the Rings"], + "Reverse edge should include The Lord of the Rings") + }) + } +} + +// TestPredicateReverseEdgeQuery tests querying entities that have reverse edges +// with predicate=~, and verifies filters work on the predicate names. +func TestPredicateReverseEdgeQuery(t *testing.T) { + testCases := []struct { + name string + uri string + skip bool + }{ + { + name: "PredicateReverseQueryWithFileURI", + uri: "file://" + GetTempDir(t), + }, + { + name: "PredicateReverseQueryWithDgraphURI", + uri: "dgraph://" + os.Getenv("MODUSGRAPH_TEST_ADDR"), + skip: os.Getenv("MODUSGRAPH_TEST_ADDR") == "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.skip { + t.Skipf("Skipping %s: MODUSGRAPH_TEST_ADDR not set", tc.name) + return + } + + client, cleanup := CreateTestClient(t, tc.uri) + defer cleanup() + + ctx := context.Background() + + // Create two authors + tolkien := &PredicateAuthor{Name: "Tolkien"} + asimov := &PredicateAuthor{Name: "Asimov"} + + err := client.Insert(ctx, tolkien) + require.NoError(t, err) + err = client.Insert(ctx, asimov) + require.NoError(t, err) + + // Create books for each author + books := []*PredicateBook{ + {Title: "The Hobbit", Year: 1937, Author: tolkien}, + {Title: "The Lord of the Rings", Year: 1954, Author: tolkien}, + {Title: "Foundation", Year: 1951, Author: asimov}, + {Title: "I, Robot", Year: 1950, Author: asimov}, + {Title: "The Caves of Steel", Year: 1954, Author: asimov}, + } + err = client.Insert(ctx, books) + require.NoError(t, err, "Insert books should succeed") + + // Query authors and verify reverse edges are populated + var authors []PredicateAuthor + err = client.Query(ctx, PredicateAuthor{}). + Filter(`eq(author_name, "Tolkien")`). + Nodes(&authors) + require.NoError(t, err, "Query with author_name filter should succeed") + require.Len(t, authors, 1, "Should find exactly 1 author named Tolkien") + assert.Len(t, authors[0].Books, 2, + "Tolkien should have 2 books via reverse edge") + + // Query Asimov and verify he has 3 books + var asimovResult []PredicateAuthor + err = client.Query(ctx, PredicateAuthor{}). + Filter(`eq(author_name, "Asimov")`). + Nodes(&asimovResult) + require.NoError(t, err, "Query for Asimov should succeed") + require.Len(t, asimovResult, 1, "Should find exactly 1 Asimov") + assert.Len(t, asimovResult[0].Books, 3, + "Asimov should have 3 books via reverse edge") + + // Query books by year using predicate name, verify author forward edge + var booksFrom1954 []PredicateBook + err = client.Query(ctx, PredicateBook{}). + Filter(`eq(book_year, 1954)`). + Nodes(&booksFrom1954) + require.NoError(t, err, "Query books by year should succeed") + require.Len(t, booksFrom1954, 2, "Should find 2 books from 1954") + + authorNames := make(map[string]bool) + for _, b := range booksFrom1954 { + require.NotNil(t, b.Author, + "Book %q should have Author populated via forward edge", b.Title) + authorNames[b.Author.Name] = true + } + assert.True(t, authorNames["Tolkien"], + "Books from 1954 should include one by Tolkien") + assert.True(t, authorNames["Asimov"], + "Books from 1954 should include one by Asimov") + }) + } +} From 33fb7024dbdb0f5b28c8d3d73cbc40608303aeda Mon Sep 17 00:00:00 2001 From: Matthew McNeely Date: Wed, 18 Feb 2026 20:16:39 -0500 Subject: [PATCH 5/6] Update to supporting version of dgman --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 5ec767e..721e3fb 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/dgraph-io/dgo/v250 v250.0.0 github.com/dgraph-io/dgraph/v25 v25.1.1-0.20260202212142-15ef722329b1 github.com/dgraph-io/ristretto/v2 v2.3.0 - github.com/dolan-in/dgman/v2 v2.2.0-preview2 + github.com/dolan-in/dgman/v2 v2.2.0 github.com/go-logr/logr v1.4.3 github.com/go-logr/stdr v1.2.2 github.com/go-playground/validator/v10 v10.30.1 diff --git a/go.sum b/go.sum index 90a9d64..4d33ee1 100644 --- a/go.sum +++ b/go.sum @@ -148,8 +148,8 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/dolan-in/dgman/v2 v2.2.0-preview2 h1:BuyUS4KesesYteG8h9fQt2rUWfgk1Sst9ACjkN9b5Yo= -github.com/dolan-in/dgman/v2 v2.2.0-preview2/go.mod h1:sL8WeQ6yPsb9GwuhKLLGGKmWRjqbwjQCoJNhF/I5zxQ= +github.com/dolan-in/dgman/v2 v2.2.0 h1:HK3bgkl1aljUd0AIjZHOZAm07ZvXJGLG4rDYqcOrxAU= +github.com/dolan-in/dgman/v2 v2.2.0/go.mod h1:sL8WeQ6yPsb9GwuhKLLGGKmWRjqbwjQCoJNhF/I5zxQ= github.com/dolan-in/reflectwalk v1.0.2-0.20210101124621-dc2073a29d71 h1:v3bErDrPApxsyBlz8/8nFTCb7Ai0wecA8TokfEHIQ80= github.com/dolan-in/reflectwalk v1.0.2-0.20210101124621-dc2073a29d71/go.mod h1:Y9TyDkSL5jQ18ZnDaSxOdCUhbb5SCeamqYFQ7LYxxFs= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= From 5a7200e661eec580bc1d4eda1bee52ac2bc38073 Mon Sep 17 00:00:00 2001 From: Matthew McNeely Date: Wed, 18 Feb 2026 20:17:47 -0500 Subject: [PATCH 6/6] Format file --- predicate_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/predicate_test.go b/predicate_test.go index 74384d8..817552f 100644 --- a/predicate_test.go +++ b/predicate_test.go @@ -38,6 +38,7 @@ type PredicateFilm struct { // predicate= tag. This mirrors the pattern used by modusGraphGen where: // - PredicateBook has a forward edge: predicate=written_by reverse // - PredicateAuthor has a reverse edge: predicate=~written_by reverse +// // The forward edge creates an @reverse index in Dgraph, and the reverse edge // declares a managed reverse that dgman expands in queries automatically. type PredicateBook struct {