Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@ func (c client) validateStruct(ctx context.Context, obj any) error {
// Insert implements inserting an object or slice of objects in the database.
// Passed object must be a pointer to a struct with appropriate dgraph tags.
func (c client) Insert(ctx context.Context, obj any) error {
obj = UnwrapSchema(obj)
Comment thread
mlwelles marked this conversation as resolved.
// Validate struct before insertion
if err := c.validateStruct(ctx, obj); err != nil {
return err
Expand All @@ -503,6 +504,7 @@ func (c client) Insert(ctx context.Context, obj any) error {
//
// Deprecated: InsertRaw is now identical to Insert. Use Insert instead.
func (c client) InsertRaw(ctx context.Context, obj any) error {
obj = UnwrapSchema(obj)
// Validate struct before insertion
if err := c.validateStruct(ctx, obj); err != nil {
return err
Expand All @@ -518,6 +520,7 @@ func (c client) InsertRaw(ctx context.Context, obj any) error {
// to be used for upserting. If none are specified, the first predicate with the `upsert` tag
// will be used.
func (c client) Upsert(ctx context.Context, obj any, predicates ...string) error {
obj = UnwrapSchema(obj)
// Validate struct before upsert
if err := c.validateStruct(ctx, obj); err != nil {
return err
Expand All @@ -531,6 +534,7 @@ func (c client) Upsert(ctx context.Context, obj any, predicates ...string) error
// Update implements updating an existing object in the database.
// Passed object must be a pointer to a struct.
func (c client) Update(ctx context.Context, obj any) error {
obj = UnwrapSchema(obj)
// Validate struct before update
if err := c.validateStruct(ctx, obj); err != nil {
return err
Expand All @@ -557,6 +561,7 @@ func (c client) Delete(ctx context.Context, uids []string) error {
// Get implements retrieving a single object by its UID.
// Passed object must be a pointer to a struct.
func (c client) Get(ctx context.Context, obj any, uid string) error {
obj = UnwrapSchema(obj)
err := checkPointer(obj)
if err != nil {
return err
Expand All @@ -575,6 +580,7 @@ func (c client) Get(ctx context.Context, obj any, uid string) error {
// Returns a *dg.Query that can be further refined with filters, pagination, etc.
// The returned query will be limited to the maximum number of edges specified in the options.
func (c client) Query(ctx context.Context, model any) *dg.Query {
model = UnwrapSchema(model)
client, err := c.pool.get()
if err != nil {
return nil
Expand All @@ -590,6 +596,9 @@ func (c client) Query(ctx context.Context, model any) *dg.Query {
// If any object contains SimString fields tagged `dgraph:"embedding"`, the
// corresponding shadow float32vector predicates (<field>__vec) are also registered.
func (c client) UpdateSchema(ctx context.Context, obj ...any) error {
for i := range obj {
obj[i] = UnwrapSchema(obj[i])
}
dgClient, err := c.pool.get()
if err != nil {
c.logger.Error(err, "Failed to get client from pool")
Expand Down
131 changes: 131 additions & 0 deletions record.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/*
* SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc.
* SPDX-License-Identifier: Apache-2.0
*/

package modusgraph
Comment thread
mlwelles marked this conversation as resolved.

import "reflect"

// Schema identifies a value as a record of a generated schema-defining type.
// modusgraph-gen-emitted schema structs implement this via a generated
// SchemaTypeName() method that returns the canonical entity name
// (e.g. "Studio"). The interface is intentionally minimal — a single method
// returning a useful piece of metadata.
//
// Plain user structs (not emitted by modusgraph-gen) do not implement Schema
// and are unaffected by the modusgraph.Client routing it enables; they pass
// through to the existing reflection-based dgman pipeline exactly as before.
type Schema interface {
SchemaTypeName() string
}

// UnwrapSchema returns the schema-defining record contained in obj. If obj
// is nil, it is returned as-is. If obj is already a Schema, it is returned
// as-is. If obj exposes an Unwrap() method whose return value satisfies
// Schema, that return is substituted. Otherwise obj is returned unchanged.
//
// This is the bridge between modusgraph-gen-emitted wrapper types and the
// rest of modusgraph.Client. It is purely additive: types that don't
// implement Schema and don't have an Unwrap() method (i.e. existing
// modusgraph users' plain structs) pass through untouched.
//
// Note on errors.Unwrap overlap: Go's errors package uses Unwrap() error
// as the standard "give me the wrapped thing" method. UnwrapSchema's
// secondary check (the returned value must itself implement Schema) means
// an error wrapper is not mistaken for a modusgraph wrapper — the
// reflection probe finds Unwrap(), calls it, gets an error, fails the
// Schema check, and returns the original obj.
func UnwrapSchema(obj any) any {
if obj == nil {
return obj
}
if _, ok := obj.(Schema); ok {
return obj
}
v := reflect.ValueOf(obj)
if !v.IsValid() {
return obj
}
// Insert, InsertRaw, and Upsert accept "an object or slice of objects".
// A slice or array of wrappers must be unwrapped element-wise: otherwise
// the wrappers reach dgman, which reflects over them and fails with an
// opaque "cannot set uid/" while persisting nothing. Map over the elements.
if k := v.Kind(); k == reflect.Slice || k == reflect.Array {
return unwrapSchemaSlice(v, obj)
}
// A typed nil pointer has a valid method set, but invoking Unwrap on a nil
// receiver would panic if the method dereferences it. Leave it untouched.
if v.Kind() == reflect.Pointer && v.IsNil() {
return obj
}
m := v.MethodByName("Unwrap")
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if !m.IsValid() && v.Kind() != reflect.Pointer {
// Unwrap may be declared with a pointer receiver while obj was passed by
// value; a value's method set excludes pointer-receiver methods, so look
// it up on an addressable copy.
pv := reflect.New(v.Type())
pv.Elem().Set(v)
m = pv.MethodByName("Unwrap")
}
if !m.IsValid() {
return obj
}
mt := m.Type()
if mt.NumIn() != 0 || mt.NumOut() != 1 {
return obj
}
inner := m.Call(nil)[0].Interface()
if _, ok := inner.(Schema); ok {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
return inner
}
return obj
}

// unwrapSchemaSlice unwraps each element of a slice or array. It returns obj
// unchanged when no element is a wrapper, so existing callers passing slices
// of plain structs are unaffected — important because dgman writes generated
// UIDs back through the original backing array, which rebuilding would break.
//
// When wrappers are present it builds a fresh slice of inner records: a typed
// []T when every inner record shares one concrete type (the common batch case,
// which dgman handles exactly as a directly-passed slice), or []any when the
// inner types differ.
func unwrapSchemaSlice(v reflect.Value, obj any) any {
n := v.Len()
if n == 0 {
return obj
}
unwrapped := make([]any, n)
changed := false
homogeneous := true
var elemType reflect.Type
for i := range n {
e := v.Index(i).Interface()
u := UnwrapSchema(e)
unwrapped[i] = u
ut := reflect.TypeOf(u)
if ut != reflect.TypeOf(e) {
changed = true
}
switch {
case ut == nil:
homogeneous = false
case i == 0:
elemType = ut
case ut != elemType:
homogeneous = false
}
}
if !changed {
return obj
}
if homogeneous && elemType != nil {
out := reflect.MakeSlice(reflect.SliceOf(elemType), n, n)
for i := range n {
out.Index(i).Set(reflect.ValueOf(unwrapped[i]))
}
return out.Interface()
}
return unwrapped
}
49 changes: 49 additions & 0 deletions record_example_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc.
* SPDX-License-Identifier: Apache-2.0
*/

package modusgraph_test

import (
"context"
"fmt"

mg "github.com/matthewmcneely/modusgraph"
)

// Actor is a schema-defining record. Implementing mg.Schema (a single
// SchemaTypeName method) marks it as a generated schema type; code generators
// such as modusgraph-gen emit this method.
type Actor struct {
UID string `json:"uid,omitempty"`
DType []string `json:"dgraph.type,omitempty"`
Name string `json:"name,omitempty" dgraph:"index=exact"`
}

func (a *Actor) SchemaTypeName() string { return "Actor" }

// ActorBuilder is a wrapper around Actor — the shape a generated fluent builder
// or domain wrapper takes. Exposing Unwrap lets the modusgraph client route the
// wrapper to its backing record, so the wrapper can be passed straight to
// Insert/Update/Get without the caller reaching for the inner value.
type ActorBuilder struct{ actor *Actor }

func (b *ActorBuilder) Unwrap() *Actor { return b.actor }

// ExampleSchema shows the wrapper pattern: the client unwraps an ActorBuilder
// to its Actor before persisting, so generated wrapper types work transparently
// while plain structs are unaffected.
func ExampleSchema() {
client, _ := mg.NewClient("dgraph://localhost:9080")
defer client.Close()

ctx := context.Background()
builder := &ActorBuilder{actor: &Actor{Name: "Sigourney Weaver"}}

// Insert the wrapper; the client unwraps it to the Actor record.
if err := client.Insert(ctx, builder); err != nil {
panic(err)
}
fmt.Println(builder.actor.Name)
}
81 changes: 81 additions & 0 deletions record_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc.
* SPDX-License-Identifier: Apache-2.0
*/

package modusgraph_test

import (
"context"
"testing"

mg "github.com/matthewmcneely/modusgraph"
"github.com/stretchr/testify/require"
)

// studioRecord is a schema-defining record (implements mg.Schema). studioWrapper
// wraps it and exposes Unwrap, exactly as a modusgraph-gen wrapper would.
type studioRecord struct {
UID string `json:"uid,omitempty"`
DType []string `json:"dgraph.type,omitempty"`
Name string `json:"name,omitempty" dgraph:"index=exact"`
}

func (s *studioRecord) SchemaTypeName() string { return "studioRecord" }

type studioWrapper struct{ inner *studioRecord }

func (w *studioWrapper) Unwrap() *studioRecord { return w.inner }

// TestClientUnwrapsWrapperThroughRealMutation exercises the real client path,
// not UnwrapSchema in isolation: it inserts a wrapper and reads it back. If a
// mutation method stopped calling UnwrapSchema, the wrapper (which has no usable
// dgraph fields of its own) would not persist Name and the inner UID would stay
// empty — so this test fails on that regression.
func TestClientUnwrapsWrapperThroughRealMutation(t *testing.T) {
client, err := mg.NewClient("file://"+GetTempDir(t), mg.WithAutoSchema(true))
require.NoError(t, err)
defer client.Close()

ctx := context.Background()
inner := &studioRecord{Name: "Acme"}
wrapper := &studioWrapper{inner: inner}

require.NoError(t, client.Insert(ctx, wrapper))
require.NotEmpty(t, inner.UID,
"Insert did not route the wrapper to its inner record")

var got studioRecord
require.NoError(t, client.Get(ctx, &got, inner.UID))
require.Equal(t, "Acme", got.Name)
}

// TestClientUnwrapsWrapperSliceThroughRealMutation covers the batch path:
// Insert accepts "an object or slice of objects", so a []*wrapper must have
// each element unwrapped. Before UnwrapSchema mapped over slices, dgman
// reflected over the wrappers and failed with "cannot set uid/", persisting
// nothing; now each inner record is inserted and receives a UID.
func TestClientUnwrapsWrapperSliceThroughRealMutation(t *testing.T) {
client, err := mg.NewClient("file://"+GetTempDir(t), mg.WithAutoSchema(true))
require.NoError(t, err)
defer client.Close()

ctx := context.Background()
acme := &studioRecord{Name: "Acme"}
globex := &studioRecord{Name: "Globex"}
batch := []*studioWrapper{{inner: acme}, {inner: globex}}

require.NoError(t, client.Insert(ctx, batch))
require.NotEmpty(t, acme.UID,
"Insert did not route the first wrapper to its inner record")
require.NotEmpty(t, globex.UID,
"Insert did not route the second wrapper to its inner record")
require.NotEqual(t, acme.UID, globex.UID,
"batch elements should receive distinct UIDs")

var gotAcme, gotGlobex studioRecord
require.NoError(t, client.Get(ctx, &gotAcme, acme.UID))
require.NoError(t, client.Get(ctx, &gotGlobex, globex.UID))
require.Equal(t, "Acme", gotAcme.Name)
require.Equal(t, "Globex", gotGlobex.Name)
}
Loading
Loading