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= diff --git a/predicate_test.go b/predicate_test.go new file mode 100644 index 0000000..817552f --- /dev/null +++ b/predicate_test.go @@ -0,0 +1,510 @@ +/* + * 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 unique"` + 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"` +} + +// 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) { + 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. +func TestPredicateUpsert(t *testing.T) { + 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)") + } + }) + } +} + +// 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") + }) + } +} 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") + }) + } +}