forked from hypermodeinc/modusGraph
-
Notifications
You must be signed in to change notification settings - Fork 1
feat: recognize generated schema types (SchemaTypeName + UnwrapSchema) #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
matthewmcneely
merged 3 commits into
matthewmcneely:main
from
mlwelles:feature/schema-routing
Jul 1, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
b74cec7
feat: recognize generated schema types via SchemaTypeName + UnwrapSchema
mlwelles 9bc4289
fix(schema): guard nil wrappers and pointer-receiver Unwrap in Unwrap…
mlwelles 7c5fccd
fix(schema): unwrap wrapper slices in UnwrapSchema; add record headers
mlwelles File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
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") | ||
|
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 { | ||
|
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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.