Skip to content

feat(typed): generic, type-safe client and query builder#17

Open
mlwelles wants to merge 1 commit into
matthewmcneely:mainfrom
mlwelles:feature/typed-client
Open

feat(typed): generic, type-safe client and query builder#17
mlwelles wants to merge 1 commit into
matthewmcneely:mainfrom
mlwelles:feature/typed-client

Conversation

@mlwelles

@mlwelles mlwelles commented Jun 4, 2026

Copy link
Copy Markdown

Adds a generic typed layer over modusgraph.Client:

  • typed.Client[T] — CRUD + iterators over a schema struct T.
  • typed.Query[T] — fluent builder: filters, ordering, paging, edge traversal (WhereEdge), IterNodes auto-paging iterator.
  • MultiQuery — N homogeneous-type blocks in one round-trip.
  • typed/filter — a small filter DSL (incl. fulltext); typed/search — ordered merge/dedup of result sets.
  • Functional options; a no-op-by-default Tracer seam (typed.SetTracer) so hosts can plug in tracing without the typed package depending on any telemetry library.

Self-contained: the diff is entirely under typed/ — it builds and go tests green against the current client with no changes to the root package.

Part of a series upstreaming work from a downstream fork; opened for review/discussion.


Summary by cubic

Adds a generic typed layer over modusgraph.Client for type-safe CRUD and fluent queries, plus batched multi-block queries, small filter/search helpers, and an optional tracing seam. All code is new under typed/ and builds green against the current client.

  • New Features

    • typed.Client[T]: type-safe CRUD and Iter with auto-paging.
    • typed.Query[T]: fluent filters and OR groups, order/limit/offset/after, WhereEdge (edge-target filters via pre-pass), Nodes/First/IterNodes (consistent snapshot), NodesAndCount, UID, All, raw access, As/Var/GroupBy, Vars, Name.
    • typed.MultiQuery[T]: batch N same-type blocks in one round-trip; remaps predicate→JSON tags; rejects WhereEdge.
    • typed/filter: small DSL (EqGroup*, RequiredEq, AnyOfText/AllOfText) for building @filter clauses.
    • typed/search: MergeByID to merge and dedup ordered result sets.
    • Functional options: typed.Option and typed.Apply.
    • Tracing seam: typed.SetTracer installs a process-wide tracer; default is no-op.
  • Migration

    • No breaking changes; everything is new under typed/.
    • Optional: install a tracer with typed.SetTracer to emit spans.

Written for commit e9028a1. Summary will update on new commits.

Review in cubic

Add a generic typed layer over modusgraph.Client: typed.Client[T] with
CRUD and iterators; a fluent Query[T] builder (filters, ordering, paging,
edge traversal, IterNodes); MultiQuery for N homogeneous blocks in one
round-trip; functional options; a filter DSL (typed/filter); and ordered
result merging (typed/search).

A small no-op-by-default Tracer seam (typed.SetTracer) lets a host plug in
tracing without the typed package depending on any telemetry library.

Self-contained: builds and tests against the current client with no other
changes.
@mlwelles mlwelles requested a review from matthewmcneely as a code owner June 4, 2026 17:07

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 16 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="typed/filter/filter_test.go">

<violation number="1" location="typed/filter/filter_test.go:115">
P2: `TestBuilder_PositionalParamsAreSequential` uses weak substring checks that can produce false positives and fail to catch broken placeholder sequencing.</violation>
</file>

<file name="typed/multi_query.go">

<violation number="1" location="typed/multi_query.go:85">
P1: In-place mutation of shared Query[T] pointers during Execute can corrupt multi-block composition when the same query instance is added under multiple names or reused elsewhere.</violation>
</file>

<file name="typed/query.go">

<violation number="1" location="typed/query.go:104">
P1: AND-combining filter fragments without per-fragment parentheses changes logical precedence and can return incorrect query results.</violation>

<violation number="2" location="typed/query.go:486">
P1: WhereEdge pre-pass silently drops user-provided RootFunc/UID root constraints by unconditionally overwriting qb.q.RootFunc() with pre-pass UIDs, while the pre-pass itself starts from a fresh query that ignores the user's existing root narrowing.</violation>
</file>

<file name="typed/query_test.go">

<violation number="1" location="typed/query_test.go:795">
P2: Test uses absolute query count instead of delta-based assertion for laziness verification</violation>
</file>

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Re-trigger cubic

Comment thread typed/multi_query.go
}
// Name the underlying dgman query so blocks do not collide on the
// default "data" name and so the response JSON keys are predictable.
block.q.Name(name)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: In-place mutation of shared Query[T] pointers during Execute can corrupt multi-block composition when the same query instance is added under multiple names or reused elsewhere.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At typed/multi_query.go, line 85:

<comment>In-place mutation of shared Query[T] pointers during Execute can corrupt multi-block composition when the same query instance is added under multiple names or reused elsewhere.</comment>

<file context>
@@ -0,0 +1,191 @@
+		}
+		// Name the underlying dgman query so blocks do not collide on the
+		// default "data" name and so the response JSON keys are predictable.
+		block.q.Name(name)
+		rawBlocks = append(rawBlocks, block.q)
+	}
</file context>

Comment thread typed/query.go
if len(uids) == 0 {
return false, nil
}
qb.q.RootFunc("uid(" + strings.Join(uids, ", ") + ")")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: WhereEdge pre-pass silently drops user-provided RootFunc/UID root constraints by unconditionally overwriting qb.q.RootFunc() with pre-pass UIDs, while the pre-pass itself starts from a fresh query that ignores the user's existing root narrowing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At typed/query.go, line 486:

<comment>WhereEdge pre-pass silently drops user-provided RootFunc/UID root constraints by unconditionally overwriting qb.q.RootFunc() with pre-pass UIDs, while the pre-pass itself starts from a fresh query that ignores the user's existing root narrowing.</comment>

<file context>
@@ -0,0 +1,565 @@
+	if len(uids) == 0 {
+		return false, nil
+	}
+	qb.q.RootFunc("uid(" + strings.Join(uids, ", ") + ")")
+	return true, nil
+}
</file context>

Comment thread typed/query.go
if f.expr == "" {
continue
}
parts = append(parts, shiftPlaceholders(f.expr, len(params)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: AND-combining filter fragments without per-fragment parentheses changes logical precedence and can return incorrect query results.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At typed/query.go, line 104:

<comment>AND-combining filter fragments without per-fragment parentheses changes logical precedence and can return incorrect query results.</comment>

<file context>
@@ -0,0 +1,565 @@
+		if f.expr == "" {
+			continue
+		}
+		parts = append(parts, shiftPlaceholders(f.expr, len(params)))
+		params = append(params, f.params...)
+	}
</file context>
Suggested change
parts = append(parts, shiftPlaceholders(f.expr, len(params)))
parts = append(parts, "("+shiftPlaceholders(f.expr, len(params))+")")

b.EqGroupUUID("id", []filter.UUID{{Value: "a"}, {Value: "b"}})
b.EqGroupString("name", []filter.String{{Value: "c"}})
expr, _ := b.Build()
if !strings.Contains(expr, "$1") || !strings.Contains(expr, "$2") || !strings.Contains(expr, "$3") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: TestBuilder_PositionalParamsAreSequential uses weak substring checks that can produce false positives and fail to catch broken placeholder sequencing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At typed/filter/filter_test.go, line 115:

<comment>`TestBuilder_PositionalParamsAreSequential` uses weak substring checks that can produce false positives and fail to catch broken placeholder sequencing.</comment>

<file context>
@@ -0,0 +1,118 @@
+	b.EqGroupUUID("id", []filter.UUID{{Value: "a"}, {Value: "b"}})
+	b.EqGroupString("name", []filter.String{{Value: "c"}})
+	expr, _ := b.Build()
+	if !strings.Contains(expr, "$1") || !strings.Contains(expr, "$2") || !strings.Contains(expr, "$3") {
+		t.Errorf("expected $1, $2, $3 in expr; got %q", expr)
+	}
</file context>

Comment thread typed/query_test.go
}
}
// Obtaining the iterator runs no query — IterNodes is lazy.
seq := c.Query(ctx).IterNodes()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Test uses absolute query count instead of delta-based assertion for laziness verification

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At typed/query_test.go, line 795:

<comment>Test uses absolute query count instead of delta-based assertion for laziness verification</comment>

<file context>
@@ -0,0 +1,1294 @@
+		}
+	}
+	// Obtaining the iterator runs no query — IterNodes is lazy.
+	seq := c.Query(ctx).IterNodes()
+	if queriesExecuted != 0 {
+		t.Fatalf("building the IterNodes iterator executed %d queries, want 0", queriesExecuted)
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant