From cc010ad27206a5d69e6221c75de945df784c9f72 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:29:33 +1200 Subject: [PATCH 01/32] feat: add Client#to_query_string and Query#spread - Client#to_query_string(**kargs, &block): builds full query document from DSL block and returns a String without touching Faraday or HTTP. - Query#spread(fragment_name): emits ...FragmentName in the query string, replacing the deprecated ___Const graphql-client convention. - spec_helper.rb: rescue LoadError on byebug require (Windows Ruby 3.2). - CLAUDE.md: AI agent instructions for this fork. --- CHANGELOG.md | 9 +++ CLAUDE.md | 68 +++++++++++++++++++++ lib/graphlient/client.rb | 6 ++ lib/graphlient/query.rb | 7 +++ spec/graphlient/client_dsl_spec.rb | 97 ++++++++++++++++++++++++++++++ spec/spec_helper.rb | 5 +- 6 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md create mode 100644 spec/graphlient/client_dsl_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f8c545..0b18be2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ * [#113](https://github.com/ashkan18/graphlient/pull/113): Fix CI builds - [@yuki24](https://github.com/yuki24). * [#112](https://github.com/ashkan18/graphlient/pull/112): Update graphql-client github repository links in README - [@th1988](https://github.com/th1988). +### EcoPortal fork additions (2026-06-05, branch: feature/dsl-only-mode) +* `Client#to_query_string(**kargs, &block)`: builds the full query document from the DSL block + and returns a `String` without touching the schema or Faraday HTTP adapter. Enables + DSL-only mode where HTTP dispatch is handled by a separate client - [@rellampec](https://github.com/rellampec). +* `Query#spread(fragment_name)`: emits `...FragmentName` into the query string. Replaces the + `___Const` graphql-client convention for fragment spreads in DSL blocks - [@rellampec](https://github.com/rellampec). +* `spec/spec_helper.rb`: rescue `LoadError` on `byebug` require so specs run on Windows + (x64-mingw-ucrt / Ruby 3.2) where byebug is not installable - [@rellampec](https://github.com/rellampec). + ### 0.8.0 (2024/01/06) * [#110](https://github.com/ashkan18/graphlient/pull/110): Ensure correct Faraday JSON response body parsing with invalid response header - [@taylorthurlow](https://github.com/taylorthurlow). * [#107](https://github.com/ashkan18/graphlient/pull/107): Pass in initialized schema as an option - [@kbaum](https://github.com/kbaum). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..80f3a49 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,68 @@ +# CLAUDE.md — graphlient (EcoPortal fork) + +AI agent instructions for this repository. + +**Cross-cutting architecture context lives in `ecoportal-api-graphql` — see its `CLAUDE.md` and `.claude/` folder for the full dependency map, project history, and shared skills.** + +--- + +## Repository Role + +This is **EcoPortal's fork** of `graphlient` — a friendly Ruby client wrapper around `graphql-client`. `ecoportal-api-graphql` depends on this fork; `Common::GraphQL::Client` inherits from `Graphlient::Client`. + +**Position in chain:** +``` +graphql-client (our fork) + ↓ +graphlient ← THIS REPO (our fork) + ↓ +ecoportal-api-graphql +``` + +**Our fork:** https://github.com/rellampec/graphlient.git +**Upstream:** https://github.com/ashkan18/graphlient.git + +--- + +## Fork Policy + +- Changes made here are **not automatically contributed upstream**. If a fix is general enough to benefit the upstream project, discuss with the developer before opening a PR upstream. +- When pulling upstream changes, check for conflicts with any local modifications first. +- Keep a note in `CHANGELOG.md` (or a fork-specific section) for any changes that diverge from upstream. + +--- + +## Key Folder Layout + +``` +lib/graphlient/ + client.rb Main Graphlient::Client class + adapters/http/ HTTP adapters — faraday_adapter, http_adapter + errors/ Error hierarchy (client, server, execution, timeout, etc.) + extensions/query.rb Query DSL extension + query.rb Query builder + schema.rb Schema introspection and caching +``` + +--- + +## Namespace + +`Graphlient::` — single top-level namespace. + +--- + +## Key Concerns + +- `Graphlient::Client` is the class `ecoportal-api-graphql`'s `Common::GraphQL::Client` inherits from. Its constructor signature, `#execute`, and error classes are the primary integration surface. +- `no_schema:` option (added or expected by our usage) — schema introspection is disabled in EcoPortal's GraphQL client. Verify this is handled correctly after any changes to `schema.rb`. +- Error classes are caught and re-raised by `ecoportal-api-graphql` — changes to the error hierarchy may break upstream error handling. + +--- + +## Running Tests + +```bash +bundle install +bundle exec rspec +``` diff --git a/lib/graphlient/client.rb b/lib/graphlient/client.rb index 16d618d..8dd9b56 100644 --- a/lib/graphlient/client.rb +++ b/lib/graphlient/client.rb @@ -20,6 +20,12 @@ def parse(query_str = nil, &block) raise Graphlient::Errors::ClientError, e.message end + def to_query_string(**_kargs, &block) + Graphlient::Query.new do + instance_eval(&block) + end.to_s + end + def execute(query, variables = nil) query_params = {} query_params[:context] = @options if @options diff --git a/lib/graphlient/query.rb b/lib/graphlient/query.rb index 6735954..eb2fc2e 100644 --- a/lib/graphlient/query.rb +++ b/lib/graphlient/query.rb @@ -39,6 +39,13 @@ def to_s query_str.strip end + # Appends a named fragment spread (`...fragment_name`) to the query string. + # Use instead of the deprecated `___Const` convention. + def spread(fragment_name) + @query_str << "\n#{indent}...#{fragment_name}" + @query_str << "\n#{indent}" + end + private def evaluate(&block) diff --git a/spec/graphlient/client_dsl_spec.rb b/spec/graphlient/client_dsl_spec.rb new file mode 100644 index 0000000..2602d95 --- /dev/null +++ b/spec/graphlient/client_dsl_spec.rb @@ -0,0 +1,97 @@ +require 'spec_helper' + +describe Graphlient::Client do + let(:client) { described_class.new('http://example.com/graphql') } + + describe '#to_query_string' do + it 'returns a String' do + result = client.to_query_string do + query do + invoice(id: 10) do + id + feeInCents + end + end + end + expect(result).to be_a String + end + + it 'builds a simple query without HTTP or schema access' do + result = client.to_query_string do + query do + invoice(id: 10) do + id + feeInCents + end + end + end + expect(result).to include('query') + expect(result).to include('invoice') + expect(result).to include('feeInCents') + end + + it 'builds a parameterized query with variable declarations' do + result = client.to_query_string do + query(id: :int) do + invoice(id: :id) do + id + feeInCents + end + end + end + expect(result).to include('$id: Int') + expect(result).to include('invoice(id: $id)') + end + + it 'builds a mutation query string' do + result = client.to_query_string do + mutation(input: :CreateInvoiceInput!) do + createInvoice(input: :input) do + invoice do + id + end + errors + end + end + end + expect(result).to include('mutation') + expect(result).to include('createInvoice') + expect(result).to include('$input: CreateInvoiceInput!') + end + + it 'does not interact with the HTTP adapter' do + expect_any_instance_of(Graphlient::Adapters::HTTP::FaradayAdapter).not_to receive(:execute) + client.to_query_string do + query do + invoice(id: 1) { id } + end + end + end + + context 'with spread helper' do + it 'includes a named fragment spread in the query string' do + result = client.to_query_string do + query do + invoice(id: 10) do + spread :InvoiceFields + end + end + end + expect(result).to include('...InvoiceFields') + end + + it 'supports multiple spreads at the same level' do + result = client.to_query_string do + query do + nodes { + spread :FragmentA + spread :FragmentB + } + end + end + expect(result).to include('...FragmentA') + expect(result).to include('...FragmentB') + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e73d719..b04284b 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,7 +3,10 @@ require 'rubygems' require 'rspec' require 'graphlient' -require 'byebug' if RUBY_ENGINE != 'jruby' +begin + require 'byebug' if RUBY_ENGINE != 'jruby' +rescue LoadError # byebug not available on all platforms (e.g. Windows x64-mingw-ucrt) +end require 'rack/test' require 'webmock/rspec' require 'vcr' From 8a5629929a30026dad070a9a1dd5a6f01a49bb30 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:19:43 +1200 Subject: [PATCH 02/32] docs: move changelog entries to (Next) section, remove org-name header --- CHANGELOG.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b18be2..050fbd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,14 @@ * Your contribution here. * [#113](https://github.com/ashkan18/graphlient/pull/113): Fix CI builds - [@yuki24](https://github.com/yuki24). * [#112](https://github.com/ashkan18/graphlient/pull/112): Update graphql-client github repository links in README - [@th1988](https://github.com/th1988). - -### EcoPortal fork additions (2026-06-05, branch: feature/dsl-only-mode) -* `Client#to_query_string(**kargs, &block)`: builds the full query document from the DSL block - and returns a `String` without touching the schema or Faraday HTTP adapter. Enables - DSL-only mode where HTTP dispatch is handled by a separate client - [@rellampec](https://github.com/rellampec). -* `Query#spread(fragment_name)`: emits `...FragmentName` into the query string. Replaces the - `___Const` graphql-client convention for fragment spreads in DSL blocks - [@rellampec](https://github.com/rellampec). -* `spec/spec_helper.rb`: rescue `LoadError` on `byebug` require so specs run on Windows - (x64-mingw-ucrt / Ruby 3.2) where byebug is not installable - [@rellampec](https://github.com/rellampec). +* `Client#to_query_string(**kargs, &block)`: builds the full GraphQL query document from the + DSL block and returns a `String` without touching the schema or Faraday HTTP adapter. + Enables DSL-only mode where HTTP dispatch is handled externally - [@rellampec](https://github.com/rellampec). +* `Query#spread(fragment_name)`: emits `...FragmentName` into the query string as a named + fragment spread. Alternative to the `___Const` convention for use cases where + `graphql-client` constant parsing is not available - [@rellampec](https://github.com/rellampec). +* `spec/spec_helper.rb`: rescue `LoadError` on `byebug` require so specs run on platforms + where byebug is not installable (e.g. Windows with Ruby 3.2) - [@rellampec](https://github.com/rellampec). ### 0.8.0 (2024/01/06) * [#110](https://github.com/ashkan18/graphlient/pull/110): Ensure correct Faraday JSON response body parsing with invalid response header - [@taylorthurlow](https://github.com/taylorthurlow). From 896c73d6ff2cef74e5c313daaf39ec87903024d3 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:01:02 +1200 Subject: [PATCH 03/32] =?UTF-8?q?fix:=20resolve=20CI=20failures=20?= =?UTF-8?q?=E2=80=94=20schema=5Fpath=20+=20rubocop=20offenses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit static_client_query_spec: add schema_path: 'spec/support/fixtures/invoice_api.json' The client was fetching the schema from the live URL at module load time (before WebMock is active), causing 403 in CI. Using a local schema file avoids the network call and matches the pattern in webmock_client_query_spec. client_dsl_spec: nodes { } → nodes do end (Style/BlockDelimiters) query.rb: add rubocop:disable Metrics/ClassLength Class grew past 100 lines after DSL additions (spread, fragment resolution). spec_helper.rb: add rubocop:disable Lint/HandleExceptions on rescue LoadError The empty rescue is intentional — byebug unavailable on some platforms. Note: faraday_adapter_spec.rb:147 fails locally on Windows (TCP error message format differs from Linux) but passes in CI on ubuntu-latest. Pre-existing. --- lib/graphlient/query.rb | 2 +- spec/graphlient/client_dsl_spec.rb | 4 ++-- spec/graphlient/static_client_query_spec.rb | 1 + spec/spec_helper.rb | 3 ++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/graphlient/query.rb b/lib/graphlient/query.rb index eb2fc2e..f63967c 100644 --- a/lib/graphlient/query.rb +++ b/lib/graphlient/query.rb @@ -1,5 +1,5 @@ module Graphlient - class Query + class Query # rubocop:disable Metrics/ClassLength SCALAR_TYPES = { int: 'Int', float: 'Float', diff --git a/spec/graphlient/client_dsl_spec.rb b/spec/graphlient/client_dsl_spec.rb index 2602d95..00cfbf5 100644 --- a/spec/graphlient/client_dsl_spec.rb +++ b/spec/graphlient/client_dsl_spec.rb @@ -83,10 +83,10 @@ it 'supports multiple spreads at the same level' do result = client.to_query_string do query do - nodes { + nodes do spread :FragmentA spread :FragmentB - } + end end end expect(result).to include('...FragmentA') diff --git a/spec/graphlient/static_client_query_spec.rb b/spec/graphlient/static_client_query_spec.rb index 5a47e46..84b5a43 100644 --- a/spec/graphlient/static_client_query_spec.rb +++ b/spec/graphlient/static_client_query_spec.rb @@ -5,6 +5,7 @@ module Graphlient::Client::Spec Client = Graphlient::Client.new( 'http://graph.biz/graphql', + schema_path: 'spec/support/fixtures/invoice_api.json', headers: { 'Authorization' => 'Bearer 1231' }, allow_dynamic_queries: false ) do |client| diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b04284b..4d09733 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,7 +5,8 @@ require 'graphlient' begin require 'byebug' if RUBY_ENGINE != 'jruby' -rescue LoadError # byebug not available on all platforms (e.g. Windows x64-mingw-ucrt) +rescue LoadError # rubocop:disable Lint/HandleExceptions + # byebug not available on all platforms (e.g. Windows x64-mingw-ucrt) end require 'rack/test' require 'webmock/rspec' From d81578e2c096ed45ea156b79474ca56ef6659ea6 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Wed, 10 Jun 2026 07:17:47 +1200 Subject: [PATCH 04/32] fix: make TCP error spec cross-platform (Windows/Linux) On Linux, Errno::ECONNREFUSED.new(msg).message prepends 'Connection refused - '. On Windows, the OS prepends a different string. Use a regex that matches the core error_message content rather than the exact platform-specific prefix. --- spec/graphlient/adapters/http/faraday_adapter_spec.rb | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/spec/graphlient/adapters/http/faraday_adapter_spec.rb b/spec/graphlient/adapters/http/faraday_adapter_spec.rb index 05ecb19..8902129 100644 --- a/spec/graphlient/adapters/http/faraday_adapter_spec.rb +++ b/spec/graphlient/adapters/http/faraday_adapter_spec.rb @@ -145,8 +145,13 @@ end specify do - expected_error_message = "Connection refused - #{error_message}" - expect { client.schema }.to raise_error(Graphlient::Errors::ConnectionFailedError, expected_error_message) + # Use a regex so the test passes on both Linux ("Connection refused - ...") + # and Windows (where Errno::ECONNREFUSED prepends a different OS message). + # Both platforms include the core error_message string in the result. + expect { client.schema }.to raise_error( + Graphlient::Errors::ConnectionFailedError, + /#{Regexp.escape(error_message)}/ + ) end end From c0225fc1b240f1e934bfc5970b05636213354962 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Wed, 10 Jun 2026 09:09:25 +1200 Subject: [PATCH 05/32] chore: gitignore .vscode/ and .idea/ editor directories --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 3c4cfd5..45a54b0 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ .byebug_history .history/ .DS_Store +.solargraph.yml +.vscode/ +.idea/ + # Used by dotenv library to load environment variables. # .env From 9a665d4ee8646e0927485bc71fa805cbd4444354 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Wed, 10 Jun 2026 07:18:22 +1200 Subject: [PATCH 06/32] =?UTF-8?q?wip:=20directive=20DSL=20spec=20=E2=80=94?= =?UTF-8?q?=20pending=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a spec for directive DSL syntax: feeInCents _skip(if: :skip_fee) This should translate to @skip(if: \) in the GraphQL query. Implementation deferred — tracked in this branch (directives-dsl-support). Base: feature/dsl-mode-only-option. --- spec/graphlient/client_query_spec.rb | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/spec/graphlient/client_query_spec.rb b/spec/graphlient/client_query_spec.rb index 3c011db..a307cb7 100644 --- a/spec/graphlient/client_query_spec.rb +++ b/spec/graphlient/client_query_spec.rb @@ -92,6 +92,29 @@ invoice = response.data.invoice expect(invoice).to be_nil end + + context 'with directive' do + let(:query) do + client.parse do + query( + some_id: :int, + skip_fee: :boolean + ) do + invoice(id: :some_id) do + id + feeInCents _skip(if: :skip_fee) + end + end + end + end + + it '#execute' do + response = client.execute(query, some_id: 42) + invoice = response.data.invoice + expect(invoice.id).to eq '42' + expect(invoice.fee_in_cents).to eq nil + end + end end context 'parameterized GRAPHQL query' do From 964b659bca6526f43860f91afa98ddbe629aaa9c Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Wed, 10 Jun 2026 09:35:19 +1200 Subject: [PATCH 07/32] docs+spec: spread/fragment/directive README + mark directive spec pending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README.md: 'Fragment Spreads in the DSL' — documents spread :Name DSL method (already implemented in feature/dsl-mode-only-option base) 'Fragment Spreads — inline definition' — shows the proposed fragment DSL (inline fragment(:Name, on: :Type) block, scoped to query call) marked 'coming in a future release' 'Directives in the DSL' — shows _skip(if:) → @skip(if: \) proposal marked 'coming in a future release', anchored to this branch spec/graphlient/client_query_spec.rb: Directive test marked pending with explanation — shows intended behaviour without causing a CI failure while the implementation is outstanding. 74 examples, 0 failures, 1 pending. --- README.md | 84 +++++++++++++++++++++++++++- spec/graphlient/client_query_spec.rb | 5 ++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9789209..34dfe73 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ A friendlier Ruby client for consuming GraphQL-based APIs. Built on top of your - [Parse and Execute Queries Separately](#parse-and-execute-queries-separately) - [Dynamic vs. Static Queries](#dynamic-vs-static-queries) - [Generate Queries with Graphlient::Query](#generate-queries-with-graphlientquery) + - [Fragment Spreads in the DSL](#fragment-spreads-in-the-dsl) + - [Directives in the DSL](#directives-in-the-dsl-coming-in-a-future-release) - [Create API Client Classes with Graphlient::Extension::Query](#create-api-client-classes-with-graphlientextensionquery) - [Swapping the HTTP Stack](#swapping-the-http-stack) - [Testing with Graphlient and RSpec](#testing-with-graphlient-and-rspec) @@ -341,7 +343,71 @@ query.to_s # "\nquery {\n invoice(id: 10){\n line_items\n }\n }\n" ``` -### Use of Fragments +### Fragment Spreads in the DSL + +When building queries with a block, you can use `spread` to insert a named fragment +spread (`...FragmentName`) without switching to raw string interpolation: + +```ruby +query = Graphlient::Query.new do + query do + invoice(id: 10) do + id + spread :InvoiceFields # → ...InvoiceFields + end + end +end + +query.to_s +# "\nquery {\n invoice(id: 10){\n id\n ...InvoiceFields\n }\n}\n" +``` + +This is useful when the fragment definition is managed externally — for example, +by a framework layer that appends fragment strings to the query before sending it +to the server, or when you are assembling queries programmatically and want to keep +the DSL clean. + +**Defining fragments alongside the query (proposed — coming in a future release)** + +A companion `fragment` DSL method is planned that lets you define fragment bodies +inline in the same block, so graphlient can assemble the complete query string +without any external tooling: + +```ruby +response = client.query do + # Define the fragment inline — body generated by the same DSL + fragment(:InvoiceFields, on: :Invoice) do + id + feeInCents + end + + query do + invoice(id: 10) do + spread :InvoiceFields + end + end +end +``` + +This would produce and send: + +```graphql +query { + invoice(id: 10) { + ...InvoiceFields + } +} + +fragment InvoiceFields on Invoice { + id + feeInCents +} +``` + +Fragments are scoped to the query call — no global registry, no cross-contamination +between requests. Multiple fragments and nested spreads are supported. + +### Use of Fragments (graphql-client style) [Fragments](https://github.com/github-community-projects/graphql-client#defining-queries) should be referred by constant: @@ -397,6 +463,22 @@ invoice.fee_in_cents # 20000 ``` +### Directives in the DSL (coming in a future release) + +Support for inline directive syntax in DSL blocks is planned, allowing expressions +like the following without falling back to raw strings: + +```ruby +client.query(some_id: 42, skip_fee: true) do + query(some_id: :int, skip_fee: :boolean) do + invoice(id: :some_id) do + id + feeInCents _skip(if: :skip_fee) # → feeInCents @skip(if: $skip_fee) + end + end +end +``` + ### Create API Client Classes with Graphlient::Extension::Query You can include `Graphlient::Extensions::Query` in your class. This will add a new `method_missing` method to your context which will be used to generate GraphQL queries. diff --git a/spec/graphlient/client_query_spec.rb b/spec/graphlient/client_query_spec.rb index a307cb7..1b9216e 100644 --- a/spec/graphlient/client_query_spec.rb +++ b/spec/graphlient/client_query_spec.rb @@ -94,7 +94,12 @@ end context 'with directive' do + # Directive DSL (_skip, _include etc.) is not yet implemented. + # This spec defines the intended behaviour and will be enabled + # once feeInCents _skip(if: :skip_fee) → @skip(if: $skip_fee) + # translation is added to Graphlient::Query. let(:query) do + pending 'directive DSL not yet implemented' client.parse do query( some_id: :int, From 341f9fc828e3fe52a72181705f7a2eb5ccfa92b5 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:15:57 +1200 Subject: [PATCH 08/32] feat: directive DSL, inline fragments, fragment definitions, custom scalars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Query refactored into Query (thin shell) + Query::Serializer (composed): query/directive.rb Directive value object — returned by _name(args), placed AFTER field name in output (correct order) query/serializer.rb Main composition; includes all concern modules query/serializer/scalars.rb SCALAR_TYPES extraction + Serializer.scalar(:sym, Type) class-level custom scalar registration query/serializer/arguments.rb hash_arg/args_str/variable_string; field_args/directive_args split; variable_string bug fixed (lambda case) query/serializer/evaluator.rb evaluate() via instance_eval query/serializer/fragments.rb ___Const triple-underscore resolution query/serializer/directives.rb directive?() predicate; method_missing returns Directive Removed: query/transcript.rb (empty stub — deleted) New DSL surface: _skip(if: :x) → @skip(if: $x) (any _name directive) _include(if: :x) → @include(if: $x) feeInCents _skip(if: :x) → feeInCents @skip(if: $x) spread :F, _skip(if: :x) → ...F @skip(if: $x) on(:Type) { fields } → ... on Type { fields } on(:Type, _skip(if: :x)) { } → ... on Type @skip(if: $x) { } fragment(:F, on: :T) { } → fragment F on T { } (appended after main query) Custom scalar registration (client config block): client = Graphlient::Client.new(url) { |c| c.scalar :date, 'Date' } query(created_after: :date) { } → query($created_after: Date) Specs: query_dsl_spec.rb — 16 examples covering all new features client_query_spec.rb — directive test enabled (was pending, now live) 90 examples total, 0 failures --- lib/graphlient/client.rb | 11 + lib/graphlient/query.rb | 146 ++---------- lib/graphlient/query/directive.rb | 47 ++++ lib/graphlient/query/serializer.rb | 123 ++++++++++ lib/graphlient/query/serializer/arguments.rb | 64 ++++++ lib/graphlient/query/serializer/directives.rb | 17 ++ lib/graphlient/query/serializer/evaluator.rb | 15 ++ lib/graphlient/query/serializer/fragments.rb | 28 +++ lib/graphlient/query/serializer/scalars.rb | 63 ++++++ spec/graphlient/client_query_spec.rb | 9 +- spec/graphlient/query_dsl_spec.rb | 212 ++++++++++++++++++ 11 files changed, 602 insertions(+), 133 deletions(-) create mode 100644 lib/graphlient/query/directive.rb create mode 100644 lib/graphlient/query/serializer.rb create mode 100644 lib/graphlient/query/serializer/arguments.rb create mode 100644 lib/graphlient/query/serializer/directives.rb create mode 100644 lib/graphlient/query/serializer/evaluator.rb create mode 100644 lib/graphlient/query/serializer/fragments.rb create mode 100644 lib/graphlient/query/serializer/scalars.rb create mode 100644 spec/graphlient/query_dsl_spec.rb diff --git a/lib/graphlient/client.rb b/lib/graphlient/client.rb index 8dd9b56..7527fbc 100644 --- a/lib/graphlient/client.rb +++ b/lib/graphlient/client.rb @@ -59,6 +59,17 @@ def http(&block) @http ||= http_adapter_class.new(@url, adapter_options, &block) end + # Register a custom scalar type for use in variable declarations. + # Call inside the client initialiser block: + # client = Graphlient::Client.new(url) do |c| + # c.scalar :date, 'Date' + # c.scalar :uuid, 'UUID' + # c.scalar :decimal, 'Decimal' + # end + def scalar(sym, graphql_type) + Query.scalar(sym, graphql_type) + end + def schema @schema ||= options[:schema] || Graphlient::Schema.new(http, schema_path) end diff --git a/lib/graphlient/query.rb b/lib/graphlient/query.rb index f63967c..af7c295 100644 --- a/lib/graphlient/query.rb +++ b/lib/graphlient/query.rb @@ -1,135 +1,29 @@ -module Graphlient - class Query # rubocop:disable Metrics/ClassLength - SCALAR_TYPES = { - int: 'Int', - float: 'Float', - string: 'String', - boolean: 'Boolean' - }.freeze - - ROOT_NODES = %w[query mutation subscription].freeze - - FRAGMENT_DEFITION = /___(?[A-Z][a-zA-Z0-9_]*(__[A-Z][a-zA-Z0-9_]*)*)/ - - attr_accessor :query_str - - def initialize(&block) - @indents = 0 - @query_str = '' - @variables = [] - evaluate(&block) - end - - def method_missing(method_name, *args, &block) - append_node(method_name, args, &block) - end +require_relative 'query/serializer' - ROOT_NODES.each do |root_node| - define_method(root_node) do |*args, &block| - @variables = args.first unless args.empty? - append_node(root_node, args, arg_processor: ->(k, v) { "$#{k}: #{variable_string(v)}" }, &block) - end +module Graphlient + # Public interface for building GraphQL query strings via a DSL block. + # Delegates all string-building work to Query::Serializer. + # + # Usage: + # Graphlient::Query.new do + # query do + # invoice(id: 10) { id; fee_in_cents } + # end + # end.to_s + class Query + # Register a custom scalar type for variable declarations. + # Delegates to Serializer so client configuration works: + # client = Graphlient::Client.new(url) { |c| c.scalar :date, 'Date' } + def self.scalar(sym, graphql_type) + Serializer.scalar(sym, graphql_type) end - def respond_to_missing?(method_name, include_private = false) - super + def initialize(&block) + @serializer = Serializer.new(&block) end def to_s - query_str.strip - end - - # Appends a named fragment spread (`...fragment_name`) to the query string. - # Use instead of the deprecated `___Const` convention. - def spread(fragment_name) - @query_str << "\n#{indent}...#{fragment_name}" - @query_str << "\n#{indent}" - end - - private - - def evaluate(&block) - @last_block = block || self - (@context ||= {})[@last_block] ||= @last_block.binding - instance_eval(&block) - end - - def resolve_fragment_constant(value) - return nil unless (match = value.to_s.match(FRAGMENT_DEFITION)) - raw_const = match[:const].gsub('__', '::') - @context[@last_block].eval(raw_const).tap do |const| - msg = "Expected constant #{raw_const} to be GraphQL::Client::FragmentDefinition. Given #{const.class}" - raise Graphlient::Errors::Error, msg unless const.is_a? GraphQL::Client::FragmentDefinition - end - end - - def append_node(node, args, arg_processor: nil, &block) - node = "...#{resolve_fragment_constant(node)}".to_sym if node.to_s.start_with?('___') - - # add field - @query_str << "\n#{indent}#{node}" - # add filter - hash_arguments = hash_arg(args) - @query_str << "(#{args_str(hash_arguments, arg_processor: arg_processor)})" if hash_arguments - - if block_given? - @indents += 1 - @query_str << '{' - evaluate(&block) - @query_str << '}' - @indents -= 1 - end - - @query_str << "\n#{indent}" - end - - def indent - ' ' * @indents - end - - def hash_arg(args) - args.detect { |arg| arg.is_a? Hash } - end - - def args_str(hash_args, arg_processor: nil) - hash_args.map do |k, v| - arg_processor ? arg_processor.call(k, v) : argument_string(k, v) - end.join(', ') - end - - def argument_string(key, val) - "#{key}: #{argument_value_string(val)}" - end - - def variable_string(val) - case val - when :id, :id! - val.to_s.upcase - when ->(v) { SCALAR_TYPES.key?(v.to_s.delete('!').to_sym) } - # scalar types - val.to_s.camelize - when Array - "[#{variable_string(val.first)}]" - else - val.to_s - end - end - - def argument_value_string(value) - case value - when String - "\"#{value}\"" - when Numeric - value.to_s - when Array - "[#{value.map { |v| argument_value_string(v) }.join(', ')}]" - when Hash - "{ #{value.map { |k, v| "#{k}: #{argument_value_string(v)}" }.join(', ')} }" - when Symbol - @variables.key?(value) ? "$#{value}" : value.to_s.camelize(:lower) - else - value - end + @serializer.to_s end end end diff --git a/lib/graphlient/query/directive.rb b/lib/graphlient/query/directive.rb new file mode 100644 index 0000000..93314a8 --- /dev/null +++ b/lib/graphlient/query/directive.rb @@ -0,0 +1,47 @@ +module Graphlient + class Query + # Value object returned by `_directive_name(args)` in the DSL. + # + # Rather than writing directly to the query string (which would produce + # the wrong position — before the field name), _skip / _include etc. + # return a Directive instance. append_node / spread / on detect Directive + # objects in their argument list and place them AFTER the field name, + # producing correct GraphQL output: + # + # feeInCents _skip(if: :skip_fee) + # → feeInCents @skip(if: $skip_fee) + # + # spread :InvoiceFields, _skip(if: :x) + # → ...InvoiceFields @skip(if: $x) + # + # on(:DraftInvoice, _skip(if: :x)) { draft_id } + # → ... on DraftInvoice @skip(if: $x) { draftId } + class Directive + attr_reader :name, :args + + def initialize(name, args = {}) + @name = name.to_s.delete_prefix('_').freeze + @args = args + end + + def to_s + return "@#{name}" if args.empty? + + formatted = args.map { |k, v| "#{k}: #{format_value(v)}" }.join(', ') + "@#{name}(#{formatted})" + end + + private + + # Symbols become variable references ($name); everything else is literal. + def format_value(value) + case value + when Symbol then "$#{value}" + when String then "\"#{value}\"" + when Numeric, TrueClass, FalseClass then value.to_s + else value.to_s + end + end + end + end +end diff --git a/lib/graphlient/query/serializer.rb b/lib/graphlient/query/serializer.rb new file mode 100644 index 0000000..8c39e75 --- /dev/null +++ b/lib/graphlient/query/serializer.rb @@ -0,0 +1,123 @@ +require_relative 'directive' +require_relative 'serializer/scalars' +require_relative 'serializer/arguments' +require_relative 'serializer/evaluator' +require_relative 'serializer/fragments' +require_relative 'serializer/directives' + +module Graphlient + class Query + # Builds a GraphQL query string from a DSL block. + # Composed from focused concern modules; Query is a thin public wrapper. + class Serializer + include Scalars + include Arguments + include Evaluator + include Fragments + include Directives + + ROOT_NODES = %w[query mutation subscription].freeze + + ROOT_NODES.each do |root_node| + define_method(root_node) do |*args, &block| + @variables = args.first || {} + append_node(root_node, args, arg_processor: variable_processor, &block) + end + end + + attr_accessor :query_str + + def initialize(&block) + @indents = 0 + @query_str = '' + @variables = {} + @fragments = {} + evaluate(&block) if block + end + + # Full output: main query string + any inline fragment definitions. + def to_s + parts = [query_str.strip] + parts.concat(@fragments.values) unless @fragments.empty? + parts.join("\n\n") + end + + # Named fragment spread: ...FragmentName [@directive ...] + # spread :InvoiceFields + # spread :InvoiceFields, _skip(if: :x) # → ...InvoiceFields @skip(if: $x) + def spread(fragment_name, *args) + directives = args.select { |a| a.is_a?(Directive) } + @query_str << "\n#{indent}...#{fragment_name}" + directives.each { |d| @query_str << " #{d}" } + @query_str << "\n#{indent}" + end + + # Inline fragment: ... on Type [@directive ...] { fields } + # on(:PaidInvoice) { amount_paid } + # on(:DraftInvoice, _skip(if: :skip)) { draft_id } + def on(type, *args, &block) + directives = args.select { |a| a.is_a?(Directive) } + @query_str << "\n#{indent}... on #{type}" + directives.each { |d| @query_str << " #{d}" } + if block_given? + @indents += 1 + @query_str << '{' + evaluate(&block) + @query_str << '}' + @indents -= 1 + end + @query_str << "\n#{indent}" + end + + # Inline fragment definition — collected and appended after the main query. + # fragment(:InvoiceFields, on: :Invoice) { id; fee_in_cents } + def fragment(name, on:, &block) + body = self.class.new(&block).query_str.strip + @fragments[name] = "fragment #{name} on #{on} {\n#{body}\n}" + end + + def method_missing(method_name, *args, &block) + if fragment?(method_name) + append_node("...#{resolve_fragment_constant(method_name)}".to_sym, args, &block) + elsif directive?(method_name) + Directive.new(method_name, args.first || {}) + else + append_node(method_name, args, &block) + end + end + + def respond_to_missing?(method_name, include_private = false) + super + end + + private + + def indent + ' ' * @indents + end + + def append_node(node, args, arg_processor: nil, &block) # rubocop:disable Metrics/MethodLength + regular = field_args(args) + dirs = directive_args(args) + + @query_str << "\n#{indent}#{node}" + + if (h = hash_arg(regular)) + @query_str << "(#{args_str(h, arg_processor: arg_processor)})" + end + + dirs.each { |d| @query_str << " #{d}" } + + if block_given? + @indents += 1 + @query_str << '{' + evaluate(&block) + @query_str << '}' + @indents -= 1 + end + + @query_str << "\n#{indent}" + end + end + end +end diff --git a/lib/graphlient/query/serializer/arguments.rb b/lib/graphlient/query/serializer/arguments.rb new file mode 100644 index 0000000..7e8d821 --- /dev/null +++ b/lib/graphlient/query/serializer/arguments.rb @@ -0,0 +1,64 @@ +module Graphlient + class Query + class Serializer + module Arguments + private + + def variable_processor + @variable_processor ||= ->(k, v) { "$#{k}: #{variable_string(v)}" } + end + + # Translate a DSL variable type symbol to its GraphQL type string. + # :id/:id! → ID/ID! :int → Int :date → Date (if registered) [:int] → [Int] + def variable_string(val) + case val + when :id, :id! + val.to_s.upcase + when ->(v) { scalar?(v) } + scalar_type(val) + when Array + "[#{variable_string(val.first)}]" + else + val.to_s + end + end + + # Separate Directive objects from regular field arguments. + def field_args(args) + args.reject { |a| a.is_a?(Directive) } + end + + def directive_args(args) + args.select { |a| a.is_a?(Directive) } + end + + def hash_arg(args) + args.detect { |arg| arg.is_a?(Hash) } + end + + def args_str(hash_args, arg_processor: nil) + hash_args.map do |k, v| + arg_processor ? arg_processor.call(k, v) : argument_string(k, v) + end.join(', ') + end + + def argument_string(key, val) + "#{key}: #{argument_value_string(val)}" + end + + def argument_value_string(value) + case value + when String then "\"#{value}\"" + when Numeric then value.to_s + when Array then "[#{value.map { |v| argument_value_string(v) }.join(', ')}]" + when Hash then "{ #{value.map { |k, v| "#{k}: #{argument_value_string(v)}" }.join(', ')} }" + when Symbol + @variables.respond_to?(:key?) && @variables.key?(value) ? "$#{value}" : value.to_s.camelize(:lower) + else + value + end + end + end + end + end +end diff --git a/lib/graphlient/query/serializer/directives.rb b/lib/graphlient/query/serializer/directives.rb new file mode 100644 index 0000000..e0e2456 --- /dev/null +++ b/lib/graphlient/query/serializer/directives.rb @@ -0,0 +1,17 @@ +module Graphlient + class Query + class Serializer + module Directives + # Matches _skip, _include, _myCustomDirective etc. + # Does NOT match ___Fragment (three underscores — checked first in method_missing). + DIRECTIVE_PREFIX = /\A_[a-z]/ + + private + + def directive?(method_name) + method_name.to_s.match?(DIRECTIVE_PREFIX) + end + end + end + end +end diff --git a/lib/graphlient/query/serializer/evaluator.rb b/lib/graphlient/query/serializer/evaluator.rb new file mode 100644 index 0000000..ace5bd9 --- /dev/null +++ b/lib/graphlient/query/serializer/evaluator.rb @@ -0,0 +1,15 @@ +module Graphlient + class Query + class Serializer + module Evaluator + private + + def evaluate(&block) + @last_block = block || self + (@context ||= {})[@last_block] ||= @last_block.binding + instance_eval(&block) + end + end + end + end +end diff --git a/lib/graphlient/query/serializer/fragments.rb b/lib/graphlient/query/serializer/fragments.rb new file mode 100644 index 0000000..ae861ef --- /dev/null +++ b/lib/graphlient/query/serializer/fragments.rb @@ -0,0 +1,28 @@ +module Graphlient + class Query + class Serializer + module Fragments + # Pattern for the legacy ___Const::Name triple-underscore convention. + FRAGMENT_DEFINITION = /___(?[A-Z][a-zA-Z0-9_]*(__[A-Z][a-zA-Z0-9_]*)*)/ + + private + + def fragment?(method_name) + method_name.to_s.start_with?('___') + end + + def resolve_fragment_constant(value) + return nil unless (match = value.to_s.match(FRAGMENT_DEFINITION)) + + raw_const = match[:const].gsub('__', '::') + @context[@last_block].eval(raw_const).tap do |const| + next if const.is_a?(GraphQL::Client::FragmentDefinition) + + msg = "Expected constant #{raw_const} to be GraphQL::Client::FragmentDefinition. Given #{const.class}" + raise Graphlient::Errors::Error, msg + end + end + end + end + end +end diff --git a/lib/graphlient/query/serializer/scalars.rb b/lib/graphlient/query/serializer/scalars.rb new file mode 100644 index 0000000..6e04729 --- /dev/null +++ b/lib/graphlient/query/serializer/scalars.rb @@ -0,0 +1,63 @@ +module Graphlient + class Query + class Serializer + module Scalars + BUILT_IN_SCALAR_TYPES = { + int: 'Int', + float: 'Float', + string: 'String', + boolean: 'Boolean' + }.freeze + + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + # Register a custom scalar type for use in variable declarations. + # + # Graphlient::Query::Serializer.scalar(:date, 'Date') + # + # Typically called via the client configuration block: + # client = Graphlient::Client.new(url) do |c| + # c.scalar :date, 'Date' + # c.scalar :uuid, 'UUID' + # c.scalar :decimal, 'Decimal' + # end + # + # After registration, use the symbol in variable declarations: + # query(created_after: :date) { ... } # → query($createdAfter: Date) + def scalar(sym, graphql_type) + custom_scalar_types[sym.to_sym] = graphql_type.to_s + end + + def custom_scalar_types + @custom_scalar_types ||= {} + end + + def all_scalar_types + BUILT_IN_SCALAR_TYPES.merge(custom_scalar_types) + end + end + + private + + def scalar?(value) + all_scalar_types.key?(value.to_s.delete('!').to_sym) + end + + def scalar_type(value) + str = value.to_s + base = str.delete('!') + non_null = str.end_with?('!') + type = all_scalar_types[base.to_sym] + non_null ? "#{type}!" : type + end + + def all_scalar_types + self.class.all_scalar_types + end + end + end + end +end diff --git a/spec/graphlient/client_query_spec.rb b/spec/graphlient/client_query_spec.rb index 1b9216e..e221b39 100644 --- a/spec/graphlient/client_query_spec.rb +++ b/spec/graphlient/client_query_spec.rb @@ -94,16 +94,11 @@ end context 'with directive' do - # Directive DSL (_skip, _include etc.) is not yet implemented. - # This spec defines the intended behaviour and will be enabled - # once feeInCents _skip(if: :skip_fee) → @skip(if: $skip_fee) - # translation is added to Graphlient::Query. let(:query) do - pending 'directive DSL not yet implemented' client.parse do query( some_id: :int, - skip_fee: :boolean + skip_fee: :boolean! ) do invoice(id: :some_id) do id @@ -114,7 +109,7 @@ end it '#execute' do - response = client.execute(query, some_id: 42) + response = client.execute(query, some_id: 42, skip_fee: true) invoice = response.data.invoice expect(invoice.id).to eq '42' expect(invoice.fee_in_cents).to eq nil diff --git a/spec/graphlient/query_dsl_spec.rb b/spec/graphlient/query_dsl_spec.rb new file mode 100644 index 0000000..c41c309 --- /dev/null +++ b/spec/graphlient/query_dsl_spec.rb @@ -0,0 +1,212 @@ +require 'spec_helper' + +# Specs for DSL extensions: directives, inline fragments, fragment definitions, +# spread + directive, and custom scalar registration. +# These operate on the Query string builder only — no HTTP or schema access. + +RSpec.describe Graphlient::Query do + def build(&block) + described_class.new(&block).to_s + end + + # --- Directives ----------------------------------------------------------- + + describe 'directives (_name convention → @name)' do + it 'applies @skip to a field' do + result = build do + query(skip_fee: :boolean!) do + invoice(id: 10) do + id + feeInCents _skip(if: :skip_fee) + end + end + end + expect(result).to include('@skip(if: $skip_fee)') + expect(result).to include('feeInCents @skip(if: $skip_fee)') + end + + it 'applies @include to a field' do + result = build do + query(show_fee: :boolean!) do + invoice(id: 10) do + feeInCents _include(if: :show_fee) + end + end + end + expect(result).to include('feeInCents @include(if: $show_fee)') + end + + it 'applies multiple directives to one field' do + result = build do + query(skip_fee: :boolean!, show_desc: :boolean!) do + invoice(id: 10) do + feeInCents _skip(if: :skip_fee), _include(if: :show_desc) + end + end + end + expect(result).to include('@skip(if: $skip_fee)') + expect(result).to include('@include(if: $show_desc)') + end + + it 'applies a directive with no arguments' do + result = build do + query do + invoice(id: 10) do + id _deprecated + end + end + end + expect(result).to include('id @deprecated') + end + + it 'does not confuse ___ fragment constant with a directive' do + result = build do + query do + invoice(id: 10) { id } + end + end + expect(result).not_to include('@__') + end + end + + # --- Fragment spreads with directives ------------------------------------ + + describe 'spread with directive' do + it 'appends directive after the spread name' do + result = build do + query(skip_invoice: :boolean!) do + spread :InvoiceFields, _skip(if: :skip_invoice) + end + end + expect(result).to include('...InvoiceFields @skip(if: $skip_invoice)') + end + + it 'spread without directive is unchanged' do + result = build do + query { spread :InvoiceFields } + end + expect(result).to include('...InvoiceFields') + expect(result).not_to include('@') + end + end + + # --- Inline fragments ---------------------------------------------------- + + describe 'on(:Type) — inline fragment' do + it 'generates ... on Type { }' do + result = build do + query do + invoice(id: 10) do + on(:PaidInvoice) do + amountPaid + end + on(:UnpaidInvoice) do + amountDue + end + end + end + end + expect(result).to include('... on PaidInvoice') + expect(result).to include('amountPaid') + expect(result).to include('... on UnpaidInvoice') + expect(result).to include('amountDue') + end + + it 'applies a directive on the inline fragment' do + result = build do + query(skip_draft: :boolean!) do + invoice(id: 10) do + on(:DraftInvoice, _skip(if: :skip_draft)) do + draftId + end + end + end + end + expect(result).to include('... on DraftInvoice @skip(if: $skip_draft)') + expect(result).to include('draftId') + end + + it 'inline fragment without block (type check only)' do + result = build do + query { on(:Invoice) } + end + expect(result).to include('... on Invoice') + end + end + + # --- Fragment definitions ------------------------------------------------ + + describe 'fragment(:Name, on: :Type)' do + it 'appends the fragment definition after the main query' do + result = build do + fragment(:InvoiceFields, on: :Invoice) do + id + feeInCents + end + query do + invoice(id: 10) do + spread :InvoiceFields + end + end + end + expect(result).to include('...InvoiceFields') + expect(result).to include('fragment InvoiceFields on Invoice') + expect(result).to include('feeInCents') + end + + it 'separates query and fragment with a blank line' do + result = build do + fragment(:F, on: :T) { id } + query { spread :F } + end + parts = result.split("\n\n") + expect(parts.length).to eq 2 + expect(parts.last).to start_with('fragment F on T') + end + + it 'supports multiple fragment definitions' do + result = build do + fragment(:FragA, on: :TypeA) { field_a } + fragment(:FragB, on: :TypeB) { field_b } + query { spread :FragA; spread :FragB } + end + expect(result).to include('fragment FragA on TypeA') + expect(result).to include('fragment FragB on TypeB') + end + end + + # --- Custom scalar registration ------------------------------------------ + + describe 'custom scalar registration' do + before { described_class::Serializer.scalar(:date, 'Date') } + + it 'uses the registered type in variable declarations' do + result = build do + query(created_after: :date) do + invoices(created_after: :created_after) { id } + end + end + # Variable names are not camelized — snake_case keys output as-is + expect(result).to include('$created_after: Date') + end + + it 'supports non-null custom scalars with !' do + result = build do + query(started_at: :date!) do + invoices(started_at: :started_at) { id } + end + end + expect(result).to include('$started_at: Date!') + end + + it 'built-in scalars still work alongside custom ones' do + result = build do + query(id: :int, created_after: :date) do + invoice(id: :id) { id } + end + end + expect(result).to include('$id: Int') + expect(result).to include('$created_after: Date') + end + end +end From 4bb4e741497804556c49186c8f9a00d12a09f3ed Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:41:05 +1200 Subject: [PATCH 09/32] docs+specs: update README + add client-level DSL coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README.md: Fragment Spreads and Definitions — remove 'proposed/coming' language, document fragment(:Name, on: :Type) as shipped, add spread+directive example Inline Fragments — new section: on(:Type) { } and on(:Type, _skip) { } Directives in the DSL — rewrite from 'coming soon' to full reference: on a field, on a spread, on an inline fragment, multiple, no-arg Custom Scalar Types — new section: c.scalar :date, 'Date' client config Table of contents updated to match all new sections spec/graphlient/client_dsl_spec.rb: to_query_string with directive — verifies @skip in output to_query_string with inline fragment — verifies ... on Type to_query_string with fragment definition — verifies assembled output scalar registration via client block — verifies c.scalar :date, 'Date' 94 examples, 0 failures --- README.md | 163 +++++++++++++++++++++++------ spec/graphlient/client_dsl_spec.rb | 53 ++++++++++ 2 files changed, 186 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 34dfe73..08bf188 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,10 @@ A friendlier Ruby client for consuming GraphQL-based APIs. Built on top of your - [Parse and Execute Queries Separately](#parse-and-execute-queries-separately) - [Dynamic vs. Static Queries](#dynamic-vs-static-queries) - [Generate Queries with Graphlient::Query](#generate-queries-with-graphlientquery) - - [Fragment Spreads in the DSL](#fragment-spreads-in-the-dsl) - - [Directives in the DSL](#directives-in-the-dsl-coming-in-a-future-release) + - [Fragment Spreads and Definitions in the DSL](#fragment-spreads-and-definitions-in-the-dsl) + - [Inline Fragments in the DSL](#inline-fragments-in-the-dsl) + - [Directives in the DSL](#directives-in-the-dsl) + - [Custom Scalar Types](#custom-scalar-types) - [Create API Client Classes with Graphlient::Extension::Query](#create-api-client-classes-with-graphlientextensionquery) - [Swapping the HTTP Stack](#swapping-the-http-stack) - [Testing with Graphlient and RSpec](#testing-with-graphlient-and-rspec) @@ -343,13 +345,12 @@ query.to_s # "\nquery {\n invoice(id: 10){\n line_items\n }\n }\n" ``` -### Fragment Spreads in the DSL +### Fragment Spreads and Definitions in the DSL -When building queries with a block, you can use `spread` to insert a named fragment -spread (`...FragmentName`) without switching to raw string interpolation: +Use `spread` to insert a named fragment spread (`...FragmentName`) in a DSL block: ```ruby -query = Graphlient::Query.new do +client.query do query do invoice(id: 10) do id @@ -357,25 +358,13 @@ query = Graphlient::Query.new do end end end - -query.to_s -# "\nquery {\n invoice(id: 10){\n id\n ...InvoiceFields\n }\n}\n" ``` -This is useful when the fragment definition is managed externally — for example, -by a framework layer that appends fragment strings to the query before sending it -to the server, or when you are assembling queries programmatically and want to keep -the DSL clean. - -**Defining fragments alongside the query (proposed — coming in a future release)** - -A companion `fragment` DSL method is planned that lets you define fragment bodies -inline in the same block, so graphlient can assemble the complete query string -without any external tooling: +Define the fragment body inline with `fragment` — graphlient assembles the complete +query string automatically, no external tooling needed: ```ruby -response = client.query do - # Define the fragment inline — body generated by the same DSL +client.query do fragment(:InvoiceFields, on: :Invoice) do id feeInCents @@ -389,7 +378,7 @@ response = client.query do end ``` -This would produce and send: +Produces and sends: ```graphql query { @@ -404,8 +393,15 @@ fragment InvoiceFields on Invoice { } ``` -Fragments are scoped to the query call — no global registry, no cross-contamination -between requests. Multiple fragments and nested spreads are supported. +Multiple fragments are supported. Fragments are scoped to the query call — no global +registry, no cross-contamination between requests. + +You can also apply a directive to a spread (see [Directives in the DSL](#directives-in-the-dsl)): + +```ruby +spread :InvoiceFields, _skip(if: :skip_invoice) +# → ...InvoiceFields @skip(if: $skip_invoice) +``` ### Use of Fragments (graphql-client style) @@ -463,22 +459,129 @@ invoice.fee_in_cents # 20000 ``` -### Directives in the DSL (coming in a future release) +### Inline Fragments in the DSL + +Use `on(:TypeName)` for inline fragments (`... on Type { }`), useful for union +types and interface implementations: + +```ruby +client.query do + query do + invoice(id: 10) do + on(:PaidInvoice) do + amountPaid + end + on(:UnpaidInvoice) do + amountDue + end + end + end +end +``` + +Produces: + +```graphql +query { + invoice(id: 10) { + ... on PaidInvoice { + amountPaid + } + ... on UnpaidInvoice { + amountDue + } + } +} +``` + +Directives can be applied to inline fragments too (see [Directives in the DSL](#directives-in-the-dsl)): + +```ruby +on(:DraftInvoice, _skip(if: :skip_drafts)) { draftId } +# → ... on DraftInvoice @skip(if: $skip_drafts) { draftId } +``` + +### Directives in the DSL + +Apply GraphQL directives to fields, spreads, and inline fragments using the `_name` +convention — any method starting with `_` followed by a lowercase letter is treated +as a directive (`_skip` → `@skip`, `_include` → `@include`, `_myDirective` → `@myDirective`). -Support for inline directive syntax in DSL blocks is planned, allowing expressions -like the following without falling back to raw strings: +**On a field:** ```ruby -client.query(some_id: 42, skip_fee: true) do - query(some_id: :int, skip_fee: :boolean) do +client.query(some_id: :int, skip_fee: :boolean!) do + query(some_id: :int, skip_fee: :boolean!) do invoice(id: :some_id) do id - feeInCents _skip(if: :skip_fee) # → feeInCents @skip(if: $skip_fee) + feeInCents _skip(if: :skip_fee) # → feeInCents @skip(if: $skip_fee) end end end ``` +**On a fragment spread:** + +```ruby +spread :InvoiceFields, _skip(if: :skip_invoice) +# → ...InvoiceFields @skip(if: $skip_invoice) +``` + +**On an inline fragment:** + +```ruby +on(:DraftInvoice, _skip(if: :skip_drafts)) { draftId } +# → ... on DraftInvoice @skip(if: $skip_drafts) { draftId } +``` + +**Multiple directives on one field:** + +```ruby +feeInCents _skip(if: :skip_fee), _include(if: :show_cents) +# → feeInCents @skip(if: $skip_fee) @include(if: $show_cents) +``` + +**No-argument directive:** + +```ruby +legacyField _deprecated +# → legacyField @deprecated +``` + +The directive value is a plain Ruby method call that returns a `Directive` object +before the field method runs, ensuring the directive appears in the correct position +in the output string regardless of Ruby's evaluation order. + +### Custom Scalar Types + +By default, graphlient maps `:int → Int`, `:float → Float`, `:string → String`, and +`:boolean → Boolean` for variable type declarations. Register additional scalar types +in the client initialiser block: + +```ruby +client = Graphlient::Client.new('https://example.com/graphql') do |c| + c.scalar :date, 'Date' + c.scalar :uuid, 'UUID' + c.scalar :decimal, 'Decimal' +end +``` + +Use the registered symbol in variable declarations: + +```ruby +client.query(created_after: Date.today.iso8601, order_id: :uuid) do + query(created_after: :date, order_id: :uuid!) do + orders(created_after: :created_after, id: :order_id) do + id + total + end + end +end +# → query($created_after: Date, $order_id: UUID!) { ... } +``` + +Non-null variants work with `!`: `:date!` → `Date!`. + ### Create API Client Classes with Graphlient::Extension::Query You can include `Graphlient::Extensions::Query` in your class. This will add a new `method_missing` method to your context which will be used to generate GraphQL queries. diff --git a/spec/graphlient/client_dsl_spec.rb b/spec/graphlient/client_dsl_spec.rb index 00cfbf5..ff388ed 100644 --- a/spec/graphlient/client_dsl_spec.rb +++ b/spec/graphlient/client_dsl_spec.rb @@ -93,5 +93,58 @@ expect(result).to include('...FragmentB') end end + + it 'builds a query with a directive' do + result = client.to_query_string do + query(skip_fee: :boolean!) do + invoice(id: 10) do + id + feeInCents _skip(if: :skip_fee) + end + end + end + expect(result).to include('feeInCents @skip(if: $skip_fee)') + expect(result).to include('$skip_fee: Boolean!') + end + + it 'builds a query with an inline fragment' do + result = client.to_query_string do + query do + invoice(id: 10) do + on(:PaidInvoice) { amountPaid } + end + end + end + expect(result).to include('... on PaidInvoice') + expect(result).to include('amountPaid') + end + + it 'builds a query with an inline fragment definition' do + result = client.to_query_string do + fragment(:InvoiceFields, on: :Invoice) { id; feeInCents } + query { invoice(id: 10) { spread :InvoiceFields } } + end + expect(result).to include('...InvoiceFields') + expect(result).to include('fragment InvoiceFields on Invoice') + end + end + + describe '#scalar (custom scalar registration)' do + let(:client_with_scalars) do + described_class.new('http://example.com/graphql') do |c| + c.scalar :date, 'Date' + c.scalar :uuid, 'UUID' + end + end + + it 'registers custom scalars via the client block' do + result = client_with_scalars.to_query_string do + query(created_after: :date, id: :uuid!) do + invoices(created_after: :created_after) { total } + end + end + expect(result).to include('$created_after: Date') + expect(result).to include('$id: UUID!') + end end end From d1ad529746dd80475b3454e7a508c20f4f8c7ee7 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:55:59 +1200 Subject: [PATCH 10/32] chore: untrack CLAUDE.md + add to .gitignore AI agent instructions are local-only and not relevant to the upstream repo. CLAUDE.md is removed from git tracking but kept in the working directory. Added to .gitignore so future sessions don't accidentally commit it. --- .gitignore | 3 +++ CLAUDE.md | 68 ------------------------------------------------------ 2 files changed, 3 insertions(+), 68 deletions(-) delete mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index 45a54b0..49b7df3 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ Gemfile.lock # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: .rvmrc + +# AI agent instructions — local only, not for the upstream repo +CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 80f3a49..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,68 +0,0 @@ -# CLAUDE.md — graphlient (EcoPortal fork) - -AI agent instructions for this repository. - -**Cross-cutting architecture context lives in `ecoportal-api-graphql` — see its `CLAUDE.md` and `.claude/` folder for the full dependency map, project history, and shared skills.** - ---- - -## Repository Role - -This is **EcoPortal's fork** of `graphlient` — a friendly Ruby client wrapper around `graphql-client`. `ecoportal-api-graphql` depends on this fork; `Common::GraphQL::Client` inherits from `Graphlient::Client`. - -**Position in chain:** -``` -graphql-client (our fork) - ↓ -graphlient ← THIS REPO (our fork) - ↓ -ecoportal-api-graphql -``` - -**Our fork:** https://github.com/rellampec/graphlient.git -**Upstream:** https://github.com/ashkan18/graphlient.git - ---- - -## Fork Policy - -- Changes made here are **not automatically contributed upstream**. If a fix is general enough to benefit the upstream project, discuss with the developer before opening a PR upstream. -- When pulling upstream changes, check for conflicts with any local modifications first. -- Keep a note in `CHANGELOG.md` (or a fork-specific section) for any changes that diverge from upstream. - ---- - -## Key Folder Layout - -``` -lib/graphlient/ - client.rb Main Graphlient::Client class - adapters/http/ HTTP adapters — faraday_adapter, http_adapter - errors/ Error hierarchy (client, server, execution, timeout, etc.) - extensions/query.rb Query DSL extension - query.rb Query builder - schema.rb Schema introspection and caching -``` - ---- - -## Namespace - -`Graphlient::` — single top-level namespace. - ---- - -## Key Concerns - -- `Graphlient::Client` is the class `ecoportal-api-graphql`'s `Common::GraphQL::Client` inherits from. Its constructor signature, `#execute`, and error classes are the primary integration surface. -- `no_schema:` option (added or expected by our usage) — schema introspection is disabled in EcoPortal's GraphQL client. Verify this is handled correctly after any changes to `schema.rb`. -- Error classes are caught and re-raised by `ecoportal-api-graphql` — changes to the error hierarchy may break upstream error handling. - ---- - -## Running Tests - -```bash -bundle install -bundle exec rspec -``` From 1b6b0000ed31a764b7497da9f01187241b18d629 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:06:04 +1200 Subject: [PATCH 11/32] fix: use WebMock to_rack for Ruby 3.1 CI compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: on Ruby 3.1, WebMock's Faraday middleware intercepts requests before they reach Faraday::Adapter::Rack, returning 403 for any URL without a stub. This caused 25 CI failures on that Ruby version. spec/support/context/dummy_client.rb: Replace Faraday::Adapter::Rack with stub_request.to_rack(app). WebMock routes both schema introspection and query execution through the Sinatra dummy app at the WebMock level (before Faraday dispatch). Removed schema_path: so graphql-client gets the full live schema (including test fields: executionErrorInvoice, partialSuccess etc.). spec/graphlient/static_client_query_spec.rb: Same to_rack approach for execution tests (schema_path kept here since the module constants are created at file-load time, before any before block). Removed Faraday::Adapter::Rack from the static client constructor. spec/graphlient/client_schema_spec.rb: Use include instead of eq for error message — Faraday 2.x appends the URL to the message; include matches on both old and new formats. 73 examples, 0 failures --- spec/graphlient/client_schema_spec.rb | 2 +- spec/graphlient/static_client_query_spec.rb | 24 +++++++++++++-------- spec/support/context/dummy_client.rb | 21 ++++++++++++------ 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/spec/graphlient/client_schema_spec.rb b/spec/graphlient/client_schema_spec.rb index 342b9c2..cc84734 100644 --- a/spec/graphlient/client_schema_spec.rb +++ b/spec/graphlient/client_schema_spec.rb @@ -30,7 +30,7 @@ expect do client.schema end.to raise_error Graphlient::Errors::FaradayServerError do |e| - expect(e.to_s).to eq 'the server responded with status 500' + expect(e.to_s).to include('the server responded with status 500') expect(e.status_code).to eq 500 expect(e.response['errors'].size).to eq 1 expect(e.response['errors'].first['message']).to eq 'test message' diff --git a/spec/graphlient/static_client_query_spec.rb b/spec/graphlient/static_client_query_spec.rb index 84b5a43..c49eaa3 100644 --- a/spec/graphlient/static_client_query_spec.rb +++ b/spec/graphlient/static_client_query_spec.rb @@ -3,18 +3,15 @@ describe Graphlient::Client do describe 'parse and execute' do module Graphlient::Client::Spec + # schema_path avoids an HTTP schema fetch at file-load time. + # No Rack adapter needed here — parsing is local; execution is handled + # by the stub_request below. Client = Graphlient::Client.new( 'http://graph.biz/graphql', schema_path: 'spec/support/fixtures/invoice_api.json', headers: { 'Authorization' => 'Bearer 1231' }, allow_dynamic_queries: false - ) do |client| - client.http do |h| - h.connection do |c| - c.adapter Faraday::Adapter::Rack, Sinatra::Application - end - end - end + ) StringQuery = Client.parse <<~GRAPHQL query($some_id: Int) { @@ -35,6 +32,15 @@ module Graphlient::Client::Spec end end + # Route execution requests through the Sinatra dummy app via WebMock. + # to_rack works reliably across Ruby/gem versions; Faraday::Adapter::Rack + # can be intercepted before dispatch on some Ruby 3.x + WebMock combinations. + before do + stub_request(:post, 'http://graph.biz/graphql') + .with(headers: { 'Authorization' => 'Bearer 1231' }) + .to_rack(Sinatra::Application) + end + it 'defaults allow_dynamic_queries to false' do expect(Graphlient::Client::Spec::Client.send(:client).allow_dynamic_queries).to be false end @@ -51,7 +57,7 @@ module Graphlient::Client::Spec context 'with both string- and block-based queries' do it 'gets identical results parsing equivalent string- and block-based queries' do - block_response = Graphlient::Client::Spec::Client.execute(Graphlient::Client::Spec::BlockQuery, some_id: 42) + block_response = Graphlient::Client::Spec::Client.execute(Graphlient::Client::Spec::BlockQuery, some_id: 42) string_response = Graphlient::Client::Spec::Client.execute(Graphlient::Client::Spec::StringQuery, some_id: 42) expect(string_response.to_h).to eq block_response.to_h end @@ -60,7 +66,7 @@ module Graphlient::Client::Spec context 'executing a query' do it 'succeeds with expected feeInCents' do response = Graphlient::Client::Spec::Client.execute(Graphlient::Client::Spec::BlockQuery, some_id: 42) - invoice = response.data.invoice + invoice = response.data.invoice expect(invoice.id).to eq '42' expect(invoice.fee_in_cents).to eq 20_000 end diff --git a/spec/support/context/dummy_client.rb b/spec/support/context/dummy_client.rb index 4101bff..1298e10 100644 --- a/spec/support/context/dummy_client.rb +++ b/spec/support/context/dummy_client.rb @@ -14,13 +14,20 @@ def app } end + # Route all requests through the Sinatra dummy app via WebMock's to_rack adapter. + # This is more reliable than configuring Faraday::Adapter::Rack directly because + # WebMock intercepts Faraday requests before they reach the adapter layer on some + # Ruby/gem version combinations — to_rack routes at the WebMock level instead. + before do + stub_request(:post, endpoint) + .with(headers: { 'Authorization' => 'Bearer 1231' }) + .to_rack(app) + end + + # No schema_path — the to_rack stub above routes schema introspection to the + # Sinatra app as well, so graphql-client gets the full live schema including + # test-only fields (executionErrorInvoice, partialSuccess etc.). let(:client) do - Graphlient::Client.new(endpoint, headers: headers) do |client| - client.http do |h| - h.connection do |c| - c.adapter Faraday::Adapter::Rack, app - end - end - end + Graphlient::Client.new(endpoint, headers: headers) end end From ad0f14c4485914be683d7ee9b29485af790b5200 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:50:37 +1200 Subject: [PATCH 12/32] fix: rubocop AsciiComments + Ruby 3.4 mutex_m + add 3.4 to CI matrix spec/support/context/dummy_client.rb: spec/graphlient/static_client_query_spec.rb: Replace em-dashes (--) with ASCII semicolons/colons in comments. Style/AsciiComments cop requires pure ASCII. Gemfile: Add mutex_m gem -- activesupport 5.x requires mutex_m which was removed from Ruby's standard library in Ruby 3.4. Adding it explicitly silences the warning and fixes the LoadError on Ruby 3.4. .github/workflows/ci.yml: Add Ruby 3.4 to the required CI matrix (was previously only in ruby-head which is allowed to fail). Ruby 3.4 is now released and should be tested. --- .github/workflows/ci.yml | 1 + Gemfile | 1 + spec/graphlient/static_client_query_spec.rb | 2 +- spec/support/context/dummy_client.rb | 4 ++-- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d89ab2..f690114 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,7 @@ jobs: - { ruby: 3.1 } - { ruby: 3.2 } - { ruby: 3.3 } + - { ruby: 3.4 } - { ruby: "ruby-head", ignore: true } - { ruby: "jruby-9.4.8.0", ignore: true } - { ruby: "jruby-head", ignore: true } diff --git a/Gemfile b/Gemfile index adf946b..fde89be 100644 --- a/Gemfile +++ b/Gemfile @@ -6,6 +6,7 @@ gem 'rake' group :development, :test do gem 'activesupport', '< 6' + gem 'mutex_m' # activesupport 5.x depends on mutex_m, removed from stdlib in Ruby 3.4 end group :development do diff --git a/spec/graphlient/static_client_query_spec.rb b/spec/graphlient/static_client_query_spec.rb index c49eaa3..b3b560f 100644 --- a/spec/graphlient/static_client_query_spec.rb +++ b/spec/graphlient/static_client_query_spec.rb @@ -4,7 +4,7 @@ describe 'parse and execute' do module Graphlient::Client::Spec # schema_path avoids an HTTP schema fetch at file-load time. - # No Rack adapter needed here — parsing is local; execution is handled + # No Rack adapter needed here; parsing is local and execution is handled # by the stub_request below. Client = Graphlient::Client.new( 'http://graph.biz/graphql', diff --git a/spec/support/context/dummy_client.rb b/spec/support/context/dummy_client.rb index 1298e10..7433dbf 100644 --- a/spec/support/context/dummy_client.rb +++ b/spec/support/context/dummy_client.rb @@ -17,14 +17,14 @@ def app # Route all requests through the Sinatra dummy app via WebMock's to_rack adapter. # This is more reliable than configuring Faraday::Adapter::Rack directly because # WebMock intercepts Faraday requests before they reach the adapter layer on some - # Ruby/gem version combinations — to_rack routes at the WebMock level instead. + # Ruby/gem version combinations; to_rack routes at the WebMock level instead. before do stub_request(:post, endpoint) .with(headers: { 'Authorization' => 'Bearer 1231' }) .to_rack(app) end - # No schema_path — the to_rack stub above routes schema introspection to the + # No schema_path: the to_rack stub above routes schema introspection to the # Sinatra app as well, so graphql-client gets the full live schema including # test-only fields (executionErrorInvoice, partialSuccess etc.). let(:client) do From 5712a3ff76b44a087a4e3854cb4868b7101e9e7b Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:59:53 +1200 Subject: [PATCH 13/32] docs: document to_query_string and spread in README New sections: 'Build Query Strings without Validation' -- explains the serialization-only path (no schema load, no graphql-client validation, no HTTP), shows the basic usage, clarifies when the string can be fed back to client.execute (fragment-free queries only), explains why fragment spreads cannot go back through the gem (graphql-client's constant-based fragment model), and shows how to_query_string is the escape hatch for replacing graphql-client with your own HTTP transport entirely. 'Fragment Spreads' -- documents Query#spread as the modern alternative to the triple-underscore convention; covers basic usage and multiple spreads. --- README.md | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/README.md b/README.md index 9789209..143ac8d 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,10 @@ A friendlier Ruby client for consuming GraphQL-based APIs. Built on top of your - [Error Handling](#error-handling) - [Executing Parameterized Queries and Mutations](#executing-parameterized-queries-and-mutations) - [Parse and Execute Queries Separately](#parse-and-execute-queries-separately) + - [Build Query Strings without Validation](#build-query-strings-without-validation) - [Dynamic vs. Static Queries](#dynamic-vs-static-queries) - [Generate Queries with Graphlient::Query](#generate-queries-with-graphlientquery) + - [Fragment Spreads](#fragment-spreads) - [Create API Client Classes with Graphlient::Extension::Query](#create-api-client-classes-with-graphlientextensionquery) - [Swapping the HTTP Stack](#swapping-the-http-stack) - [Testing with Graphlient and RSpec](#testing-with-graphlient-and-rspec) @@ -278,6 +280,53 @@ GRAPHQL client.execute query, ids: [42] ``` +### Build Query Strings without Validation + +`Client#to_query_string` serializes a DSL block into a GraphQL query string and +returns it as a plain `String`. It uses the same DSL serializer (`Graphlient::Query`) +that powers `client.query` and `client.parse`, but it stops there — no schema is +loaded, no graphql-client validation runs, no HTTP call is made. + +```ruby +client = Graphlient::Client.new('https://example.com/graphql', + headers: { 'Authorization' => 'Bearer 123' } +) + +query_str = client.to_query_string do + query(id: :int) do + invoice(id: :id) do + id + feeInCents + end + end +end + +# => "query($id: Int){\n invoice(id: $id){\n id\n feeInCents\n }\n }" +``` + +For fragment-free queries the string can be fed back to `client.execute`: + +```ruby +client.execute(query_str, id: 42) +``` + +Queries containing fragment spreads (`spread :Name` → `...Name`) cannot go back +through the gem — graphql-client requires fragments to be pre-registered module +constants, not named strings. Pass those directly to your own HTTP client instead. + +This is also the escape hatch if you want to replace the graphql-client dependency +entirely. `to_query_string` gives you a standard GraphQL document — from there you +own the transport: Faraday, Net::HTTP, anything else. You get full control over +headers, retries, connection pooling, and middleware without any graphql-client +overhead. + +```ruby +conn = Faraday.new('https://example.com/graphql', + headers: { 'Authorization' => 'Bearer 123', 'Content-Type' => 'application/json' } +) +response = conn.post('/', { query: query_str, variables: { id: 42 } }.to_json) +``` + ### Dynamic vs. Static Queries Graphlient uses [graphql-client](https://github.com/github-community-projects/graphql-client), which [recommends](https://github.com/github-community-projects/graphql-client/blob/master/guides/dynamic-query-error.md) building queries as static module members along with dynamic variables during execution. This can be accomplished with graphlient the same way. @@ -397,6 +446,40 @@ invoice.fee_in_cents # 20000 ``` +### Fragment Spreads + +Use `spread` to emit a named fragment spread (`...FragmentName`) inside a DSL block. +This is a cleaner alternative to the `___Const__Name` triple-underscore convention. + +```ruby +query_str = client.to_query_string do + query do + invoice(id: 10) do + id + spread :InvoiceFields # → ...InvoiceFields + end + end +end +``` + +Multiple spreads at the same level are supported: + +```ruby +client.to_query_string do + query do + invoice(id: 10) do + spread :CoreFields + spread :AuditFields + end + end +end +``` + +`spread` works in both `to_query_string` and the standard `parse`/`query` execution +paths. The caller is responsible for appending the fragment definition to the query +string before sending it to the server, or for using a framework layer that handles +fragment assembly automatically. + ### Create API Client Classes with Graphlient::Extension::Query You can include `Graphlient::Extensions::Query` in your class. This will add a new `method_missing` method to your context which will be used to generate GraphQL queries. From 3a30761648bd8de3fed279a58ee6ac648febf224 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:08:26 +1200 Subject: [PATCH 14/32] docs: update CHANGELOG for directives-dsl-support Adds entries for: directive DSL (_name convention), on(:Type) inline fragments, fragment(:Name, on:) definitions, custom scalar registration, Query::Serializer refactor, Ruby 3.1/3.4 CI fixes. --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 050fbd4..49f9da2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ * `Query#spread(fragment_name)`: emits `...FragmentName` into the query string as a named fragment spread. Alternative to the `___Const` convention for use cases where `graphql-client` constant parsing is not available - [@rellampec](https://github.com/rellampec). +* Directive DSL (`_name` convention): any method starting with `_` followed by a lowercase + letter is treated as a GraphQL directive — `_skip(if: :x)` → `@skip(if: $x)`, + `_include(if: :x)` → `@include(if: $x)`, custom directives supported. Directives can be + applied to fields, fragment spreads, and inline fragments - [@rellampec](https://github.com/rellampec). +* `on(:TypeName, *directives, &block)`: inline fragment DSL — `on(:PaidInvoice) { amountPaid }` + → `... on PaidInvoice { amountPaid }`. Accepts directive arguments the same way as fields + - [@rellampec](https://github.com/rellampec). +* `fragment(:Name, on: :Type, &block)`: inline fragment definition — defines a named fragment + body using the same DSL and appends it to the query document automatically. Fragments are + scoped to the query call; no global registry - [@rellampec](https://github.com/rellampec). +* Custom scalar registration: `client = Graphlient::Client.new(url) { |c| c.scalar :date, 'Date' }` + — registers additional scalar types for use in variable declarations (`:date` → `Date`, + `:uuid` → `UUID`, etc.) alongside the built-in `:int`, `:float`, `:string`, `:boolean` + - [@rellampec](https://github.com/rellampec). +* `Query` refactored into `Query::Serializer` composed from focused concern modules + (`Scalars`, `Arguments`, `Evaluator`, `Fragments`, `Directives`). Public API unchanged + - [@rellampec](https://github.com/rellampec). +* CI: added Ruby 3.4 to the required test matrix; fixed Ruby 3.1 compatibility via + WebMock `stub_request.to_rack`; fixed Ruby 3.4 `mutex_m` dependency + - [@rellampec](https://github.com/rellampec). * `spec/spec_helper.rb`: rescue `LoadError` on `byebug` require so specs run on platforms where byebug is not installable (e.g. Windows with Ruby 3.2) - [@rellampec](https://github.com/rellampec). From 62b5aca76ba3752f9e130b1432a0646c63622ceb Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:10:47 +1200 Subject: [PATCH 15/32] docs: fix CHANGELOG continuation line indentation (4 spaces) --- CHANGELOG.md | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49f9da2..948833d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,33 +3,33 @@ * [#113](https://github.com/ashkan18/graphlient/pull/113): Fix CI builds - [@yuki24](https://github.com/yuki24). * [#112](https://github.com/ashkan18/graphlient/pull/112): Update graphql-client github repository links in README - [@th1988](https://github.com/th1988). * `Client#to_query_string(**kargs, &block)`: builds the full GraphQL query document from the - DSL block and returns a `String` without touching the schema or Faraday HTTP adapter. - Enables DSL-only mode where HTTP dispatch is handled externally - [@rellampec](https://github.com/rellampec). + DSL block and returns a `String` without touching the schema or Faraday HTTP adapter. + Enables DSL-only mode where HTTP dispatch is handled externally - [@rellampec](https://github.com/rellampec). * `Query#spread(fragment_name)`: emits `...FragmentName` into the query string as a named - fragment spread. Alternative to the `___Const` convention for use cases where - `graphql-client` constant parsing is not available - [@rellampec](https://github.com/rellampec). + fragment spread. Alternative to the `___Const` convention for use cases where + `graphql-client` constant parsing is not available - [@rellampec](https://github.com/rellampec). * Directive DSL (`_name` convention): any method starting with `_` followed by a lowercase - letter is treated as a GraphQL directive — `_skip(if: :x)` → `@skip(if: $x)`, - `_include(if: :x)` → `@include(if: $x)`, custom directives supported. Directives can be - applied to fields, fragment spreads, and inline fragments - [@rellampec](https://github.com/rellampec). + letter is treated as a GraphQL directive — `_skip(if: :x)` → `@skip(if: $x)`, + `_include(if: :x)` → `@include(if: $x)`, custom directives supported. Directives can be + applied to fields, fragment spreads, and inline fragments - [@rellampec](https://github.com/rellampec). * `on(:TypeName, *directives, &block)`: inline fragment DSL — `on(:PaidInvoice) { amountPaid }` - → `... on PaidInvoice { amountPaid }`. Accepts directive arguments the same way as fields - - [@rellampec](https://github.com/rellampec). + → `... on PaidInvoice { amountPaid }`. Accepts directive arguments the same way as fields + - [@rellampec](https://github.com/rellampec). * `fragment(:Name, on: :Type, &block)`: inline fragment definition — defines a named fragment - body using the same DSL and appends it to the query document automatically. Fragments are - scoped to the query call; no global registry - [@rellampec](https://github.com/rellampec). + body using the same DSL and appends it to the query document automatically. Fragments are + scoped to the query call; no global registry - [@rellampec](https://github.com/rellampec). * Custom scalar registration: `client = Graphlient::Client.new(url) { |c| c.scalar :date, 'Date' }` - — registers additional scalar types for use in variable declarations (`:date` → `Date`, - `:uuid` → `UUID`, etc.) alongside the built-in `:int`, `:float`, `:string`, `:boolean` - - [@rellampec](https://github.com/rellampec). + — registers additional scalar types for use in variable declarations (`:date` → `Date`, + `:uuid` → `UUID`, etc.) alongside the built-in `:int`, `:float`, `:string`, `:boolean` + - [@rellampec](https://github.com/rellampec). * `Query` refactored into `Query::Serializer` composed from focused concern modules - (`Scalars`, `Arguments`, `Evaluator`, `Fragments`, `Directives`). Public API unchanged - - [@rellampec](https://github.com/rellampec). + (`Scalars`, `Arguments`, `Evaluator`, `Fragments`, `Directives`). Public API unchanged + - [@rellampec](https://github.com/rellampec). * CI: added Ruby 3.4 to the required test matrix; fixed Ruby 3.1 compatibility via - WebMock `stub_request.to_rack`; fixed Ruby 3.4 `mutex_m` dependency - - [@rellampec](https://github.com/rellampec). + WebMock `stub_request.to_rack`; fixed Ruby 3.4 `mutex_m` dependency + - [@rellampec](https://github.com/rellampec). * `spec/spec_helper.rb`: rescue `LoadError` on `byebug` require so specs run on platforms - where byebug is not installable (e.g. Windows with Ruby 3.2) - [@rellampec](https://github.com/rellampec). + where byebug is not installable (e.g. Windows with Ruby 3.2) - [@rellampec](https://github.com/rellampec). ### 0.8.0 (2024/01/06) * [#110](https://github.com/ashkan18/graphlient/pull/110): Ensure correct Faraday JSON response body parsing with invalid response header - [@taylorthurlow](https://github.com/taylorthurlow). From 7e938a2697d572e2ed70fc896b5fe4917d73a890 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:14:10 +1200 Subject: [PATCH 16/32] docs: fix CHANGELOG continuation line indentation (4 spaces) --- CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 050fbd4..d619fc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,13 +3,13 @@ * [#113](https://github.com/ashkan18/graphlient/pull/113): Fix CI builds - [@yuki24](https://github.com/yuki24). * [#112](https://github.com/ashkan18/graphlient/pull/112): Update graphql-client github repository links in README - [@th1988](https://github.com/th1988). * `Client#to_query_string(**kargs, &block)`: builds the full GraphQL query document from the - DSL block and returns a `String` without touching the schema or Faraday HTTP adapter. - Enables DSL-only mode where HTTP dispatch is handled externally - [@rellampec](https://github.com/rellampec). + DSL block and returns a `String` without touching the schema or Faraday HTTP adapter. + Enables DSL-only mode where HTTP dispatch is handled externally - [@rellampec](https://github.com/rellampec). * `Query#spread(fragment_name)`: emits `...FragmentName` into the query string as a named - fragment spread. Alternative to the `___Const` convention for use cases where - `graphql-client` constant parsing is not available - [@rellampec](https://github.com/rellampec). + fragment spread. Alternative to the `___Const` convention for use cases where + `graphql-client` constant parsing is not available - [@rellampec](https://github.com/rellampec). * `spec/spec_helper.rb`: rescue `LoadError` on `byebug` require so specs run on platforms - where byebug is not installable (e.g. Windows with Ruby 3.2) - [@rellampec](https://github.com/rellampec). + where byebug is not installable (e.g. Windows with Ruby 3.2) - [@rellampec](https://github.com/rellampec). ### 0.8.0 (2024/01/06) * [#110](https://github.com/ashkan18/graphlient/pull/110): Ensure correct Faraday JSON response body parsing with invalid response header - [@taylorthurlow](https://github.com/taylorthurlow). From 112a99d5688441000e4ab750befc726229e1a14a Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:18:23 +1200 Subject: [PATCH 17/32] docs: add PR #115 reference to CHANGELOG entries --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d619fc0..6377bfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,13 @@ * Your contribution here. * [#113](https://github.com/ashkan18/graphlient/pull/113): Fix CI builds - [@yuki24](https://github.com/yuki24). * [#112](https://github.com/ashkan18/graphlient/pull/112): Update graphql-client github repository links in README - [@th1988](https://github.com/th1988). -* `Client#to_query_string(**kargs, &block)`: builds the full GraphQL query document from the +* [#115](https://github.com/ashkan18/graphlient/pull/115): `Client#to_query_string(**kargs, &block)`: builds the full GraphQL query document from the DSL block and returns a `String` without touching the schema or Faraday HTTP adapter. Enables DSL-only mode where HTTP dispatch is handled externally - [@rellampec](https://github.com/rellampec). -* `Query#spread(fragment_name)`: emits `...FragmentName` into the query string as a named +* [#115](https://github.com/ashkan18/graphlient/pull/115): `Query#spread(fragment_name)`: emits `...FragmentName` into the query string as a named fragment spread. Alternative to the `___Const` convention for use cases where `graphql-client` constant parsing is not available - [@rellampec](https://github.com/rellampec). -* `spec/spec_helper.rb`: rescue `LoadError` on `byebug` require so specs run on platforms +* [#115](https://github.com/ashkan18/graphlient/pull/115): `spec/spec_helper.rb`: rescue `LoadError` on `byebug` require so specs run on platforms where byebug is not installable (e.g. Windows with Ruby 3.2) - [@rellampec](https://github.com/rellampec). ### 0.8.0 (2024/01/06) From 34267ba4e44c9a90a0b82153f5ff2859839f3955 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:56:05 +1200 Subject: [PATCH 18/32] docs: clean up CHANGELOG for PR #116 - All entries single-line matching file convention (no continuation lines) - #115 entries restored to their original order and not modified - #116 prefix added to all directives/fragments/scalars/CI entries --- CHANGELOG.md | 37 +++++++++---------------------------- 1 file changed, 9 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea5de93..cc246a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,34 +2,15 @@ * Your contribution here. * [#113](https://github.com/ashkan18/graphlient/pull/113): Fix CI builds - [@yuki24](https://github.com/yuki24). * [#112](https://github.com/ashkan18/graphlient/pull/112): Update graphql-client github repository links in README - [@th1988](https://github.com/th1988). -* [#115](https://github.com/ashkan18/graphlient/pull/115): `Client#to_query_string(**kargs, &block)`: builds the full GraphQL query document from the - DSL block and returns a `String` without touching the schema or Faraday HTTP adapter. - Enables DSL-only mode where HTTP dispatch is handled externally - [@rellampec](https://github.com/rellampec). -* [#115](https://github.com/ashkan18/graphlient/pull/115): `Query#spread(fragment_name)`: emits `...FragmentName` into the query string as a named - fragment spread. Alternative to the `___Const` convention for use cases where - `graphql-client` constant parsing is not available - [@rellampec](https://github.com/rellampec). -* Directive DSL (`_name` convention): any method starting with `_` followed by a lowercase - letter is treated as a GraphQL directive — `_skip(if: :x)` → `@skip(if: $x)`, - `_include(if: :x)` → `@include(if: $x)`, custom directives supported. Directives can be - applied to fields, fragment spreads, and inline fragments - [@rellampec](https://github.com/rellampec). -* `on(:TypeName, *directives, &block)`: inline fragment DSL — `on(:PaidInvoice) { amountPaid }` - → `... on PaidInvoice { amountPaid }`. Accepts directive arguments the same way as fields - - [@rellampec](https://github.com/rellampec). -* `fragment(:Name, on: :Type, &block)`: inline fragment definition — defines a named fragment - body using the same DSL and appends it to the query document automatically. Fragments are - scoped to the query call; no global registry - [@rellampec](https://github.com/rellampec). -* Custom scalar registration: `client = Graphlient::Client.new(url) { |c| c.scalar :date, 'Date' }` - — registers additional scalar types for use in variable declarations (`:date` → `Date`, - `:uuid` → `UUID`, etc.) alongside the built-in `:int`, `:float`, `:string`, `:boolean` - - [@rellampec](https://github.com/rellampec). -* `Query` refactored into `Query::Serializer` composed from focused concern modules - (`Scalars`, `Arguments`, `Evaluator`, `Fragments`, `Directives`). Public API unchanged - - [@rellampec](https://github.com/rellampec). -* CI: added Ruby 3.4 to the required test matrix; fixed Ruby 3.1 compatibility via - WebMock `stub_request.to_rack`; fixed Ruby 3.4 `mutex_m` dependency - - [@rellampec](https://github.com/rellampec). -* [#115](https://github.com/ashkan18/graphlient/pull/115): `spec/spec_helper.rb`: rescue `LoadError` on `byebug` require so specs run on platforms - where byebug is not installable (e.g. Windows with Ruby 3.2) - [@rellampec](https://github.com/rellampec). +* [#115](https://github.com/ashkan18/graphlient/pull/115): `Client#to_query_string(**kargs, &block)`: serializes a DSL block to a `String` without schema loading or HTTP — enables DSL-only mode and external HTTP dispatch - [@rellampec](https://github.com/rellampec). +* [#115](https://github.com/ashkan18/graphlient/pull/115): `Query#spread(fragment_name)`: emits `...FragmentName` in the query string — cleaner alternative to the `___Const` triple-underscore convention - [@rellampec](https://github.com/rellampec). +* [#115](https://github.com/ashkan18/graphlient/pull/115): `spec/spec_helper.rb`: rescue `LoadError` on `byebug` require for platforms where byebug is unavailable (e.g. Windows Ruby 3.2) - [@rellampec](https://github.com/rellampec). +* [#116](https://github.com/ashkan18/graphlient/pull/116): Directive DSL — `_skip(if: :x)` → `@skip(if: $x)`, `_include(if: :x)` → `@include(if: $x)`. Any `_name` method is treated as a `@name` directive; applies to fields, spreads, and inline fragments - [@rellampec](https://github.com/rellampec). +* [#116](https://github.com/ashkan18/graphlient/pull/116): `on(:TypeName, *directives, &block)`: inline fragment DSL — `on(:PaidInvoice) { amountPaid }` → `... on PaidInvoice { amountPaid }` - [@rellampec](https://github.com/rellampec). +* [#116](https://github.com/ashkan18/graphlient/pull/116): `fragment(:Name, on: :Type, &block)`: inline fragment definition appended to the query document automatically; scoped to the query call, no global registry - [@rellampec](https://github.com/rellampec). +* [#116](https://github.com/ashkan18/graphlient/pull/116): Custom scalar registration — `c.scalar :date, 'Date'` extends the built-in scalar set (`:int`, `:float`, `:string`, `:boolean`) with API-specific types - [@rellampec](https://github.com/rellampec). +* [#116](https://github.com/ashkan18/graphlient/pull/116): `Query` refactored into `Query::Serializer` composed from concern modules (`Scalars`, `Arguments`, `Evaluator`, `Fragments`, `Directives`). Public API unchanged - [@rellampec](https://github.com/rellampec). +* [#116](https://github.com/ashkan18/graphlient/pull/116): CI: Ruby 3.4 added to test matrix; Ruby 3.1 WebMock `to_rack` fix; `mutex_m` gem added for Ruby 3.4 stdlib removal - [@rellampec](https://github.com/rellampec). ### 0.8.0 (2024/01/06) * [#110](https://github.com/ashkan18/graphlient/pull/110): Ensure correct Faraday JSON response body parsing with invalid response header - [@taylorthurlow](https://github.com/taylorthurlow). From 98a88127044c7d4586e614b66d5c5a89ea4e7d75 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:00:02 +1200 Subject: [PATCH 19/32] don't change changelog --- CHANGELOG.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc246a0..00b394e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,14 @@ * Your contribution here. * [#113](https://github.com/ashkan18/graphlient/pull/113): Fix CI builds - [@yuki24](https://github.com/yuki24). * [#112](https://github.com/ashkan18/graphlient/pull/112): Update graphql-client github repository links in README - [@th1988](https://github.com/th1988). -* [#115](https://github.com/ashkan18/graphlient/pull/115): `Client#to_query_string(**kargs, &block)`: serializes a DSL block to a `String` without schema loading or HTTP — enables DSL-only mode and external HTTP dispatch - [@rellampec](https://github.com/rellampec). -* [#115](https://github.com/ashkan18/graphlient/pull/115): `Query#spread(fragment_name)`: emits `...FragmentName` in the query string — cleaner alternative to the `___Const` triple-underscore convention - [@rellampec](https://github.com/rellampec). -* [#115](https://github.com/ashkan18/graphlient/pull/115): `spec/spec_helper.rb`: rescue `LoadError` on `byebug` require for platforms where byebug is unavailable (e.g. Windows Ruby 3.2) - [@rellampec](https://github.com/rellampec). +* [#115](https://github.com/ashkan18/graphlient/pull/115): `Client#to_query_string(**kargs, &block)`: builds the full GraphQL query document from the + DSL block and returns a `String` without touching the schema or Faraday HTTP adapter. + Enables DSL-only mode where HTTP dispatch is handled externally - [@rellampec](https://github.com/rellampec). +* [#115](https://github.com/ashkan18/graphlient/pull/115): `Query#spread(fragment_name)`: emits `...FragmentName` into the query string as a named + fragment spread. Alternative to the `___Const` convention for use cases where + `graphql-client` constant parsing is not available - [@rellampec](https://github.com/rellampec). +* [#115](https://github.com/ashkan18/graphlient/pull/115): `spec/spec_helper.rb`: rescue `LoadError` on `byebug` require so specs run on platforms + where byebug is not installable (e.g. Windows with Ruby 3.2) - [@rellampec](https://github.com/rellampec). * [#116](https://github.com/ashkan18/graphlient/pull/116): Directive DSL — `_skip(if: :x)` → `@skip(if: $x)`, `_include(if: :x)` → `@include(if: $x)`. Any `_name` method is treated as a `@name` directive; applies to fields, spreads, and inline fragments - [@rellampec](https://github.com/rellampec). * [#116](https://github.com/ashkan18/graphlient/pull/116): `on(:TypeName, *directives, &block)`: inline fragment DSL — `on(:PaidInvoice) { amountPaid }` → `... on PaidInvoice { amountPaid }` - [@rellampec](https://github.com/rellampec). * [#116](https://github.com/ashkan18/graphlient/pull/116): `fragment(:Name, on: :Type, &block)`: inline fragment definition appended to the query document automatically; scoped to the query call, no global registry - [@rellampec](https://github.com/rellampec). From 836fa426248155fc7f2e9cf4a15274843560e675 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:04:43 +1200 Subject: [PATCH 20/32] fix: close unclosed code block in Directives section of README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 5d829f8..52fb976 100644 --- a/README.md +++ b/README.md @@ -563,6 +563,10 @@ client.query(some_id: :int, skip_fee: :boolean!) do invoice(id: :some_id) do id feeInCents _skip(if: :skip_fee) # → feeInCents @skip(if: $skip_fee) + end + end +end +``` **On a fragment spread:** From ee3c55d338547f6ffb7487934e25edac04c97199 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:13:28 +1200 Subject: [PATCH 21/32] style: fix rubocop 0.56 offenses on feature/directives-dsl-support --- lib/graphlient/query/directive.rb | 10 +++++----- lib/graphlient/query/serializer.rb | 8 ++++---- lib/graphlient/query/serializer/arguments.rb | 2 +- lib/graphlient/query/serializer/directives.rb | 2 +- lib/graphlient/query/serializer/scalars.rb | 2 +- spec/graphlient/client_dsl_spec.rb | 5 ++++- spec/graphlient/query_dsl_spec.rb | 9 ++++++--- 7 files changed, 22 insertions(+), 16 deletions(-) diff --git a/lib/graphlient/query/directive.rb b/lib/graphlient/query/directive.rb index 93314a8..9aa5349 100644 --- a/lib/graphlient/query/directive.rb +++ b/lib/graphlient/query/directive.rb @@ -3,19 +3,19 @@ class Query # Value object returned by `_directive_name(args)` in the DSL. # # Rather than writing directly to the query string (which would produce - # the wrong position — before the field name), _skip / _include etc. + # the wrong position -- before the field name), _skip / _include etc. # return a Directive instance. append_node / spread / on detect Directive # objects in their argument list and place them AFTER the field name, # producing correct GraphQL output: # # feeInCents _skip(if: :skip_fee) - # → feeInCents @skip(if: $skip_fee) + # -> feeInCents @skip(if: $skip_fee) # # spread :InvoiceFields, _skip(if: :x) - # → ...InvoiceFields @skip(if: $x) + # -> ...InvoiceFields @skip(if: $x) # # on(:DraftInvoice, _skip(if: :x)) { draft_id } - # → ... on DraftInvoice @skip(if: $x) { draftId } + # -> ... on DraftInvoice @skip(if: $x) { draftId } class Directive attr_reader :name, :args @@ -39,7 +39,7 @@ def format_value(value) when Symbol then "$#{value}" when String then "\"#{value}\"" when Numeric, TrueClass, FalseClass then value.to_s - else value.to_s + else value.to_s end end end diff --git a/lib/graphlient/query/serializer.rb b/lib/graphlient/query/serializer.rb index 8c39e75..08af83e 100644 --- a/lib/graphlient/query/serializer.rb +++ b/lib/graphlient/query/serializer.rb @@ -44,7 +44,7 @@ def to_s # Named fragment spread: ...FragmentName [@directive ...] # spread :InvoiceFields - # spread :InvoiceFields, _skip(if: :x) # → ...InvoiceFields @skip(if: $x) + # spread :InvoiceFields, _skip(if: :x) # -> ...InvoiceFields @skip(if: $x) def spread(fragment_name, *args) directives = args.select { |a| a.is_a?(Directive) } @query_str << "\n#{indent}...#{fragment_name}" @@ -69,14 +69,14 @@ def on(type, *args, &block) @query_str << "\n#{indent}" end - # Inline fragment definition — collected and appended after the main query. + # Inline fragment definition -- collected and appended after the main query. # fragment(:InvoiceFields, on: :Invoice) { id; fee_in_cents } def fragment(name, on:, &block) body = self.class.new(&block).query_str.strip @fragments[name] = "fragment #{name} on #{on} {\n#{body}\n}" end - def method_missing(method_name, *args, &block) + def method_missing(method_name, *args, &block) # rubocop:disable Style/MethodMissingSuper if fragment?(method_name) append_node("...#{resolve_fragment_constant(method_name)}".to_sym, args, &block) elsif directive?(method_name) @@ -96,7 +96,7 @@ def indent ' ' * @indents end - def append_node(node, args, arg_processor: nil, &block) # rubocop:disable Metrics/MethodLength + def append_node(node, args, arg_processor: nil, &block) regular = field_args(args) dirs = directive_args(args) diff --git a/lib/graphlient/query/serializer/arguments.rb b/lib/graphlient/query/serializer/arguments.rb index 7e8d821..568a5f1 100644 --- a/lib/graphlient/query/serializer/arguments.rb +++ b/lib/graphlient/query/serializer/arguments.rb @@ -9,7 +9,7 @@ def variable_processor end # Translate a DSL variable type symbol to its GraphQL type string. - # :id/:id! → ID/ID! :int → Int :date → Date (if registered) [:int] → [Int] + # :id/:id! -> ID/ID! :int -> Int :date -> Date (if registered) [:int] -> [Int] def variable_string(val) case val when :id, :id! diff --git a/lib/graphlient/query/serializer/directives.rb b/lib/graphlient/query/serializer/directives.rb index e0e2456..711a297 100644 --- a/lib/graphlient/query/serializer/directives.rb +++ b/lib/graphlient/query/serializer/directives.rb @@ -3,7 +3,7 @@ class Query class Serializer module Directives # Matches _skip, _include, _myCustomDirective etc. - # Does NOT match ___Fragment (three underscores — checked first in method_missing). + # Does NOT match ___Fragment (three underscores -- checked first in method_missing). DIRECTIVE_PREFIX = /\A_[a-z]/ private diff --git a/lib/graphlient/query/serializer/scalars.rb b/lib/graphlient/query/serializer/scalars.rb index 6e04729..f5f1289 100644 --- a/lib/graphlient/query/serializer/scalars.rb +++ b/lib/graphlient/query/serializer/scalars.rb @@ -26,7 +26,7 @@ module ClassMethods # end # # After registration, use the symbol in variable declarations: - # query(created_after: :date) { ... } # → query($createdAfter: Date) + # query(created_after: :date) { ... } # -> query($createdAfter: Date) def scalar(sym, graphql_type) custom_scalar_types[sym.to_sym] = graphql_type.to_s end diff --git a/spec/graphlient/client_dsl_spec.rb b/spec/graphlient/client_dsl_spec.rb index ff388ed..7aec261 100644 --- a/spec/graphlient/client_dsl_spec.rb +++ b/spec/graphlient/client_dsl_spec.rb @@ -121,7 +121,10 @@ it 'builds a query with an inline fragment definition' do result = client.to_query_string do - fragment(:InvoiceFields, on: :Invoice) { id; feeInCents } + fragment(:InvoiceFields, on: :Invoice) do + id + feeInCents + end query { invoice(id: 10) { spread :InvoiceFields } } end expect(result).to include('...InvoiceFields') diff --git a/spec/graphlient/query_dsl_spec.rb b/spec/graphlient/query_dsl_spec.rb index c41c309..49c840c 100644 --- a/spec/graphlient/query_dsl_spec.rb +++ b/spec/graphlient/query_dsl_spec.rb @@ -2,7 +2,7 @@ # Specs for DSL extensions: directives, inline fragments, fragment definitions, # spread + directive, and custom scalar registration. -# These operate on the Query string builder only — no HTTP or schema access. +# These operate on the Query string builder only -- no HTTP or schema access. RSpec.describe Graphlient::Query do def build(&block) @@ -168,7 +168,10 @@ def build(&block) result = build do fragment(:FragA, on: :TypeA) { field_a } fragment(:FragB, on: :TypeB) { field_b } - query { spread :FragA; spread :FragB } + query do + spread :FragA + spread :FragB + end end expect(result).to include('fragment FragA on TypeA') expect(result).to include('fragment FragB on TypeB') @@ -186,7 +189,7 @@ def build(&block) invoices(created_after: :created_after) { id } end end - # Variable names are not camelized — snake_case keys output as-is + # Variable names are not camelized -- snake_case keys output as-is expect(result).to include('$created_after: Date') end From 1bb7e2634e27636f98313a3134e1c9335262cf5b Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:56:00 +1200 Subject: [PATCH 22/32] chore: replace inline rubocop:disable with auto-gen-config, bump rubocop to ~> 1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed two inline disable comments (Metrics/ClassLength on Query, Lint/HandleExceptions in spec_helper) in favour of the rubocop_todo.yml approach suggested by upstream maintainer. Bumped rubocop dev dependency from pinned 0.56.0 to ~> 1.0 (required for Ruby 3.2 compatibility — Psych.safe_load signature changed). TargetRubyVersion kept at 2.3 to reflect the gem's minimum supported Ruby. Ran rubocop -a (11 auto-corrections) then --auto-gen-config to regenerate .rubocop_todo.yml with current cop names (Metrics/LineLength renamed to Layout/LineLength, Style/MethodMissingSuper removed, etc.). --- .rubocop.yml | 10 ++- .rubocop_todo.yml | 68 +++++++++++++------- Dangerfile | 4 +- Gemfile | 2 +- lib/graphlient/adapters/http/http_adapter.rb | 1 + lib/graphlient/client.rb | 3 +- lib/graphlient/errors/error.rb | 1 + lib/graphlient/errors/graphql_error.rb | 1 + lib/graphlient/query.rb | 3 +- lib/graphlient/schema.rb | 1 + spec/spec_helper.rb | 2 +- spec/support/dummy_schema.rb | 2 +- spec/support/queries/query.rb | 3 +- 13 files changed, 64 insertions(+), 37 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 81a370a..6116fdc 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,8 +1,8 @@ +inherit_from: .rubocop_todo.yml + AllCops: TargetRubyVersion: 2.3 - -Lint/SplatKeywordArguments: - Enabled: false + NewCops: disable Style/FrozenStringLiteralComment: Enabled: false @@ -13,7 +13,7 @@ Style/Documentation: Style/SafeNavigation: Enabled: false -Metrics/LineLength: +Layout/LineLength: Enabled: false Metrics/MethodLength: @@ -24,5 +24,3 @@ Metrics/BlockLength: Metrics/AbcSize: Enabled: false - -inherit_from: .rubocop_todo.yml diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 530f19b..e75f044 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,48 +1,72 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2018-08-14 17:57:21 -0400 using RuboCop version 0.56.0. +# on 2026-06-11 10:55:02 UTC using RuboCop version 1.87.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. # Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: AllowUnusedKeywordArguments, IgnoreEmptyMethods. +Gemspec/RequiredRubyVersion: + Exclude: + - 'graphlient.gemspec' + +# Offense count: 1 +# Configuration parameters: AllowedMethods. +# AllowedMethods: enums +Lint/ConstantDefinitionInBlock: + Exclude: + - 'spec/graphlient/static_client_query_spec.rb' + +# Offense count: 1 +Lint/MixedRegexpCaptureTypes: + Exclude: + - 'lib/graphlient/query.rb' + +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +Lint/NonDeterministicRequireOrder: + Exclude: + - 'spec/spec_helper.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowUnusedKeywordArguments, IgnoreEmptyMethods, IgnoreNotImplementedMethods, NotImplementedExceptions. +# NotImplementedExceptions: NotImplementedError Lint/UnusedMethodArgument: Exclude: - 'lib/graphlient/adapters/http/http_adapter.rb' -# Offense count: 2 -Metrics/CyclomaticComplexity: - Max: 8 +# Offense count: 1 +# Configuration parameters: CountComments, CountAsOne. +Metrics/ClassLength: + Max: 104 # Offense count: 1 -Metrics/PerceivedComplexity: - Max: 8 +# Configuration parameters: AllowedMethods, AllowedPatterns. +Metrics/CyclomaticComplexity: + Max: 9 # Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: AutoCorrect, EnforcedStyle. +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: EnforcedStyle, EnforcedStyleForClasses, EnforcedStyleForModules. # SupportedStyles: nested, compact +# SupportedStylesForClasses: ~, nested, compact +# SupportedStylesForModules: ~, nested, compact Style/ClassAndModuleChildren: Exclude: - 'spec/graphlient/static_client_query_spec.rb' -# Offense count: 2 -Style/MethodMissingSuper: - Exclude: - - 'lib/graphlient/extensions/query.rb' - - 'lib/graphlient/query.rb' - -# Offense count: 5 +# Offense count: 8 Style/MultilineBlockChain: Exclude: - 'spec/graphlient/client_query_spec.rb' - 'spec/graphlient/client_schema_spec.rb' -# Offense count: 39 -# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns. -# URISchemes: http, https -Metrics/LineLength: - Max: 181 +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: literals, strict +Style/MutableConstant: + Exclude: + - 'lib/graphlient/query.rb' diff --git a/Dangerfile b/Dangerfile index f8850d4..baaa7fa 100644 --- a/Dangerfile +++ b/Dangerfile @@ -14,9 +14,7 @@ warn("There're library changes, but not tests. That's OK as long as you're refac # -------------------------------------------------------------------------------------------------------------------- # You've made changes to specs, but no library code has changed? # -------------------------------------------------------------------------------------------------------------------- -if !has_app_changes && has_spec_changes - message('We really appreciate pull requests that demonstrate issues, even without a fix. That said, the next step is to try and fix the failing tests!', sticky: false) -end +message('We really appreciate pull requests that demonstrate issues, even without a fix. That said, the next step is to try and fix the failing tests!', sticky: false) if !has_app_changes && has_spec_changes # -------------------------------------------------------------------------------------------------------------------- # Have you updated CHANGELOG.md? diff --git a/Gemfile b/Gemfile index fde89be..748a272 100644 --- a/Gemfile +++ b/Gemfile @@ -11,7 +11,7 @@ end group :development do gem 'byebug', platform: :ruby - gem 'rubocop', '0.56.0' + gem 'rubocop', '~> 1.0' end group :test do diff --git a/lib/graphlient/adapters/http/http_adapter.rb b/lib/graphlient/adapters/http/http_adapter.rb index 15a351a..ab2b186 100644 --- a/lib/graphlient/adapters/http/http_adapter.rb +++ b/lib/graphlient/adapters/http/http_adapter.rb @@ -19,6 +19,7 @@ def execute(document:, operation_name: nil, variables: {}, context: {}) response = connection.request(request) raise Graphlient::Errors::HttpServerError, response unless response.is_a?(Net::HTTPOK) + JSON.parse(response.body) end diff --git a/lib/graphlient/client.rb b/lib/graphlient/client.rb index 8dd9b56..46da074 100644 --- a/lib/graphlient/client.rb +++ b/lib/graphlient/client.rb @@ -36,6 +36,7 @@ def execute(query, variables = nil) # see https://github.com/github-community-projects/graphql-client/pull/132 # see https://github.com/exAspArk/graphql-errors/issues/2 raise Graphlient::Errors::ExecutionError, rc if errors_in_result?(rc) + rc rescue GraphQL::Client::Error => e raise Graphlient::Errors::ClientError, e.message @@ -70,7 +71,7 @@ def raise_error_if_invalid_configuration! end def schema_path - return options[:schema_path].to_s if options[:schema_path] + options[:schema_path].to_s if options[:schema_path] end def client diff --git a/lib/graphlient/errors/error.rb b/lib/graphlient/errors/error.rb index fc93ceb..36419f9 100644 --- a/lib/graphlient/errors/error.rb +++ b/lib/graphlient/errors/error.rb @@ -2,6 +2,7 @@ module Graphlient module Errors class Error < StandardError attr_reader :inner_exception + def initialize(message, inner_exception = nil) super(message) diff --git a/lib/graphlient/errors/graphql_error.rb b/lib/graphlient/errors/graphql_error.rb index 72e7e35..7f90735 100644 --- a/lib/graphlient/errors/graphql_error.rb +++ b/lib/graphlient/errors/graphql_error.rb @@ -2,6 +2,7 @@ module Graphlient module Errors class GraphQLError < Error attr_reader :response + def initialize(response) super('the server responded with a GraphQL error') @response = response diff --git a/lib/graphlient/query.rb b/lib/graphlient/query.rb index f63967c..9c4b7bc 100644 --- a/lib/graphlient/query.rb +++ b/lib/graphlient/query.rb @@ -1,5 +1,5 @@ module Graphlient - class Query # rubocop:disable Metrics/ClassLength + class Query SCALAR_TYPES = { int: 'Int', float: 'Float', @@ -56,6 +56,7 @@ def evaluate(&block) def resolve_fragment_constant(value) return nil unless (match = value.to_s.match(FRAGMENT_DEFITION)) + raw_const = match[:const].gsub('__', '::') @context[@last_block].eval(raw_const).tap do |const| msg = "Expected constant #{raw_const} to be GraphQL::Client::FragmentDefinition. Given #{const.class}" diff --git a/lib/graphlient/schema.rb b/lib/graphlient/schema.rb index f155d93..776878e 100644 --- a/lib/graphlient/schema.rb +++ b/lib/graphlient/schema.rb @@ -20,6 +20,7 @@ def initialize(http, path) def dump! raise MissingConfigurationError, PATH_ERROR_MESSAGE unless path + GraphQL::Client.dump_schema(http, path) end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 4d09733..16d88b2 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,7 +5,7 @@ require 'graphlient' begin require 'byebug' if RUBY_ENGINE != 'jruby' -rescue LoadError # rubocop:disable Lint/HandleExceptions +rescue LoadError # byebug not available on all platforms (e.g. Windows x64-mingw-ucrt) end require 'rack/test' diff --git a/spec/support/dummy_schema.rb b/spec/support/dummy_schema.rb index f99b596..90c4243 100644 --- a/spec/support/dummy_schema.rb +++ b/spec/support/dummy_schema.rb @@ -1,7 +1,7 @@ require_relative 'types/invoice_type' require_relative 'queries/query' -require_relative 'types/mutation_type.rb' +require_relative 'types/mutation_type' require 'graphql/errors' diff --git a/spec/support/queries/query.rb b/spec/support/queries/query.rb index a714c8e..0f0141b 100644 --- a/spec/support/queries/query.rb +++ b/spec/support/queries/query.rb @@ -21,6 +21,7 @@ class Query < GraphQL::Schema::Object def invoice(id: nil) return nil if id.nil? + OpenStruct.new( id: id, fee_in_cents: 20_000 @@ -31,7 +32,7 @@ def not_null_invoice(*) nil end - def execution_error_invoice(id: nil, execution_errors:) + def execution_error_invoice(execution_errors:, id: nil) execution_errors.add(GraphQL::ExecutionError.new('Execution Error')) invoice(id: id) From 1e9d77b9ab7e6c7a758797adf0a265ecbcaefe38 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Fri, 12 Jun 2026 03:38:11 +1200 Subject: [PATCH 23/32] Update CHANGELOG.md --- CHANGELOG.md | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d210d0d..e0aef2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,14 +3,9 @@ * [#117](https://github.com/ashkan18/graphlient/pull/117): Migrate Danger to use `danger-pr-comment` reusable workflow, add Ruby 4.0 to CI, fix tests on Ruby 3.4+ and 4.0 - [@dblock](https://github.com/dblock). * [#113](https://github.com/ashkan18/graphlient/pull/113): Fix CI builds - [@yuki24](https://github.com/yuki24). * [#112](https://github.com/ashkan18/graphlient/pull/112): Update graphql-client github repository links in README - [@th1988](https://github.com/th1988). -* [#115](https://github.com/ashkan18/graphlient/pull/115): `Client#to_query_string(**kargs, &block)`: builds the full GraphQL query document from the - DSL block and returns a `String` without touching the schema or Faraday HTTP adapter. - Enables DSL-only mode where HTTP dispatch is handled externally - [@rellampec](https://github.com/rellampec). -* [#115](https://github.com/ashkan18/graphlient/pull/115): `Query#spread(fragment_name)`: emits `...FragmentName` into the query string as a named - fragment spread. Alternative to the `___Const` convention for use cases where - `graphql-client` constant parsing is not available - [@rellampec](https://github.com/rellampec). -* [#115](https://github.com/ashkan18/graphlient/pull/115): `spec/spec_helper.rb`: rescue `LoadError` on `byebug` require so specs run on platforms - where byebug is not installable (e.g. Windows with Ruby 3.2) - [@rellampec](https://github.com/rellampec). +* [#115](https://github.com/ashkan18/graphlient/pull/115): `Client#to_query_string(**kargs, &block)`: builds the full GraphQL query document from the DSL block and returns a `String` without touching the schema or Faraday HTTP adapter. Enables DSL-only mode where HTTP dispatch is handled externally - [@rellampec](https://github.com/rellampec). +* [#115](https://github.com/ashkan18/graphlient/pull/115): `Query#spread(fragment_name)`: emits `...FragmentName` into the query string as a named fragment spread. Alternative to the `___Const` convention for use cases where `graphql-client` constant parsing is not available - [@rellampec](https://github.com/rellampec). +* [#115](https://github.com/ashkan18/graphlient/pull/115): `spec/spec_helper.rb`: rescue `LoadError` on `byebug` require so specs run on platforms where byebug is not installable (e.g. Windows with Ruby 3.2) - [@rellampec](https://github.com/rellampec). ### 0.8.0 (2024/01/06) * [#110](https://github.com/ashkan18/graphlient/pull/110): Ensure correct Faraday JSON response body parsing with invalid response header - [@taylorthurlow](https://github.com/taylorthurlow). From edae1d8cb577f67424cd7126d54a6c43c5c511aa Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Fri, 12 Jun 2026 04:04:47 +1200 Subject: [PATCH 24/32] correct order in change kig --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2059275..8c15bf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,15 @@ ### (Next) * Your contribution here. * [#117](https://github.com/ashkan18/graphlient/pull/117): Migrate Danger to use `danger-pr-comment` reusable workflow, add Ruby 4.0 to CI, fix tests on Ruby 3.4+ and 4.0 - [@dblock](https://github.com/dblock). -* [#115](https://github.com/ashkan18/graphlient/pull/115): `Client#to_query_string(**kargs, &block)`: builds the full GraphQL query document from the DSL block and returns a `String` without touching the schema or Faraday HTTP adapter. Enables DSL-only mode where HTTP dispatch is handled externally - [@rellampec](https://github.com/rellampec). -* [#115](https://github.com/ashkan18/graphlient/pull/115): `Query#spread(fragment_name)`: emits `...FragmentName` into the query string as a named fragment spread. Alternative to the `___Const` convention for use cases where `graphql-client` constant parsing is not available - [@rellampec](https://github.com/rellampec). -* [#115](https://github.com/ashkan18/graphlient/pull/115): `spec/spec_helper.rb`: rescue `LoadError` on `byebug` require so specs run on platforms where byebug is not installable (e.g. Windows with Ruby 3.2) - [@rellampec](https://github.com/rellampec). * [#116](https://github.com/ashkan18/graphlient/pull/116): Directive DSL — `_skip(if: :x)` → `@skip(if: $x)`, `_include(if: :x)` → `@include(if: $x)`. Any `_name` method is treated as a `@name` directive; applies to fields, spreads, and inline fragments - [@rellampec](https://github.com/rellampec). * [#116](https://github.com/ashkan18/graphlient/pull/116): `on(:TypeName, *directives, &block)`: inline fragment DSL — `on(:PaidInvoice) { amountPaid }` → `... on PaidInvoice { amountPaid }` - [@rellampec](https://github.com/rellampec). * [#116](https://github.com/ashkan18/graphlient/pull/116): `fragment(:Name, on: :Type, &block)`: inline fragment definition appended to the query document automatically; scoped to the query call, no global registry - [@rellampec](https://github.com/rellampec). * [#116](https://github.com/ashkan18/graphlient/pull/116): Custom scalar registration — `c.scalar :date, 'Date'` extends the built-in scalar set (`:int`, `:float`, `:string`, `:boolean`) with API-specific types - [@rellampec](https://github.com/rellampec). * [#116](https://github.com/ashkan18/graphlient/pull/116): `Query` refactored into `Query::Serializer` composed from concern modules (`Scalars`, `Arguments`, `Evaluator`, `Fragments`, `Directives`). Public API unchanged - [@rellampec](https://github.com/rellampec). * [#116](https://github.com/ashkan18/graphlient/pull/116): CI: Ruby 3.4 added to test matrix; Ruby 3.1 WebMock `to_rack` fix; `mutex_m` gem added for Ruby 3.4 stdlib removal - [@rellampec](https://github.com/rellampec). +* [#115](https://github.com/ashkan18/graphlient/pull/115): `Client#to_query_string(**kargs, &block)`: builds the full GraphQL query document from the DSL block and returns a `String` without touching the schema or Faraday HTTP adapter. Enables DSL-only mode where HTTP dispatch is handled externally - [@rellampec](https://github.com/rellampec). +* [#115](https://github.com/ashkan18/graphlient/pull/115): `Query#spread(fragment_name)`: emits `...FragmentName` into the query string as a named fragment spread. Alternative to the `___Const` convention for use cases where `graphql-client` constant parsing is not available - [@rellampec](https://github.com/rellampec). +* [#115](https://github.com/ashkan18/graphlient/pull/115): `spec/spec_helper.rb`: rescue `LoadError` on `byebug` require so specs run on platforms where byebug is not installable (e.g. Windows with Ruby 3.2) - [@rellampec](https://github.com/rellampec). * [#113](https://github.com/ashkan18/graphlient/pull/113): Fix CI builds - [@yuki24](https://github.com/yuki24). * [#112](https://github.com/ashkan18/graphlient/pull/112): Update graphql-client github repository links in README - [@th1988](https://github.com/th1988). From fe45f972d912fa1fdfbdef86ce46fed76e912cc5 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Fri, 12 Jun 2026 04:16:59 +1200 Subject: [PATCH 25/32] fix: rubocop offenses after merge of feature/dsl-mode-only-option --- lib/graphlient/query/serializer.rb | 2 +- lib/graphlient/query/serializer/arguments.rb | 7 +++++-- lib/graphlient/query/serializer/directives.rb | 2 +- lib/graphlient/query/serializer/fragments.rb | 2 +- lib/graphlient/query/serializer/scalars.rb | 6 +++--- spec/graphlient/client_query_spec.rb | 2 +- 6 files changed, 12 insertions(+), 9 deletions(-) diff --git a/lib/graphlient/query/serializer.rb b/lib/graphlient/query/serializer.rb index 08af83e..eae51c1 100644 --- a/lib/graphlient/query/serializer.rb +++ b/lib/graphlient/query/serializer.rb @@ -76,7 +76,7 @@ def fragment(name, on:, &block) @fragments[name] = "fragment #{name} on #{on} {\n#{body}\n}" end - def method_missing(method_name, *args, &block) # rubocop:disable Style/MethodMissingSuper + def method_missing(method_name, *args, &block) if fragment?(method_name) append_node("...#{resolve_fragment_constant(method_name)}".to_sym, args, &block) elsif directive?(method_name) diff --git a/lib/graphlient/query/serializer/arguments.rb b/lib/graphlient/query/serializer/arguments.rb index 568a5f1..9d51d03 100644 --- a/lib/graphlient/query/serializer/arguments.rb +++ b/lib/graphlient/query/serializer/arguments.rb @@ -52,12 +52,15 @@ def argument_value_string(value) when Numeric then value.to_s when Array then "[#{value.map { |v| argument_value_string(v) }.join(', ')}]" when Hash then "{ #{value.map { |k, v| "#{k}: #{argument_value_string(v)}" }.join(', ')} }" - when Symbol - @variables.respond_to?(:key?) && @variables.key?(value) ? "$#{value}" : value.to_s.camelize(:lower) + when Symbol then symbol_argument_value(value) else value end end + + def symbol_argument_value(value) + @variables.respond_to?(:key?) && @variables.key?(value) ? "$#{value}" : value.to_s.camelize(:lower) + end end end end diff --git a/lib/graphlient/query/serializer/directives.rb b/lib/graphlient/query/serializer/directives.rb index 711a297..4574b3f 100644 --- a/lib/graphlient/query/serializer/directives.rb +++ b/lib/graphlient/query/serializer/directives.rb @@ -4,7 +4,7 @@ class Serializer module Directives # Matches _skip, _include, _myCustomDirective etc. # Does NOT match ___Fragment (three underscores -- checked first in method_missing). - DIRECTIVE_PREFIX = /\A_[a-z]/ + DIRECTIVE_PREFIX = /\A_[a-z]/.freeze private diff --git a/lib/graphlient/query/serializer/fragments.rb b/lib/graphlient/query/serializer/fragments.rb index ae861ef..e55bd9e 100644 --- a/lib/graphlient/query/serializer/fragments.rb +++ b/lib/graphlient/query/serializer/fragments.rb @@ -3,7 +3,7 @@ class Query class Serializer module Fragments # Pattern for the legacy ___Const::Name triple-underscore convention. - FRAGMENT_DEFINITION = /___(?[A-Z][a-zA-Z0-9_]*(__[A-Z][a-zA-Z0-9_]*)*)/ + FRAGMENT_DEFINITION = /___(?[A-Z][a-zA-Z0-9_]*(?:__[A-Z][a-zA-Z0-9_]*)*)/.freeze private diff --git a/lib/graphlient/query/serializer/scalars.rb b/lib/graphlient/query/serializer/scalars.rb index f5f1289..cf5f343 100644 --- a/lib/graphlient/query/serializer/scalars.rb +++ b/lib/graphlient/query/serializer/scalars.rb @@ -3,9 +3,9 @@ class Query class Serializer module Scalars BUILT_IN_SCALAR_TYPES = { - int: 'Int', - float: 'Float', - string: 'String', + int: 'Int', + float: 'Float', + string: 'String', boolean: 'Boolean' }.freeze diff --git a/spec/graphlient/client_query_spec.rb b/spec/graphlient/client_query_spec.rb index e221b39..9cfa6ef 100644 --- a/spec/graphlient/client_query_spec.rb +++ b/spec/graphlient/client_query_spec.rb @@ -97,7 +97,7 @@ let(:query) do client.parse do query( - some_id: :int, + some_id: :int, skip_fee: :boolean! ) do invoice(id: :some_id) do From f42c699561a2f33aa13ad96cd6a246c50d4c5b68 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Fri, 12 Jun 2026 05:17:40 +1200 Subject: [PATCH 26/32] chore: pin rubocop to 1.87.0 (fixed version, no pessimistic constraint) --- Gemfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index 4474f8e..2459de4 100644 --- a/Gemfile +++ b/Gemfile @@ -12,7 +12,7 @@ end group :development do gem 'byebug', platform: :ruby - gem 'rubocop', '~> 1.0' + gem 'rubocop', '1.87.0' end group :test do From efa67cd082c4ac16525f023d379f4679e9bd7504 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Sat, 13 Jun 2026 08:53:26 +1200 Subject: [PATCH 27/32] docs: clarify inline vs external-constant fragment field access --- README.md | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 52fb976..4a53c6e 100644 --- a/README.md +++ b/README.md @@ -441,6 +441,28 @@ fragment InvoiceFields on Invoice { } ``` +Because the fragment is defined inline in the same query block, graphql-client treats it +as part of the same document — all fragment fields are directly accessible on the response +wrapper with no extra wrapping step: + +```ruby +response = client.query do + fragment(:InvoiceFields, on: :Invoice) do + id + feeInCents + end + + query do + invoice(id: 10) do + spread :InvoiceFields + end + end +end + +response.data.invoice.id # 10 +response.data.invoice.fee_in_cents # 20000 +``` + Multiple fragments are supported. Fragments are scoped to the query call — no global registry, no cross-contamination between requests. @@ -484,7 +506,12 @@ end ``` The wrapped response only allows access to fields that have been explicitly asked for. -In this example, while `id` has been referenced directly in the main query, `feeInCents` has been spread via fragment and trying to access it in the original wrapped response will throw [`GraphQL::Client::ImplicitlyFetchedFieldError`](https://github.com/github-community-projects/graphql-client/blob/master/guides/implicitly-fetched-field-error.md) (to prevent data leaks between components). +In this example, while `id` has been referenced directly in the main query, `feeInCents` +has been spread via an **external fragment constant** and trying to access it in the +original wrapped response will throw +[`GraphQL::Client::ImplicitlyFetchedFieldError`](https://github.com/github-community-projects/graphql-client/blob/master/guides/implicitly-fetched-field-error.md). +This is graphql-client's component-isolation mechanism: each fragment constant "owns" the +fields it declares, preventing accidental data access across component boundaries. ```ruby response = client.execute(invoice_query) @@ -507,6 +534,11 @@ invoice.fee_in_cents # 20000 ``` +> **Note:** This component-isolation behaviour only applies to external fragment constants +> (the `___` / `__` pattern). Fragments defined inline via the `fragment` DSL in the same +> query block are not subject to this restriction — their fields are accessible directly +> on the operation response (see [Fragment Spreads and Definitions in the DSL](#fragment-spreads-and-definitions-in-the-dsl)). + ### Inline Fragments in the DSL Use `on(:TypeName)` for inline fragments (`... on Type { }`), useful for union From 688264f3d70e3bbde224d0d4d5d6e9a2556f4f65 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Sat, 13 Jun 2026 09:03:51 +1200 Subject: [PATCH 28/32] feat: pass allow_named_fragment_access through to graphql-client --- lib/graphlient/client.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/graphlient/client.rb b/lib/graphlient/client.rb index 123546d..4ffe36d 100644 --- a/lib/graphlient/client.rb +++ b/lib/graphlient/client.rb @@ -88,6 +88,7 @@ def schema_path def client @client ||= GraphQL::Client.new(schema: schema.graphql_schema, execute: http).tap do |client| client.allow_dynamic_queries = @options.key?(:allow_dynamic_queries) ? options[:allow_dynamic_queries] : true + client.allow_named_fragment_access = options[:allow_named_fragment_access] if @options.key?(:allow_named_fragment_access) end end From e2f0b4891bf88d0c5b2489f3b47e9fff659a0852 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Sat, 13 Jun 2026 13:10:48 +1200 Subject: [PATCH 29/32] revert: remove allow_named_fragment_access passthrough (flag removed from graphql-client) --- lib/graphlient/client.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/graphlient/client.rb b/lib/graphlient/client.rb index 4ffe36d..123546d 100644 --- a/lib/graphlient/client.rb +++ b/lib/graphlient/client.rb @@ -88,7 +88,6 @@ def schema_path def client @client ||= GraphQL::Client.new(schema: schema.graphql_schema, execute: http).tap do |client| client.allow_dynamic_queries = @options.key?(:allow_dynamic_queries) ? options[:allow_dynamic_queries] : true - client.allow_named_fragment_access = options[:allow_named_fragment_access] if @options.key?(:allow_named_fragment_access) end end From c3ec9843acd22ec578ddafde5731dacc99383080 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Tue, 16 Jun 2026 06:34:56 +1200 Subject: [PATCH 30/32] refactor: unify inline fragments under spread(on:) per #116 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses @dblock's review question on #116 — use a single, consistent verb instead of a separate `on`. `spread` now covers both GraphQL forms: spread :InvoiceFields # ...InvoiceFields (named fragment spread) spread on: :PaidInvoice { ... } # ... on PaidInvoice { ... } (inline fragment) spread on: :Invoice # ... on Invoice (bare type condition) The `on:` keyword matches `fragment(name, on:)`, keeping the DSL coherent and leaving room for a future `spread(:X).skip(...)` chaining form without a breaking change. The standalone `on` DSL method is removed (introduced in #116, never released, so no compatibility impact). Guards: `spread` with neither a name nor `on:` raises; a named spread given a block raises (named spreads have no selection set). Updated specs, README, and CHANGELOG accordingly. --- CHANGELOG.md | 2 +- README.md | 13 ++++--- lib/graphlient/query/directive.rb | 2 +- lib/graphlient/query/serializer.rb | 59 ++++++++++++++++++++---------- spec/graphlient/client_dsl_spec.rb | 2 +- spec/graphlient/query_dsl_spec.rb | 28 +++++++++++--- 6 files changed, 72 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09c7830..2c1a294 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ * Your contribution here. * [#117](https://github.com/ashkan18/graphlient/pull/117): Migrate Danger to use `danger-pr-comment` reusable workflow, add Ruby 4.0 to CI, fix tests on Ruby 3.4+ and 4.0 - [@dblock](https://github.com/dblock). * [#116](https://github.com/ashkan18/graphlient/pull/116): Directive DSL — `_skip(if: :x)` → `@skip(if: $x)`, `_include(if: :x)` → `@include(if: $x)`. Any `_name` method is treated as a `@name` directive; applies to fields, spreads, and inline fragments - [@rellampec](https://github.com/rellampec). -* [#116](https://github.com/ashkan18/graphlient/pull/116): `on(:TypeName, *directives, &block)`: inline fragment DSL — `on(:PaidInvoice) { amountPaid }` → `... on PaidInvoice { amountPaid }` - [@rellampec](https://github.com/rellampec). +* [#116](https://github.com/ashkan18/graphlient/pull/116): `spread(on: :Type, *directives, &block)`: inline fragment DSL — `spread(on: :PaidInvoice) { amountPaid }` → `... on PaidInvoice { amountPaid }`. `spread` is the single verb for both named fragment spreads (`spread :Name`) and inline fragments (`spread on: :Type`), using the same `on:` keyword as `fragment(name, on:)` - [@rellampec](https://github.com/rellampec). * [#116](https://github.com/ashkan18/graphlient/pull/116): `fragment(:Name, on: :Type, &block)`: inline fragment definition appended to the query document automatically; scoped to the query call, no global registry - [@rellampec](https://github.com/rellampec). * [#116](https://github.com/ashkan18/graphlient/pull/116): Custom scalar registration — `c.scalar :date, 'Date'` extends the built-in scalar set (`:int`, `:float`, `:string`, `:boolean`) with API-specific types - [@rellampec](https://github.com/rellampec). * [#116](https://github.com/ashkan18/graphlient/pull/116): `Query` refactored into `Query::Serializer` composed from concern modules (`Scalars`, `Arguments`, `Evaluator`, `Fragments`, `Directives`). Public API unchanged - [@rellampec](https://github.com/rellampec). diff --git a/README.md b/README.md index 4a53c6e..fc4e1f0 100644 --- a/README.md +++ b/README.md @@ -541,17 +541,18 @@ invoice.fee_in_cents ### Inline Fragments in the DSL -Use `on(:TypeName)` for inline fragments (`... on Type { }`), useful for union -types and interface implementations: +Use `spread(on: :TypeName)` for inline fragments (`... on Type { }`), useful for union +types and interface implementations. It's the same `spread` verb as named fragment +spreads, and the same `on:` keyword as `fragment(name, on:)`: ```ruby client.query do query do invoice(id: 10) do - on(:PaidInvoice) do + spread(on: :PaidInvoice) do amountPaid end - on(:UnpaidInvoice) do + spread(on: :UnpaidInvoice) do amountDue end end @@ -577,7 +578,7 @@ query { Directives can be applied to inline fragments too (see [Directives in the DSL](#directives-in-the-dsl)): ```ruby -on(:DraftInvoice, _skip(if: :skip_drafts)) { draftId } +spread(_skip(if: :skip_drafts), on: :DraftInvoice) { draftId } # → ... on DraftInvoice @skip(if: $skip_drafts) { draftId } ``` @@ -610,7 +611,7 @@ spread :InvoiceFields, _skip(if: :skip_invoice) **On an inline fragment:** ```ruby -on(:DraftInvoice, _skip(if: :skip_drafts)) { draftId } +spread(_skip(if: :skip_drafts), on: :DraftInvoice) { draftId } # → ... on DraftInvoice @skip(if: $skip_drafts) { draftId } ``` diff --git a/lib/graphlient/query/directive.rb b/lib/graphlient/query/directive.rb index 9aa5349..056b056 100644 --- a/lib/graphlient/query/directive.rb +++ b/lib/graphlient/query/directive.rb @@ -14,7 +14,7 @@ class Query # spread :InvoiceFields, _skip(if: :x) # -> ...InvoiceFields @skip(if: $x) # - # on(:DraftInvoice, _skip(if: :x)) { draft_id } + # spread(_skip(if: :x), on: :DraftInvoice) { draft_id } # -> ... on DraftInvoice @skip(if: $x) { draftId } class Directive attr_reader :name, :args diff --git a/lib/graphlient/query/serializer.rb b/lib/graphlient/query/serializer.rb index eae51c1..5d0fc2b 100644 --- a/lib/graphlient/query/serializer.rb +++ b/lib/graphlient/query/serializer.rb @@ -1,3 +1,4 @@ +require_relative '../errors/error' require_relative 'directive' require_relative 'serializer/scalars' require_relative 'serializer/arguments' @@ -42,29 +43,49 @@ def to_s parts.join("\n\n") end + # Raised when `spread` is called with neither a fragment name nor `on:`. + SPREAD_REQUIRES_NAME_OR_ON = + 'spread requires a fragment name (`spread :InvoiceFields`) or an inline ' \ + 'type condition (`spread on: :PaidInvoice { ... }`).' + # Raised when a named spread is given a block (a named spread has no selection set). + SPREAD_NAME_TAKES_NO_BLOCK = + 'a named fragment spread takes no block; for an inline fragment use ' \ + '`spread on: :Type { ... }`.' + + # Fragment spread OR inline fragment -- one consistent entry point. + # # Named fragment spread: ...FragmentName [@directive ...] # spread :InvoiceFields - # spread :InvoiceFields, _skip(if: :x) # -> ...InvoiceFields @skip(if: $x) - def spread(fragment_name, *args) + # spread :InvoiceFields, _skip(if: :x) # -> ...InvoiceFields @skip(if: $x) + # + # Inline fragment / type condition: ... on Type [@directive ...] { fields } + # spread on: :PaidInvoice { amount_paid } # -> ... on PaidInvoice { amountPaid } + # spread on: :DraftInvoice, _skip(if: :skip) { draft_id } + # spread on: :Invoice # bare type condition, no block + # + # A single verb covers both GraphQL forms; `on:` is the same keyword used by + # `fragment(name, on:)`, keeping the DSL consistent (and leaving room for a future + # `spread(:X).skip(...)` chaining form without a breaking change). + def spread(*args, on: nil, &block) directives = args.select { |a| a.is_a?(Directive) } - @query_str << "\n#{indent}...#{fragment_name}" - directives.each { |d| @query_str << " #{d}" } - @query_str << "\n#{indent}" - end - # Inline fragment: ... on Type [@directive ...] { fields } - # on(:PaidInvoice) { amount_paid } - # on(:DraftInvoice, _skip(if: :skip)) { draft_id } - def on(type, *args, &block) - directives = args.select { |a| a.is_a?(Directive) } - @query_str << "\n#{indent}... on #{type}" - directives.each { |d| @query_str << " #{d}" } - if block_given? - @indents += 1 - @query_str << '{' - evaluate(&block) - @query_str << '}' - @indents -= 1 + if on + @query_str << "\n#{indent}... on #{on}" + directives.each { |d| @query_str << " #{d}" } + if block + @indents += 1 + @query_str << '{' + evaluate(&block) + @query_str << '}' + @indents -= 1 + end + else + fragment_name = args.find { |a| !a.is_a?(Directive) } + raise Graphlient::Errors::Error, SPREAD_REQUIRES_NAME_OR_ON if fragment_name.nil? + raise Graphlient::Errors::Error, SPREAD_NAME_TAKES_NO_BLOCK if block + + @query_str << "\n#{indent}...#{fragment_name}" + directives.each { |d| @query_str << " #{d}" } end @query_str << "\n#{indent}" end diff --git a/spec/graphlient/client_dsl_spec.rb b/spec/graphlient/client_dsl_spec.rb index 7aec261..eb1005a 100644 --- a/spec/graphlient/client_dsl_spec.rb +++ b/spec/graphlient/client_dsl_spec.rb @@ -111,7 +111,7 @@ result = client.to_query_string do query do invoice(id: 10) do - on(:PaidInvoice) { amountPaid } + spread(on: :PaidInvoice) { amountPaid } end end end diff --git a/spec/graphlient/query_dsl_spec.rb b/spec/graphlient/query_dsl_spec.rb index 49c840c..98d0b4f 100644 --- a/spec/graphlient/query_dsl_spec.rb +++ b/spec/graphlient/query_dsl_spec.rb @@ -92,15 +92,17 @@ def build(&block) # --- Inline fragments ---------------------------------------------------- - describe 'on(:Type) — inline fragment' do + # Inline fragments use the same `spread` verb as named fragment spreads, with the + # `on:` keyword (the same keyword as `fragment(name, on:)`) — one consistent entry point. + describe 'spread(on: :Type) — inline fragment' do it 'generates ... on Type { }' do result = build do query do invoice(id: 10) do - on(:PaidInvoice) do + spread(on: :PaidInvoice) do amountPaid end - on(:UnpaidInvoice) do + spread(on: :UnpaidInvoice) do amountDue end end @@ -116,7 +118,7 @@ def build(&block) result = build do query(skip_draft: :boolean!) do invoice(id: 10) do - on(:DraftInvoice, _skip(if: :skip_draft)) do + spread(_skip(if: :skip_draft), on: :DraftInvoice) do draftId end end @@ -126,14 +128,28 @@ def build(&block) expect(result).to include('draftId') end - it 'inline fragment without block (type check only)' do + it 'inline fragment without block (bare type condition)' do result = build do - query { on(:Invoice) } + query { spread(on: :Invoice) } end expect(result).to include('... on Invoice') end end + # --- spread argument validation ----------------------------------------- + + describe 'spread requires a name or an inline type condition' do + it 'raises when given neither a fragment name nor on:' do + expect { build { query { spread } } } + .to raise_error(Graphlient::Errors::Error, /requires a fragment name/) + end + + it 'raises when a named spread is given a block' do + expect { build { query { spread(:InvoiceFields) { id } } } } + .to raise_error(Graphlient::Errors::Error, /takes no block/) + end + end + # --- Fragment definitions ------------------------------------------------ describe 'fragment(:Name, on: :Type)' do From 192bb2119e13c636b68962e0737359bf66b05aa8 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Tue, 16 Jun 2026 06:41:59 +1200 Subject: [PATCH 31/32] test: require ostruct + mutex_m before loading graphlient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On newer Ruby (3.4+) ostruct and mutex_m are no longer auto-available, so the test-group deps that rely on them fail to load: graphql 1.13's compatibility specs reference OpenStruct (NameError at require time), and activesupport 5.x needs mutex_m. Both gems are already in the test Gemfile group; require them in spec_helper before graphlient is loaded transitively. Test-only — not added to the library, which must not depend on test gems. --- spec/spec_helper.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 16d88b2..3e48a70 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -2,6 +2,14 @@ require 'rubygems' require 'rspec' + +# Standard-library gems unbundled from newer Ruby (3.4+) and needed by the test +# dependencies below — required here so they're loaded before `graphlient` pulls +# them in transitively: +# - ostruct: graphql 1.13's compatibility specs reference OpenStruct +# - mutex_m: activesupport 5.x depends on it +require 'ostruct' +require 'mutex_m' require 'graphlient' begin require 'byebug' if RUBY_ENGINE != 'jruby' From b62165577e2a62b57c4d2ebb0b5cd508296f96a3 Mon Sep 17 00:00:00 2001 From: rellampec <30378924+rellampec@users.noreply.github.com> Date: Tue, 16 Jun 2026 06:46:38 +1200 Subject: [PATCH 32/32] style: satisfy rubocop in unified spread - freeze the SPREAD_* message constants (Style/MutableConstant) - extract append_inline_fragment / append_named_spread helpers so #spread is below the Metrics/PerceivedComplexity threshold --- lib/graphlient/query/serializer.rb | 45 ++++++++++++++++++------------ 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/lib/graphlient/query/serializer.rb b/lib/graphlient/query/serializer.rb index 5d0fc2b..4c10db8 100644 --- a/lib/graphlient/query/serializer.rb +++ b/lib/graphlient/query/serializer.rb @@ -46,11 +46,11 @@ def to_s # Raised when `spread` is called with neither a fragment name nor `on:`. SPREAD_REQUIRES_NAME_OR_ON = 'spread requires a fragment name (`spread :InvoiceFields`) or an inline ' \ - 'type condition (`spread on: :PaidInvoice { ... }`).' + 'type condition (`spread on: :PaidInvoice { ... }`).'.freeze # Raised when a named spread is given a block (a named spread has no selection set). SPREAD_NAME_TAKES_NO_BLOCK = 'a named fragment spread takes no block; for an inline fragment use ' \ - '`spread on: :Type { ... }`.' + '`spread on: :Type { ... }`.'.freeze # Fragment spread OR inline fragment -- one consistent entry point. # @@ -68,24 +68,10 @@ def to_s # `spread(:X).skip(...)` chaining form without a breaking change). def spread(*args, on: nil, &block) directives = args.select { |a| a.is_a?(Directive) } - if on - @query_str << "\n#{indent}... on #{on}" - directives.each { |d| @query_str << " #{d}" } - if block - @indents += 1 - @query_str << '{' - evaluate(&block) - @query_str << '}' - @indents -= 1 - end + append_inline_fragment(on, directives, &block) else - fragment_name = args.find { |a| !a.is_a?(Directive) } - raise Graphlient::Errors::Error, SPREAD_REQUIRES_NAME_OR_ON if fragment_name.nil? - raise Graphlient::Errors::Error, SPREAD_NAME_TAKES_NO_BLOCK if block - - @query_str << "\n#{indent}...#{fragment_name}" - directives.each { |d| @query_str << " #{d}" } + append_named_spread(args, directives, &block) end @query_str << "\n#{indent}" end @@ -117,6 +103,29 @@ def indent ' ' * @indents end + # Inline fragment / type condition: ... on Type [@directive ...] [{ fields }] + def append_inline_fragment(type, directives, &block) + @query_str << "\n#{indent}... on #{type}" + directives.each { |d| @query_str << " #{d}" } + return unless block + + @indents += 1 + @query_str << '{' + evaluate(&block) + @query_str << '}' + @indents -= 1 + end + + # Named fragment spread: ...FragmentName [@directive ...] + def append_named_spread(args, directives, &block) + fragment_name = args.find { |a| !a.is_a?(Directive) } + raise Graphlient::Errors::Error, SPREAD_REQUIRES_NAME_OR_ON if fragment_name.nil? + raise Graphlient::Errors::Error, SPREAD_NAME_TAKES_NO_BLOCK if block + + @query_str << "\n#{indent}...#{fragment_name}" + directives.each { |d| @query_str << " #{d}" } + end + def append_node(node, args, arg_processor: nil, &block) regular = field_args(args) dirs = directive_args(args)