Skip to content

feat: support @include/@skip directives and a pluggable generation lifecycle#68

Merged
fahchen merged 6 commits into
mainfrom
test/directive-support
May 23, 2026
Merged

feat: support @include/@skip directives and a pluggable generation lifecycle#68
fahchen merged 6 commits into
mainfrom
test/directive-support

Conversation

@fahchen

@fahchen fahchen commented May 22, 2026

Copy link
Copy Markdown
Owner

Summary

  • Conditionally-selected response fields (@include/@skip, or fields under a conditional fragment) are now generated nullable — the server may omit them even when the schema says non-null.
  • New pluggable generation lifecycle (Grephql.Generation.Plugin; steps normalize → resolve → lower → create with after_<step> hooks). @include/@skip ships as the built-in SkipInclude; users register their own via :generation_plugins on use Grephql.
  • Validator now checks variables used in directive arguments (@include(if: $var)).

Design notes

  • Reuses existing structures (TypeMapper.resolve map → flat Grephql.Generation.FieldGrephql.Generation.Schema tree), not a new descriptor IR.
  • Plugins run before EctoTypedSchema lowering, so they can change the Ecto field type without desyncing the generated @type.
  • Untyped inline fragments on a union/interface parent are hoisted into the shared selection, not treated as a variant.

Testing

mix precommit green — 654 tests, 0 failures; credo --strict clean; dialyzer 0 errors. Covers @include/@skip on fields, inline fragments, and fragment spreads; no-op literals; union propagation; the untyped-fragment edge case. Nullability is asserted at the IR level, plus one end-to-end test on the generated @type (id: String.t() | nil, via a compiled fixture) and plugin wiring via an observable field rename.

🤖 Generated with Claude Code

…ifecycle

Conditionally-selected response fields (via @include/@Skip, or fields under a
conditional inline fragment / fragment spread) are now generated as nullable,
since the server may omit them even when the schema marks them non-null.

- Add a generation lifecycle (Grephql.Generation.Plugin) with named steps
  normalize -> resolve -> lower -> create and after_<step> hooks. @include and
  @Skip ship as the built-in SkipInclude plugin; users register custom plugins
  via the :generation_plugins option on `use Grephql`.
- Validate variables used in directive arguments.
- Cover directive parsing, validation, and generation in tests and the
  integration schema fixture.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Copilot AI 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.

Pull request overview

Adds a generation lifecycle/plugin system to response type generation and uses it to correctly model conditional field selection via GraphQL @include/@skip as nullable in generated result types, plus extends validation to cover directive-argument variables.

Changes:

  • Introduces Grephql.Generation.Plugin lifecycle (normalize → resolve → lower → create) with built-in SkipInclude and user-configurable :generation_plugins.
  • Updates type generation to build a Grephql.Generation.Schema tree, run lifecycle hooks, and lower/compile modules from that tree.
  • Extends variable validation to treat variables referenced in directive arguments as “used” and type-check them against directive arg definitions.

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
test/support/schemas/integration.json Adds skip/include directives to the integration schema fixture.
test/integration_test.exs Adds integration coverage for @include decoding omitted fields as nil.
test/grephql/validator/rules/variables_test.exs Adds tests for directive-argument variable usage and type mismatch.
test/grephql/validator/rules/directives_test.exs Adds tests for operation-level directives on query/mutation.
test/grephql/validator_test.exs Adds end-to-end validator tests for operation-level directives.
test/grephql/type_generator_test.exs Adds coverage for module ordering and object-parent inline fragment flattening.
test/grephql/schema/parser_test.exs Extends schema directive parsing coverage (QUERY/MUTATION locations).
test/grephql/printer_test.exs Adds printer roundtrip tests for operation directives.
test/grephql/parser_test.exs Adds parsing tests for query/mutation operation directives.
test/grephql/generation/skip_include_test.exs Adds comprehensive generation/plugin tests for @include/@skip nullability behavior.
test/grephql/generation/schema_test.exs Adds tests for Generation.Schema.map_fields/2 traversal behavior.
test/grephql/generation/plugin_test.exs Adds tests for plugin default identity callbacks and overriding.
test/grephql/client_module_test.exs Verifies use Grephql can inject :generation_plugins into generation pipeline.
lib/grephql/validator/rules/variables.ex Collects/type-checks variables used in directive arguments across selections.
lib/grephql/type_generator.ex Refactors generation into normalize/resolve/lower/create pipeline with plugin hooks.
lib/grephql/macros.ex Plumbs @grephql_generation_plugins into compiler invocation.
lib/grephql/generation/schema.ex Introduces the generation tree node struct + map_fields/2.
lib/grephql/generation/plugins/skip_include.ex Adds built-in plugin to mark conditional fields nullable.
lib/grephql/generation/plugin.ex Defines the generation plugin behaviour and default callback implementations.
lib/grephql/generation/field.ex Introduces generation-time field struct and nullability mutation helper.
lib/grephql/generation/context.ex Adds plugin callback context struct (schema, scalar types, fragments).
lib/grephql/compiler.ex Passes :generation_plugins into type/fragment generation.
lib/grephql.ex Adds public :generation_plugins option to use Grephql.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/grephql/type_generator.ex
fahchen and others added 4 commits May 23, 2026 09:05
…arents

An inline fragment without a type condition is valid GraphQL but raised at
generation time on a union/interface parent, since normalize_union_selections/3
(and resolve_union/4) dereferenced a nil type_condition. Untyped inline
fragments now hoist their members — directives propagated — into the shared
selection rather than being treated as a variant.

Addresses Copilot review feedback on #68.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The "legacy path" / "old collect_selections" the comments compared against is
deleted by this PR, so the references only confuse a future reader. Kept the
load-bearing WHY (rebuilding from resolved so plugin mutations flow through).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Exercise before_normalize / after_normalize / after_lower through a real
  generate run (only after_resolve was covered in-pipeline before).
- Cover the fragment-level no-op literal case (@include(if: true) on an inline
  fragment leaves inner fields non-null).
- Make the generation_plugins end-to-end test assert an observable effect (the
  plugin drops a field) so it fails if the option is not wired through, rather
  than only proving generation does not break.
- Drop a duplicate assertion and fix a comment that described a missing check;
  simplify a nested Map.fetch! chain.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Forced nullability is invisible at runtime (Code.Typespec.fetch_types/1
returns :error for Module.create'd generated modules), so the plugin
extensibility tests now demonstrate an observable rename (:name ->
:display_name, sourced from the original key) instead.

Also fixes the skip_include test helper: the capture plugin ran before user
plugins, so it never observed their effect — the previous force-nullable
assertion passed only because the field was schema-nullable anyway.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@fahchen fahchen changed the title feat: support @include/@skip directives via a generation lifecycle feat: support @include/@skip directives and a pluggable generation lifecycle May 23, 2026
Checks end-to-end that @include(if: $var) makes a non-null field nullable in
the generated @type (id: String.t() | nil), while an undirected non-null field
stays non-null (name: String.t()).

This is assertable because the fixture is a compiled .ex client, so its
generated modules have .beam files that Code.Typespec.fetch_types/1 can read —
unlike the in-memory modules the other generation tests build at runtime.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@fahchen fahchen merged commit 6a52504 into main May 23, 2026
5 checks passed
@fahchen fahchen deleted the test/directive-support branch May 23, 2026 01:49
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.

2 participants