feat(typed): generic, type-safe client and query builder#17
Conversation
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.
There was a problem hiding this comment.
5 issues found across 16 files
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic
Addresses review feedback on the typed query builder: - combineAnd now parenthesizes each accumulated @filter fragment. Without this, a fragment containing OR ANDed with another fragment rendered as "a OR b AND c", which dgraph parses as "a OR (b AND c)" — silently widening results. dgman exposes only a string Filter(), so the builder must compose the expression itself; this makes that composition correct. - WhereEdge's pre-pass no longer discards a caller-set UID/RootFunc root. When a custom root is present, the matched UIDs are intersected via a uid() @filter instead of overwriting the root. - MultiQuery.Add rejects the same *Query[T] under two names; Execute names the underlying query in place, so reuse would corrupt block composition. - NodesAndCount now opens a tracing span, matching the other terminals. Tests: add a precedence regression, a WhereEdge+UID intersection test, and a duplicate-Query guard test; strengthen the filter-sequencing test to assert the exact expression; make the IterNodes laziness check delta-based. Docs: add package doc.go with a before/after narrative, runnable examples for the client/query builder/MultiQuery, and a verified filter example.
There was a problem hiding this comment.
1 issue found across 9 files (changes from recent commits).
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/doc.go">
<violation number="1" location="typed/doc.go:70">
P2: Global process-wide tracer is unsynchronized; concurrent SetTracer and query execution risk data races and test isolation failures</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| // | ||
| // # Tracing | ||
| // | ||
| // Every terminal opens a span through a process-wide tracer that is a no-op by |
There was a problem hiding this comment.
P2: Global process-wide tracer is unsynchronized; concurrent SetTracer and query execution risk data races and test isolation failures
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At typed/doc.go, line 70:
<comment>Global process-wide tracer is unsynchronized; concurrent SetTracer and query execution risk data races and test isolation failures</comment>
<file context>
@@ -0,0 +1,73 @@
+//
+// # Tracing
+//
+// Every terminal opens a span through a process-wide tracer that is a no-op by
+// default. Install one with SetTracer to emit spans without the typed package
+// depending on any telemetry library.
</file context>
Wrap a >120-char error string in MultiQuery.Execute and a long test helper signature, and pre-allocate two test result slices. Resolves the Trunk golangci-lint findings; no behavior change.
matthewmcneely
left a comment
There was a problem hiding this comment.
Six subpackage files are missing the SPDX header that every other file in the repo carries: filter/filter.go, filter/fulltext.go, filter/filter_test.go, filter/fulltext_test.go, search/merge.go, search/merge_test.go.
| // pre-pass resolves the UIDs of roots that satisfy every constraint, then the | ||
| // main query runs against uid(...) — keeping ordering, pagination, and result | ||
| // projection on the normal path. See | ||
| // docs/specs/2026-05-21-query-edge-filter-design.md. |
There was a problem hiding this comment.
Did you forget to commit this in this branch?
| } | ||
| if len(predMap) > 0 { | ||
| remapped, err := remapArrayKeys(body, predMap) | ||
| if err == nil { |
There was a problem hiding this comment.
This err gets swallowed. The later unmarshal usually surfaces something, but the root cause is lost.
| // matchedUIDs runs the pre-pass: an @cascade query over T that keeps only | ||
| // nodes whose every WhereEdge predicate has a target matching its filter, and | ||
| // returns those nodes' UIDs. | ||
| func (qb *Query[T]) matchedUIDs() ([]string, error) { |
There was a problem hiding this comment.
The WhereEdge pre-pass materializes every matching root UID, unbounded.
matchedUIDs runs the pre-pass with no limit and pulls all matching UIDs into the client (query.go:516-533), then embeds them as uid(uid1, …, uidN) (query.go:504-509). On a high-cardinality edge match that can exhaust memory, overflow the pre-pass gRPC response, and generate an enormous DQL string. That's a sharp edge on a branch built atop the companion WithMaxRecvMsgSize work.
It also undercuts IterNodes. A WhereEdge(...).IterNodes() chain is sold as bounded-memory streaming, but the pre-pass is eager and unbounded before paging even begins.
The idiomatic fix is a server-side var block feeding func: uid(var), which keeps the UIDs off the client entirely. At minimum, document the limitation. The referenced design doc would presumably justify the tradeoff, but it's missing (see the separate note about that).
| return result | ||
| } | ||
|
|
||
| // remapArrayKeys rewrites top-level keys in each object of a JSON array using |
There was a problem hiding this comment.
MultiQuery silently mis-decodes renamed nested predicates, and the comments claiming dgman parity are inaccurate.
remapArrayKeys only remaps top-level keys (multi_query.go:181-200), but dgman's own remapPredicateKeys recurses into nested edge structs (dgman/v2@v2.2.0/query.go:78-145; remapObject recurses). Two comments overstate the parity:
- "matching the behavior of dgman's QueryBlock.Scan path" (
multi_query.go:76-78) - "edge fields are hydrated lazily, not in the multi-block response" (
multi_query.go:183-184)
The second is the load-bearing error. MultiQuery blocks are built from conn.Query(), which applies .All(maxEdgeTraversal) (client.go:585), so the response does carry expanded edges. A type with a renamed predicate (predicate= ≠ json:) on a nested edge populates correctly through the normal Query path but silently leaves that nested field empty through MultiQuery.
Recommend either recursing like dgman, or narrowing the comments to top-level scalar predicates only and documenting the limitation loudly.
|
@mlwelles Also, can you update the README with the major feature additions of this PR? |
What this adds
A generic
typedlayer overmodusgraph.Clientthat binds a Go type to theotherwise
any-typed client, giving you compile-time-typed CRUD and a fluentquery builder with no per-entity code generation. It is the handwritten
substrate
modusgraph-gen's generated clients compose over, and it stands onits own wherever you want types over modusgraph.
The whole diff lives under
typed/. It builds and tests green against thecurrent client with no changes to the root package.
The problem it solves
modusgraph.Clientis value-oriented: its methods take and returnany, and aquery is assembled by hand from dgman primitives and decoded into a slice you
declare at the call site. Every call site repeats the same shape — declare the
destination, build the query, decode, re-assert the type. The typed layer lifts
that shape into the type system once.
Before — untyped client
After — typed client
The same lift applies across the API:
Nodes()returns[]Person,IterNodes()yields*Person,Getreturns*Person.Query builder
Query[T]is fluent: builder methods chain, terminals (Nodes,First,NodesAndCount,IterNodes) execute and decode typed results.fragment containing
ORkeeps its precedence.OrGroupORs several sub-scopes into one parenthesized group:WhereEdgeconstrainsTby a scalar of a neighbour reached over an edge(a root filter cannot express this); resolved by a pre-pass and intersected
with any root you set.
IterNodesstreams arbitrarily large result sets a page at a time over asingle read-only snapshot.
MultiQuerybatches N same-type blocks into one round-trip.On reusing dgman vs. reinventing it
The
Or/OrGroupsupport builds on dgman, it does not reimplement it.dgman exposes a single string-based
Query.Filter(filter string, params ...any)with
$Npositional markers and no AND/OR combinators and no root-intersectionhelpers. So any builder offering
Where/Orcomposition must assemble thefilter string itself and hand it to dgman's
Filter. This layer does exactlythat and no more:
$Nsubstitutionstay in dgman (
Query.Filter,parseQueryWithParams);shiftPlaceholdersonly renumbers$Nto match dgman's own conventionwhen independently-written fragments are concatenated — it does not re-parse
or re-escape values;
with correct precedence) and the type binding.
Review round — changes since first push
Addresses the automated review (cubic). Highlights:
combineAndnow parenthesizes each fragment, soa raw
ORfragment no longer binds incorrectly when ANDed.UID/RootFunc; it intersects via auid()@filterinstead.Addrejects the same*Query[T]under two names(
Executenames the underlying query in place).NodesAndCounttracing: now opens a span like the other terminals.WhereEdge+root intersectiontest, and a duplicate-
Queryguard; the filter-sequencing test now assertsthe exact expression; the laziness check is delta-based.
Documentation
doc.gowith the before/after narrative and the design rationale.OrGroup,WhereEdge,IterNodes, andMultiQuery.filterbuilder.