diff --git a/CHANGELOG.md b/CHANGELOG.md index e0aef2b..2c1a294 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +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). +* [#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): `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). +* [#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). * [#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). 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 diff --git a/README.md b/README.md index 143ac8d..fc4e1f0 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,10 @@ A friendlier Ruby client for consuming GraphQL-based APIs. Built on top of your - [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) + - [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) @@ -390,7 +393,87 @@ query.to_s # "\nquery {\n invoice(id: 10){\n line_items\n }\n }\n" ``` -### Use of Fragments +### Fragment Spreads and Definitions in the DSL + +Use `spread` to insert a named fragment spread (`...FragmentName`) in a DSL block: + +```ruby +client.query do + query do + invoice(id: 10) do + id + spread :InvoiceFields # → ...InvoiceFields + end + end +end +``` + +Define the fragment body inline with `fragment` — graphlient assembles the complete +query string automatically, no external tooling needed: + +```ruby +client.query do + fragment(:InvoiceFields, on: :Invoice) do + id + feeInCents + end + + query do + invoice(id: 10) do + spread :InvoiceFields + end + end +end +``` + +Produces and sends: + +```graphql +query { + invoice(id: 10) { + ...InvoiceFields + } +} + +fragment InvoiceFields on Invoice { + id + feeInCents +} +``` + +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. + +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) [Fragments](https://github.com/github-community-projects/graphql-client#defining-queries) should be referred by constant: @@ -423,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) @@ -446,39 +534,134 @@ invoice.fee_in_cents # 20000 ``` -### Fragment Spreads +> **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 `spread` to emit a named fragment spread (`...FragmentName`) inside a DSL block. -This is a cleaner alternative to the `___Const__Name` triple-underscore convention. +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 -query_str = client.to_query_string do +client.query do query do invoice(id: 10) do + spread(on: :PaidInvoice) do + amountPaid + end + spread(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 +spread(_skip(if: :skip_drafts), on: :DraftInvoice) { 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`). + +**On a field:** + +```ruby +client.query(some_id: :int, skip_fee: :boolean!) do + query(some_id: :int, skip_fee: :boolean!) do + invoice(id: :some_id) do id - spread :InvoiceFields # → ...InvoiceFields + feeInCents _skip(if: :skip_fee) # → feeInCents @skip(if: $skip_fee) end end end ``` -Multiple spreads at the same level are supported: +**On a fragment spread:** ```ruby -client.to_query_string do - query do - invoice(id: 10) do - spread :CoreFields - spread :AuditFields +spread :InvoiceFields, _skip(if: :skip_invoice) +# → ...InvoiceFields @skip(if: $skip_invoice) +``` + +**On an inline fragment:** + +```ruby +spread(_skip(if: :skip_drafts), on: :DraftInvoice) { 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!) { ... } ``` -`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. +Non-null variants work with `!`: `:date!` → `Date!`. ### Create API Client Classes with Graphlient::Extension::Query diff --git a/lib/graphlient/client.rb b/lib/graphlient/client.rb index 46da074..123546d 100644 --- a/lib/graphlient/client.rb +++ b/lib/graphlient/client.rb @@ -60,6 +60,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 9c4b7bc..af7c295 100644 --- a/lib/graphlient/query.rb +++ b/lib/graphlient/query.rb @@ -1,136 +1,29 @@ +require_relative 'query/serializer' + 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 - 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 - - 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 + # 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..056b056 --- /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) + # + # spread(_skip(if: :x), on: :DraftInvoice) { 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..4c10db8 --- /dev/null +++ b/lib/graphlient/query/serializer.rb @@ -0,0 +1,153 @@ +require_relative '../errors/error' +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 + + # 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 { ... }`).'.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 { ... }`.'.freeze + + # 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) + # + # 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) } + if on + append_inline_fragment(on, directives, &block) + else + append_named_spread(args, directives, &block) + 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 + + # 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) + + @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..9d51d03 --- /dev/null +++ b/lib/graphlient/query/serializer/arguments.rb @@ -0,0 +1,67 @@ +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 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 +end diff --git a/lib/graphlient/query/serializer/directives.rb b/lib/graphlient/query/serializer/directives.rb new file mode 100644 index 0000000..4574b3f --- /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]/.freeze + + 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..e55bd9e --- /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_]*)*)/.freeze + + 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..cf5f343 --- /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_dsl_spec.rb b/spec/graphlient/client_dsl_spec.rb index 00cfbf5..eb1005a 100644 --- a/spec/graphlient/client_dsl_spec.rb +++ b/spec/graphlient/client_dsl_spec.rb @@ -93,5 +93,61 @@ 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 + spread(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) do + id + feeInCents + end + 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 diff --git a/spec/graphlient/client_query_spec.rb b/spec/graphlient/client_query_spec.rb index 3c011db..9cfa6ef 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, skip_fee: true) + 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 diff --git a/spec/graphlient/query_dsl_spec.rb b/spec/graphlient/query_dsl_spec.rb new file mode 100644 index 0000000..98d0b4f --- /dev/null +++ b/spec/graphlient/query_dsl_spec.rb @@ -0,0 +1,231 @@ +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 ---------------------------------------------------- + + # 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 + spread(on: :PaidInvoice) do + amountPaid + end + spread(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 + spread(_skip(if: :skip_draft), on: :DraftInvoice) 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 (bare type condition)' do + result = build do + 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 + 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 do + spread :FragA + spread :FragB + end + 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 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'