Skip to content
Open
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
87 changes: 87 additions & 0 deletions typed/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc.
* SPDX-License-Identifier: Apache-2.0
*/

// Package typed binds a Go type to the otherwise any-typed modusgraph.Client,
// providing generic, type-safe CRUD and query operations without per-entity
// code generation. It is the handwritten substrate that modusgraph-gen's
// generated clients compose over.
package typed

import (
"context"
"iter"

"github.com/matthewmcneely/modusgraph"
)

// Client provides type-safe CRUD and query operations over records of type T.
// T is the schema struct (for example schema.Actor); modusgraph reflects over
// the struct's dgraph/json tags, so T needs no constraint.
type Client[T any] struct {
conn modusgraph.Client
}

// NewClient binds a Client[T] to conn.
func NewClient[T any](conn modusgraph.Client) *Client[T] {
return &Client[T]{conn: conn}
}

// Get loads the T with the given UID.
func (c *Client[T]) Get(ctx context.Context, uid string) (rec *T, err error) {
ctx, span := tracer.StartSpan(ctx, "get", entityName[T]())
defer func() { span.End(err) }()
var out T
if err = c.conn.Get(ctx, &out, uid); err != nil {
return nil, err
}
return &out, nil
}

// Add inserts a new T. modusgraph writes the assigned UID back into rec.
func (c *Client[T]) Add(ctx context.Context, rec *T) (err error) {
ctx, span := tracer.StartSpan(ctx, "add", entityName[T]())
defer func() { span.End(err) }()
return c.conn.Insert(ctx, rec)
}

// Update modifies an existing T (must have its UID set).
func (c *Client[T]) Update(ctx context.Context, rec *T) (err error) {
ctx, span := tracer.StartSpan(ctx, "update", entityName[T]())
defer func() { span.End(err) }()
return c.conn.Update(ctx, rec)
}

// Upsert inserts or updates rec, matching against predicates. With no
// predicates, the first field tagged dgraph:"upsert" is used.
func (c *Client[T]) Upsert(ctx context.Context, rec *T, predicates ...string) (err error) {
ctx, span := tracer.StartSpan(ctx, "upsert", entityName[T]())
defer func() { span.End(err) }()
return c.conn.Upsert(ctx, rec, predicates...)
}

// Delete removes the T with the given UID.
func (c *Client[T]) Delete(ctx context.Context, uid string) (err error) {
ctx, span := tracer.StartSpan(ctx, "delete", entityName[T]())
defer func() { span.End(err) }()
return c.conn.Delete(ctx, []string{uid})
}

// 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), conn: c.conn, ctx: ctx}
}

// 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. 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 c.Query(ctx).IterNodes()
}
209 changes: 209 additions & 0 deletions typed/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/*
* SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc.
* SPDX-License-Identifier: Apache-2.0
*/

package typed_test

import (
"context"
"testing"

"github.com/matthewmcneely/modusgraph"
"github.com/matthewmcneely/modusgraph/typed"
)

// widget is a minimal schema struct used to exercise the typed package.
type widget struct {
UID string `json:"uid,omitempty"`
DType []string `json:"dgraph.type,omitempty"`
Name string `json:"name,omitempty" dgraph:"index=exact"`
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()
conn, err := modusgraph.NewClient("file://"+t.TempDir(), modusgraph.WithAutoSchema(true))
if err != nil {
t.Fatalf("modusgraph.NewClient: %v", err)
}
t.Cleanup(conn.Close)
return conn
}

func TestClient_AddPopulatesUIDAndGetReadsBack(t *testing.T) {
ctx := context.Background()
c := typed.NewClient[widget](newConn(t))

w := &widget{Name: "sprocket", Qty: 3}
if err := c.Add(ctx, w); err != nil {
t.Fatalf("Add: %v", err)
}
if w.UID == "" {
t.Fatal("Add did not populate UID on the passed struct")
}

got, err := c.Get(ctx, w.UID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.Name != "sprocket" || got.Qty != 3 {
t.Fatalf("Get returned %+v, want Name=sprocket Qty=3", got)
}
}

func TestClient_Update(t *testing.T) {
ctx := context.Background()
c := typed.NewClient[widget](newConn(t))

w := &widget{Name: "gear", Qty: 1}
if err := c.Add(ctx, w); err != nil {
t.Fatalf("Add: %v", err)
}
w.Qty = 99
if err := c.Update(ctx, w); err != nil {
t.Fatalf("Update: %v", err)
}

got, err := c.Get(ctx, w.UID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.Qty != 99 {
t.Fatalf("Update did not persist; Qty = %d, want 99", got.Qty)
}
}

func TestClient_Delete(t *testing.T) {
ctx := context.Background()
c := typed.NewClient[widget](newConn(t))

w := &widget{Name: "bolt"}
if err := c.Add(ctx, w); err != nil {
t.Fatalf("Add: %v", err)
}
if err := c.Delete(ctx, w.UID); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := c.Get(ctx, w.UID); err == nil {
t.Fatal("Get after Delete returned no error; expected not-found")
}
}

func TestClient_IterPagesThroughAllRecords(t *testing.T) {
ctx := context.Background()
c := typed.NewClient[widget](newConn(t))

// 125 is deliberately larger than the package's 50-record page size, so
// a correct Iter must fetch more than one page.
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 w, err := range c.Iter(ctx) {
if err != nil {
t.Fatalf("Iter yielded error: %v", err)
}
if w == nil {
t.Fatal("Iter yielded a nil widget")
}
seen++
}
if seen != n {
t.Fatalf("Iter yielded %d records, want %d", seen, n)
}
}

// gadget is a dedicated upsert struct. It must not be the shared widget, because
// widget is used in tests that insert many records with duplicate Name values;
// adding a "upsert" directive to widget.Name would cause those inserts to
// collide and break unrelated tests.
type gadget struct {
UID string `json:"uid,omitempty"`
DType []string `json:"dgraph.type,omitempty"`
Label string `json:"label,omitempty" dgraph:"index=exact upsert"`
Stock int `json:"stock,omitempty" dgraph:"index=int"`
}

func TestClient_Upsert(t *testing.T) {
ctx := context.Background()
c := typed.NewClient[gadget](newConn(t))

// First call — creates the record.
g := &gadget{Label: "sprocket", Stock: 10}
if err := c.Upsert(ctx, g, "label"); err != nil {
t.Fatalf("Upsert (create): %v", err)
}
if g.UID == "" {
t.Fatal("Upsert (create) did not populate UID")
}

// Second call — same Label value, different Stock. Must UPDATE, not insert.
g2 := &gadget{Label: "sprocket", Stock: 99}
if err := c.Upsert(ctx, g2, "label"); err != nil {
t.Fatalf("Upsert (update): %v", err)
}

// Exactly one record must exist and it must carry the updated Stock.
nodes, err := c.Query(ctx).Nodes()
if err != nil {
t.Fatalf("Query after Upsert: %v", err)
}
if len(nodes) != 1 {
t.Fatalf("got %d gadgets after two upserts on the same label, want 1", len(nodes))
}
if nodes[0].Stock != 99 {
t.Fatalf("upserted gadget Stock = %d, want 99", nodes[0].Stock)
}
}

func TestClient_IterStopsOnConsumerBreak(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 w, err := range c.Iter(ctx) {
if err != nil {
t.Fatalf("Iter yielded error: %v", err)
}
if w == nil {
t.Fatal("Iter yielded a nil widget")
}
seen++
if seen == 10 {
break
}
}
if seen != 10 {
t.Fatalf("Iter yielded %d records after break at 10, want 10", seen)
}
}
Loading
Loading