Skip to content

Replace FieldQuery with Selection, and add a GraphQL server example on it - #691

Open
goccy wants to merge 3 commits into
masterfrom
feat/field-selection
Open

goccy wants to merge 3 commits into
masterfrom
feat/field-selection

Conversation

@goccy

@goccy goccy commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

Summary

FieldQuery is replaced by Selection, which selects the fields of a value as a GraphQL selection set does. examples/graphql builds a GraphQL server on it from scratch and compares it with the server which gqlgen generates, to see whether such a server is worth a product of its own.

Fixes #390.

Breaking changes

Removed: FieldQuery, FieldQueryString, BuildFieldQuery, BuildSubFieldQuery, SetFieldQueryToContext, FieldQueryFromContext.

before after
json.BuildFieldQuery("A", json.BuildSubFieldQuery("B").Fields("C")) json.Select(json.Field("A"), json.Field("B", json.Field("C"))) or json.ParseSelection("A B { C }")
json.MarshalContext(json.SetFieldQueryToContext(ctx, q), v) json.MarshalWithOption(v, json.WithSelection(sel)) (or MarshalContext(ctx, v, json.WithSelection(sel)))
json.FieldQueryFromContext(ctx) in MarshalJSON(ctx) json.SelectionFromContext(ctx)

What a selection does

  • The fields are written in the order of the selection, with their aliases (Field("name").As("displayName")).
  • The selection of a slice, an array, a map or a pointer is the one of its elements (Support dynamic filter query on slice value and map value #390). The selection of an interface value is the one of the value it holds; before this change the value of an interface was filtered by the whole query instead of the query of its place.
  • The fields of a struct embedded as a value are written where they are selected. The fields of a struct embedded by a pointer stay together.
  • FieldOf(name, sel) selects a field by an existing selection, and Select() with no field selects nothing ({}).
  • MarshalJSON(context.Context) gets the selection of its value (SelectionFromContext) and the selected field (SelectedFieldFromContext), with a value given by SelectionField.With, such as the arguments of a GraphQL field.
  • The code compiled for a selection is kept by the selection itself, and is freed with it. The old query cache per type grew without a bound.
  • A call without a selection costs only an inlined flag check. MarshalContext now uses the runtime context's table of recently encoded types, which the field query used to turn off.

examples/graphql

A separate module (it needs Go 1.26 for gqlgen, which is not a dependency of go-json). TestSameResponses checks that both servers write the same bytes for queries with aliases, fragments, a union, @skip / @include, __typename, literal / default / variable arguments and merged fields.

Results (Apple M5, arm64, a loaded machine, so the ratios are what to read; the details are in examples/graphql/README.md):

  • one request at a time: 4x-26x faster than gqlgen, 15x-6500x fewer allocations;
  • requests from all CPUs: 8x-61x faster;
  • resolvers with a 1 ms fetch latency: the same time as gqlgen, with 2 fetches instead of 11. The list resolver batches its elements' fetches, because the whole selection is known before writing starts.

Writing the example showed what a product built on this would still need: resolvers write through a nested MarshalContext, whose output is checked again; unions are selected per value in the resolver; resolver errors stop the whole response instead of producing null plus a path; resolvers run serially and must batch their fetches.

Builds on #692

The engine of the example finds a resolved value by the address of its field, which relies on #692 (merged):
a pointer-receiver marshaler of a pointer-sized type is called with the address of the value.

examples/graphql: resolve phase with concurrency and data loaders

The go-json server is now a small engine (examples/graphql/gql) plus the models and resolvers of the schema. A request is served in two phases:

  1. Resolve: the fields with resolvers are found from the selection level by level over the whole response and resolved concurrently. A data loader batches a level the same way it does with gqlgen, and a batch resolver resolves the whole level with one call, without the data loader's wait.
  2. Write: one MarshalContext with the selection.

TestSameResponses compares five variants (gqlgen with and without dataloadgen, and the engine with batches, with one call per field, and with data loaders), including resolver errors with their paths.

With a 1 ms fetch latency, the engine takes the same time as gqlgen for the same way of fetching (three_levels: 3.9 ms with 3 fetches in batches, vs gqlgen 3.8 ms with 31 fetches or 6.5 ms with data loaders). One request at a time it is 3.9x-17x faster in CPU, with 5x-3600x fewer allocations.

Tests

  • go test ./... with Go 1.25.0, 1.26.2 and 1.27.1, and -race for the selection tests and the example
  • make lint

🤖 Generated with Claude Code

@codecov-commenter

codecov-commenter commented Sep 30, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 93.75000% with 21 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.36%. Comparing base (aac126f) to head (087a3e9).
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #691      +/-   ##
==========================================
+ Coverage   88.11%   88.36%   +0.25%     
==========================================
  Files          84       84              
  Lines       12945    13125     +180     
==========================================
+ Hits        11406    11598     +192     
+ Misses       1539     1527      -12     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

goccy and others added 3 commits October 1, 2026 19:22
…elds to write

FieldQuery is replaced by Selection, which selects the fields of a value as a
selection set of GraphQL does. This is a breaking change of the API:
FieldQuery, FieldQueryString, BuildFieldQuery, BuildSubFieldQuery,
SetFieldQueryToContext and FieldQueryFromContext are removed.

- A selection is made by Select / Field / FieldOf, or parsed from the syntax
  of a GraphQL selection set by ParseSelection, and given by the encode
  option WithSelection instead of a context.
- The fields are written in the order of the selection, with their aliases.
- The selection of a slice, an array, a map or a pointer is the one of its
  elements, and the one of an interface value is the one of the value it
  holds (issue #390). The value of an interface was filtered by the whole
  query before.
- The fields of a struct embedded as a value are written where they are
  selected; the ones of a struct embedded by a pointer stay together.
- MarshalJSON(context.Context) gets the selection of its value by
  SelectionFromContext, and the selected field by SelectedFieldFromContext,
  with the value given by SelectionField.With, such as the arguments of a
  GraphQL field.
- The code compiled for a selection is kept by the selection itself, so it
  is freed with the selection instead of growing a table per type.
- MarshalContext now uses the table of the recent types of the runtime
  context, which a context used to turn off because of the field query.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A GraphQL server written from scratch on json.Selection, and the one which
gqlgen generates for the same schema and data. TestSameResponses checks
that both write the same bytes for queries with aliases, fragments, a
union, @Skip / @include, __typename, arguments by literals, defaults and
variables, and merged fields. The benchmarks compare one request at a time,
requests from all the CPUs, and resolvers fetching from a store with a
latency. README.md has the results and what a product built on it would
still need.

The example is a module of its own, so that gqlgen is not a dependency of
go-json; it needs Go 1.26, as gqlgen does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ata loaders or batches

The go-json server is split into a small GraphQL engine (package gql) and
the models and resolvers of the schema on it. The engine serves a request
in two phases: the fields with resolvers (gql.Field) are found from the
selection level by level over the whole response and resolved
concurrently, and then the response is written by one MarshalContext with
the selection, each field writing its resolved value.

- A data loader gathers the fetches of a level, as with gqlgen.
- A batch resolver resolves the field of all the values of a level by one
  call, without the wait of a data loader.
- A value reached twice by the same selected field is resolved once.
- A resolver error is written as null, with its path and its place in the
  query.

The gqlgen server gets the data loaders of dataloadgen, which gqlgen's
documents recommend, and the follow-schema layout, so that its resolvers
are kept when the code is generated again. The schema gets a field whose
resolver fails, to compare the errors. TestSameResponses compares five
variants (gqlgen with and without data loaders, the engine with batches,
one call per field, and data loaders), and a benchmark measures resolvers
fetching from a store with a latency.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@goccy
goccy force-pushed the feat/field-selection branch from 91dbb39 to 087a3e9 Compare October 1, 2026 10:24

This branch has not been deployed

No deployments
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.

Support dynamic filter query on slice value and map value

2 participants