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).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
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.
|
@mlwelles Also, can you update the README with the major feature additions of this PR? |
…n MultiQuery MultiQuery.Execute remapped only top-level predicate keys, so a renamed predicate (dgraph predicate= diverging from json=) on a nested edge struct silently decoded as empty. Make the remap type-driven and recursive, mirroring dgman's QueryBlock.Scan, so nested edges decode at every depth. Also propagate the top-level remap error instead of swallowing it, so a malformed block surfaces its root cause rather than a generic downstream decode error. Addresses review feedback on multi_query.go (nested-predicate remap gap and swallowed remap error).
…e tracer WhereEdge ran an eager client-side pre-pass that pulled every matching root UID into the client and inlined them as uid(0x1, ..., 0xN) in the main query — unbounded in memory and DQL size on a high-cardinality match, and eager even under IterNodes. Replace it with a single multi-block request: a var block binds the matched roots server-side (mgMatched as var(func: ...) @cascade {...}) and the data block roots at uid(mgMatched). NodesAndCount adds a count block over the same var; the var block roots at the caller's UID/RootFunc when present, so a custom root intersects the match rather than being overwritten. The matched UIDs never leave the server, so memory and query size stay bounded, and IterNodes re-resolves the var per page. This mirrors the var-block shape dgman's own NodesAndCount uses internally. Also make the process-wide typed tracer race-safe: hold it in an atomic.Pointer so SetTracer and the per-operation reads in currentTracer no longer race. Addresses review feedback on query.go (unbounded WhereEdge pre-pass) and the unsynchronized global tracer.
…de var block The WhereEdge doc comment referenced docs/specs/2026-05-21-query-edge-filter-design.md, but the file was never committed on this branch. Add it, and revise its execution narrative from the originally-drafted client-side two-step to the server-side var block that ships: the matched UIDs stay off the client, and the QueryRaw two-block path the draft rejected is in fact viable because dgman renders the data block's projection (reverse-edge-aware), so nothing is hand-written. Addresses review feedback on the missing design doc.
There was a problem hiding this comment.
1 issue found across 9 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
trunk fmt (prettier) reflow, code-block tabs to spaces (MD010), and de-nested backticks in the DQL-example caption (MD038). trunk check is clean.
The WhereEdge path (runEdge) renders its own multi-block request and ran it via QueryRaw with a nil variable map, so a query built with .Vars(...) lost its GraphQL named variables -- a regression from the pre-var-block path, which ran qb.q.Nodes() and forwarded them via QueryWithVars. Store the Vars funcDef and map on the typed Query and forward them to the QueryBlock (so the "query <funcDef>" declaration renders) and to QueryRaw (so they bind). Tests: TestQuery_WhereEdgeForwardsVars (Vars + WhereEdge, verified to fail with "Variable not defined $n" without the fix); TestRemapPredicateKeys_SurfacesMalformedBlock (guards the earlier swallowed-remap-error fix in MultiQuery.Execute). Addresses cubic review feedback (P1: WhereEdge drops Vars).
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.