active_shopify_graphql 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -0
  3. data/README.md +187 -56
  4. data/lib/active_shopify_graphql/configuration.rb +2 -15
  5. data/lib/active_shopify_graphql/connections/connection_loader.rb +141 -0
  6. data/lib/active_shopify_graphql/gid_helper.rb +2 -0
  7. data/lib/active_shopify_graphql/graphql_associations.rb +245 -0
  8. data/lib/active_shopify_graphql/loader.rb +147 -126
  9. data/lib/active_shopify_graphql/loader_context.rb +2 -47
  10. data/lib/active_shopify_graphql/loader_proxy.rb +67 -0
  11. data/lib/active_shopify_graphql/loaders/admin_api_loader.rb +1 -17
  12. data/lib/active_shopify_graphql/loaders/customer_account_api_loader.rb +10 -26
  13. data/lib/active_shopify_graphql/model/associations.rb +94 -0
  14. data/lib/active_shopify_graphql/model/attributes.rb +48 -0
  15. data/lib/active_shopify_graphql/model/connections.rb +174 -0
  16. data/lib/active_shopify_graphql/model/finder_methods.rb +155 -0
  17. data/lib/active_shopify_graphql/model/graphql_type_resolver.rb +89 -0
  18. data/lib/active_shopify_graphql/model/loader_switchable.rb +79 -0
  19. data/lib/active_shopify_graphql/model/metafield_attributes.rb +59 -0
  20. data/lib/active_shopify_graphql/{base.rb → model.rb} +30 -16
  21. data/lib/active_shopify_graphql/model_builder.rb +53 -0
  22. data/lib/active_shopify_graphql/query/node/collection.rb +78 -0
  23. data/lib/active_shopify_graphql/query/node/connection.rb +16 -0
  24. data/lib/active_shopify_graphql/query/node/current_customer.rb +37 -0
  25. data/lib/active_shopify_graphql/query/node/field.rb +23 -0
  26. data/lib/active_shopify_graphql/query/node/fragment.rb +17 -0
  27. data/lib/active_shopify_graphql/query/node/nested_connection.rb +79 -0
  28. data/lib/active_shopify_graphql/query/node/raw.rb +15 -0
  29. data/lib/active_shopify_graphql/query/node/root_connection.rb +76 -0
  30. data/lib/active_shopify_graphql/query/node/single_record.rb +36 -0
  31. data/lib/active_shopify_graphql/query/node/singular.rb +16 -0
  32. data/lib/active_shopify_graphql/query/node.rb +95 -0
  33. data/lib/active_shopify_graphql/query/query_builder.rb +290 -0
  34. data/lib/active_shopify_graphql/query/relation.rb +424 -0
  35. data/lib/active_shopify_graphql/query/scope.rb +219 -0
  36. data/lib/active_shopify_graphql/response/page_info.rb +40 -0
  37. data/lib/active_shopify_graphql/response/paginated_result.rb +114 -0
  38. data/lib/active_shopify_graphql/response/response_mapper.rb +242 -0
  39. data/lib/active_shopify_graphql/search_query/hash_condition_formatter.rb +107 -0
  40. data/lib/active_shopify_graphql/search_query/parameter_binder.rb +68 -0
  41. data/lib/active_shopify_graphql/search_query/value_sanitizer.rb +24 -0
  42. data/lib/active_shopify_graphql/search_query.rb +34 -84
  43. data/lib/active_shopify_graphql/version.rb +1 -1
  44. data/lib/active_shopify_graphql.rb +30 -28
  45. metadata +47 -15
  46. data/lib/active_shopify_graphql/associations.rb +0 -90
  47. data/lib/active_shopify_graphql/attributes.rb +0 -50
  48. data/lib/active_shopify_graphql/connection_loader.rb +0 -96
  49. data/lib/active_shopify_graphql/connections.rb +0 -198
  50. data/lib/active_shopify_graphql/finder_methods.rb +0 -159
  51. data/lib/active_shopify_graphql/fragment_builder.rb +0 -228
  52. data/lib/active_shopify_graphql/graphql_type_resolver.rb +0 -91
  53. data/lib/active_shopify_graphql/includes_scope.rb +0 -48
  54. data/lib/active_shopify_graphql/loader_switchable.rb +0 -180
  55. data/lib/active_shopify_graphql/metafield_attributes.rb +0 -61
  56. data/lib/active_shopify_graphql/query_node.rb +0 -173
  57. data/lib/active_shopify_graphql/query_tree.rb +0 -225
  58. data/lib/active_shopify_graphql/response_mapper.rb +0 -249
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL::Model::GraphqlTypeResolver
4
+ # Centralizes GraphQL type resolution logic.
5
+ extend ActiveSupport::Concern
6
+
7
+ class_methods do
8
+ # Set or get the base GraphQL type for this model.
9
+ #
10
+ # @param type [String, nil] The GraphQL type name to set, or nil to get
11
+ # @return [String] The GraphQL type name
12
+ # @raise [NotImplementedError] If no type is defined
13
+ def graphql_type(type = nil)
14
+ if type
15
+ if @current_loader_context
16
+ @loader_graphql_types ||= {}
17
+ @loader_graphql_types[@current_loader_context] = type
18
+ else
19
+ @base_graphql_type = type
20
+ end
21
+ end
22
+
23
+ @base_graphql_type || raise(NotImplementedError, "#{self} must define graphql_type")
24
+ end
25
+
26
+ # Get the GraphQL type for a specific loader class.
27
+ # Resolution order:
28
+ # 1. Loader-specific type defined via `for_loader`
29
+ # 2. Base graphql_type defined on the model
30
+ # 3. Type defined on the loader class itself
31
+ # 4. Inferred from model class name
32
+ #
33
+ # @param loader_class [Class] The loader class to resolve type for
34
+ # @return [String] The resolved GraphQL type
35
+ # @raise [NotImplementedError] If no type can be resolved
36
+ def graphql_type_for_loader(loader_class)
37
+ # 1. Check loader-specific override
38
+ return @loader_graphql_types[loader_class] if @loader_graphql_types&.key?(loader_class)
39
+
40
+ # 2. Check base graphql_type
41
+ return @base_graphql_type if @base_graphql_type
42
+
43
+ # 3. Check loader class itself
44
+ loader_type = loader_class.instance_variable_get(:@graphql_type)
45
+ return loader_type if loader_type
46
+
47
+ # 4. Infer from model name
48
+ return name.demodulize if respond_to?(:name) && name
49
+
50
+ raise NotImplementedError,
51
+ "#{self} must define graphql_type or #{loader_class} must define graphql_type"
52
+ end
53
+
54
+ # Resolve the GraphQL type from any source (model class, loader, or value).
55
+ # Useful for external callers that need to resolve type from various inputs.
56
+ #
57
+ # @param model_class [Class, nil] The model class
58
+ # @param loader_class [Class, nil] The loader class
59
+ # @return [String] The resolved GraphQL type
60
+ def resolve_graphql_type(model_class: nil, loader_class: nil)
61
+ if model_class.respond_to?(:graphql_type_for_loader) && loader_class
62
+ model_class.graphql_type_for_loader(loader_class)
63
+ elsif model_class.respond_to?(:graphql_type)
64
+ model_class.graphql_type
65
+ elsif loader_class.respond_to?(:graphql_type)
66
+ loader_class.graphql_type
67
+ elsif model_class.respond_to?(:name) && model_class.name
68
+ model_class.name.demodulize
69
+ else
70
+ raise ArgumentError, "Cannot resolve graphql_type from provided arguments"
71
+ end
72
+ end
73
+ end
74
+
75
+ # Module-level resolver for convenience
76
+ def self.resolve(model_class: nil, loader_class: nil)
77
+ if model_class.respond_to?(:graphql_type_for_loader) && loader_class
78
+ model_class.graphql_type_for_loader(loader_class)
79
+ elsif model_class.respond_to?(:graphql_type)
80
+ model_class.graphql_type
81
+ elsif loader_class.respond_to?(:graphql_type)
82
+ loader_class.graphql_type
83
+ elsif model_class.respond_to?(:name) && model_class.name
84
+ model_class.name.demodulize
85
+ else
86
+ raise ArgumentError, "Cannot resolve graphql_type"
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL::Model::LoaderSwitchable
4
+ # Provides capability to switch between different loaders within the same model
5
+ extend ActiveSupport::Concern
6
+
7
+ # Generic method to execute with a specific loader
8
+ # @param loader_class [Class] The loader class to use
9
+ # @yield [Object] Block to execute with the loader
10
+ # @return [Object] Result of the block
11
+ def with_loader(loader_class, &_block)
12
+ old_loader = Thread.current[:active_shopify_graphql_loader]
13
+ Thread.current[:active_shopify_graphql_loader] = loader_class.new(self.class)
14
+
15
+ if block_given?
16
+ yield(self)
17
+ else
18
+ self
19
+ end
20
+ ensure
21
+ Thread.current[:active_shopify_graphql_loader] = old_loader
22
+ end
23
+
24
+ # Executes with the admin API loader
25
+ # @return [self]
26
+ def with_admin_api(&block)
27
+ with_loader(ActiveShopifyGraphQL::Loaders::AdminApiLoader, &block)
28
+ end
29
+
30
+ # Executes with the customer account API loader
31
+ # @return [self]
32
+ def with_customer_account_api(&block)
33
+ with_loader(ActiveShopifyGraphQL::Loaders::CustomerAccountApiLoader, &block)
34
+ end
35
+
36
+ class_methods do
37
+ # @!method use_loader(loader_class)
38
+ # Sets the default loader class for this model.
39
+ #
40
+ # @param loader_class [Class] The loader class to use as default
41
+ # @example
42
+ # class Customer < ActiveRecord::Base
43
+ # use_loader ActiveShopifyGraphQL::Loaders::CustomerAccountApiLoader
44
+ # end
45
+ def use_loader(loader_class)
46
+ @default_loader_class = loader_class
47
+ end
48
+
49
+ # Define loader-specific attribute and graphql_type overrides
50
+ # @param loader_class [Class] The loader class to override attributes for
51
+ def for_loader(loader_class, &block)
52
+ @current_loader_context = loader_class
53
+ @loader_contexts ||= {}
54
+ @loader_contexts[loader_class] ||= {}
55
+ instance_eval(&block) if block_given?
56
+ @current_loader_context = nil
57
+ end
58
+
59
+ # Class-level method to execute with admin API loader
60
+ # @return [LoaderProxy] Proxy object with find method
61
+ def with_admin_api
62
+ ActiveShopifyGraphQL::LoaderProxy.new(self, ActiveShopifyGraphQL::Loaders::AdminApiLoader.new(self))
63
+ end
64
+
65
+ # Class-level method to execute with customer account API loader
66
+ # @return [LoaderProxy] Proxy object with find method
67
+ def with_customer_account_api(token = nil)
68
+ ActiveShopifyGraphQL::LoaderProxy.new(self, ActiveShopifyGraphQL::Loaders::CustomerAccountApiLoader.new(self, token))
69
+ end
70
+
71
+ private
72
+
73
+ # Returns the default loader class (either set via DSL or inferred)
74
+ # @return [Class] The default loader class
75
+ def default_loader_class
76
+ @default_loader_class ||= ActiveShopifyGraphQL::Loaders::AdminApiLoader
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL::Model::MetafieldAttributes
4
+ extend ActiveSupport::Concern
5
+
6
+ class_methods do
7
+ # Define a metafield attribute for this model.
8
+ #
9
+ # @param name [Symbol] The Ruby attribute name
10
+ # @param namespace [String] The metafield namespace
11
+ # @param key [String] The metafield key
12
+ # @param type [Symbol] The type for coercion (:string, :integer, :float, :boolean, :datetime, :json)
13
+ # @param null [Boolean] Whether the attribute can be null (default: true)
14
+ # @param default [Object] Default value when GraphQL response is nil
15
+ # @param transform [Proc] Custom transform block for the value
16
+ def metafield_attribute(name, namespace:, key:, type: :string, null: true, default: nil, transform: nil)
17
+ @metafields ||= {}
18
+ @metafields[name] = { namespace: namespace, key: key, type: type }
19
+
20
+ # Build metafield config
21
+ alias_name = "#{infer_path(name)}Metafield"
22
+ value_field = type == :json ? 'jsonValue' : 'value'
23
+ path = "#{alias_name}.#{value_field}"
24
+
25
+ config = {
26
+ path: path,
27
+ type: type,
28
+ null: null,
29
+ default: default,
30
+ transform: transform,
31
+ is_metafield: true,
32
+ metafield_alias: alias_name,
33
+ metafield_namespace: namespace,
34
+ metafield_key: key
35
+ }
36
+
37
+ if @current_loader_context
38
+ @loader_contexts[@current_loader_context][name] = config
39
+ else
40
+ @base_attributes ||= {}
41
+ @base_attributes[name] = config
42
+ end
43
+
44
+ attr_accessor name unless method_defined?(name) || method_defined?("#{name}=")
45
+ end
46
+
47
+ # Get metafields defined for this model
48
+ def metafields
49
+ @metafields || {}
50
+ end
51
+
52
+ private
53
+
54
+ # Infer GraphQL path from Ruby attribute name (delegates to Attributes if available)
55
+ def infer_path(name)
56
+ name.to_s.gsub(/_([a-z])/) { ::Regexp.last_match(1).upcase }
57
+ end
58
+ end
59
+ end
@@ -1,25 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveShopifyGraphQL
4
- module Base
5
- extend ActiveSupport::Concern
4
+ # Base class for all GraphQL-backed models.
5
+ #
6
+ # Models should inherit from this class (typically via an ApplicationShopifyGqlRecord
7
+ # intermediate class) to gain ActiveRecord-like functionality for Shopify GraphQL APIs.
8
+ #
9
+ # @example Creating an ApplicationShopifyGqlRecord base class
10
+ # class ApplicationShopifyGqlRecord < ActiveShopifyGraphQL::Model
11
+ # attribute :id, transform: ->(id) { id.split("/").last }
12
+ # attribute :gid, path: "id"
13
+ # end
14
+ #
15
+ # @example Defining a model
16
+ # class Customer < ApplicationShopifyGqlRecord
17
+ # graphql_type "Customer"
18
+ #
19
+ # attribute :first_name
20
+ # attribute :email, path: "defaultEmailAddress.emailAddress"
21
+ # end
22
+ #
23
+ class Model
24
+ include ActiveModel::AttributeAssignment
25
+ include ActiveModel::Validations
26
+ extend ActiveModel::Naming
6
27
 
7
- included do
8
- include ActiveModel::AttributeAssignment
9
- include ActiveModel::Validations
10
- extend ActiveModel::Naming
11
- include ActiveShopifyGraphQL::GraphqlTypeResolver
12
- include ActiveShopifyGraphQL::FinderMethods
13
- include ActiveShopifyGraphQL::Associations
14
- include ActiveShopifyGraphQL::Connections
15
- include ActiveShopifyGraphQL::Attributes
16
- include ActiveShopifyGraphQL::MetafieldAttributes
17
- include ActiveShopifyGraphQL::LoaderSwitchable
18
- end
28
+ include GraphqlTypeResolver
29
+ include FinderMethods
30
+ include Associations
31
+ include Connections
32
+ include Attributes
33
+ include MetafieldAttributes
34
+ include LoaderSwitchable
19
35
 
20
36
  def initialize(attributes = {})
21
- super()
22
-
23
37
  # Extract connection cache if present and populate inverse caches
24
38
  if attributes.key?(:_connection_cache)
25
39
  @_connection_cache = attributes.delete(:_connection_cache)
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ # Pure factory for building model instances from attribute hashes.
5
+ # Separates data mapping (Loader/ResponseMapper) from object construction.
6
+ #
7
+ # Responsibilities:
8
+ # - Build single model instances from attributes
9
+ # - Apply connection caches to instances
10
+ # - Handle batch building with connection caching
11
+ #
12
+ # @example Single instance
13
+ # attributes = loader.load_attributes(gid)
14
+ # customer = ModelBuilder.build(Customer, attributes)
15
+ #
16
+ # @example Batch building
17
+ # attributes_list = loader.load_paginated_attributes(...)
18
+ # customers = ModelBuilder.build_many(Customer, attributes_list)
19
+ class ModelBuilder
20
+ class << self
21
+ # Build a single model instance from attributes
22
+ # @param model_class [Class] The model class to instantiate
23
+ # @param attributes [Hash] Attribute hash (may contain :_connection_cache)
24
+ # @return [Object] The instantiated model with cached connections
25
+ def build(model_class, attributes)
26
+ return nil if attributes.nil?
27
+
28
+ instance = model_class.new(attributes)
29
+ apply_connection_cache(instance, attributes)
30
+ instance
31
+ end
32
+
33
+ # Build multiple model instances from an array of attribute hashes
34
+ # @param model_class [Class] The model class to instantiate
35
+ # @param attributes_array [Array<Hash>] Array of attribute hashes
36
+ # @return [Array<Object>] Array of instantiated models
37
+ def build_many(model_class, attributes_array)
38
+ return [] if attributes_array.nil? || attributes_array.empty?
39
+
40
+ attributes_array.filter_map { |attrs| build(model_class, attrs) }
41
+ end
42
+
43
+ # Apply connection cache from attributes to an already-instantiated model
44
+ # @param instance [Object] The model instance
45
+ # @param attributes [Hash] Attribute hash that may contain :_connection_cache
46
+ def apply_connection_cache(instance, attributes)
47
+ return unless attributes.is_a?(Hash) && attributes.key?(:_connection_cache)
48
+
49
+ instance.instance_variable_set(:@_connection_cache, attributes[:_connection_cache])
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ class Node
6
+ # Renders a collection query with optional pagination.
7
+ # Example without pagination:
8
+ # query getCustomers {
9
+ # customers(first: 10) {
10
+ # nodes {
11
+ # ...CustomerFragment
12
+ # }
13
+ # }
14
+ # }
15
+ #
16
+ # Example with pagination:
17
+ # query getCustomers {
18
+ # customers(first: 10, after: "cursor") {
19
+ # pageInfo {
20
+ # hasNextPage
21
+ # hasPreviousPage
22
+ # startCursor
23
+ # endCursor
24
+ # }
25
+ # nodes {
26
+ # ...CustomerFragment
27
+ # }
28
+ # }
29
+ # }
30
+ class Collection < Node
31
+ attr_reader :model_type, :query_name, :fragment_name, :variables, :fragments, :include_page_info
32
+
33
+ def initialize(model_type:, query_name:, fragment_name:, variables: {}, fragments: [], include_page_info: false)
34
+ @model_type = model_type
35
+ @query_name = query_name
36
+ @fragment_name = fragment_name
37
+ @variables = variables
38
+ @fragments = fragments
39
+ @include_page_info = include_page_info
40
+ super(name: query_name)
41
+ end
42
+
43
+ def to_s
44
+ parts = [fragments_string, "query get#{model_type.pluralize} { #{query_name}#{field_signature} {"]
45
+ parts << PAGE_INFO_FIELDS if @include_page_info
46
+ parts << "nodes { ...#{fragment_name} }"
47
+ parts << "} }"
48
+ parts.join(' ')
49
+ end
50
+
51
+ private
52
+
53
+ def fragments_string
54
+ @fragments.map(&:to_s).join(' ')
55
+ end
56
+
57
+ def field_signature
58
+ params = build_field_parameters(@variables.compact)
59
+ params.empty? ? "" : "(#{params.join(', ')})"
60
+ end
61
+
62
+ def build_field_parameters(variables)
63
+ variables.map do |key, value|
64
+ "#{key.to_s.camelize(:lower)}: #{format_inline_value(key, value)}"
65
+ end
66
+ end
67
+
68
+ def format_inline_value(key, value)
69
+ case value
70
+ when Integer, TrueClass, FalseClass then value.to_s
71
+ when String then STRING_KEYS_NEEDING_QUOTES.include?(key.to_sym) ? "\"#{value}\"" : value
72
+ else value.to_s
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ class Node
6
+ # Renders connection fields using the nodes pattern.
7
+ # Example: `orders(first: 10) { nodes { id createdAt } }`
8
+ class Connection < Node
9
+ def to_s
10
+ nested_fields = render_children
11
+ "#{field_name_with_alias}#{format_arguments} { nodes { #{nested_fields.join(' ')} } }"
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ class Node
6
+ # Renders a query that doesn't require an ID parameter.
7
+ # Used for Customer Account API's current customer query.
8
+ # Example:
9
+ # query getCurrentCustomer {
10
+ # customer {
11
+ # ...CustomerFragment
12
+ # }
13
+ # }
14
+ class CurrentCustomer < Node
15
+ attr_reader :model_type, :query_name, :fragment_name, :fragments
16
+
17
+ def initialize(model_type:, query_name:, fragment_name:, fragments: [])
18
+ @model_type = model_type
19
+ @query_name = query_name
20
+ @fragment_name = fragment_name
21
+ @fragments = fragments
22
+ super(name: query_name)
23
+ end
24
+
25
+ def to_s
26
+ "#{fragments_string} query getCurrent#{model_type} { #{query_name} { ...#{fragment_name} } }"
27
+ end
28
+
29
+ private
30
+
31
+ def fragments_string
32
+ @fragments.map(&:to_s).join(' ')
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ class Node
6
+ # Renders simple fields and nested fields with children.
7
+ # Examples:
8
+ # - Simple: `id`
9
+ # - With alias: `customerId: id`
10
+ # - Nested: `defaultAddress { city country }`
11
+ class Field < Node
12
+ def to_s
13
+ full_name = "#{field_name_with_alias}#{format_arguments}"
14
+
15
+ return full_name unless has_children?
16
+
17
+ nested_fields = render_children
18
+ "#{full_name} { #{nested_fields.join(' ')} }"
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ class Node
6
+ # Renders GraphQL fragment definitions.
7
+ # Example: `fragment CustomerFragment on Customer { id displayName email }`
8
+ class Fragment < Node
9
+ def to_s
10
+ type_name = @arguments[:on]
11
+ fields = render_children
12
+ "fragment #{@name} on #{type_name} { #{fields.join(' ')} }"
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ class Node
6
+ # Renders a nested connection query (loaded from a parent record).
7
+ # Example:
8
+ # query($id: ID!) {
9
+ # customer(id: $id) {
10
+ # orders(first: 10) {
11
+ # pageInfo {
12
+ # hasNextPage
13
+ # hasPreviousPage
14
+ # startCursor
15
+ # endCursor
16
+ # }
17
+ # nodes {
18
+ # ...OrderFragment
19
+ # }
20
+ # }
21
+ # }
22
+ # }
23
+ class NestedConnection < Node
24
+ attr_reader :query_name, :fragment_name, :variables, :parent_query, :fragments, :singular
25
+
26
+ def initialize(query_name:, fragment_name:, parent_query:, variables: {}, fragments: [], singular: false)
27
+ @query_name = query_name
28
+ @fragment_name = fragment_name
29
+ @variables = variables
30
+ @parent_query = parent_query
31
+ @fragments = fragments
32
+ @singular = singular
33
+ super(name: query_name)
34
+ end
35
+
36
+ def to_s(*)
37
+ if @singular
38
+ render_singular_connection
39
+ else
40
+ render_nodes_connection
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def render_singular_connection
47
+ "#{fragments_string} query($id: ID!) { #{parent_query} { #{query_name}#{field_signature} { ...#{fragment_name} } } }"
48
+ end
49
+
50
+ def render_nodes_connection
51
+ "#{fragments_string} query($id: ID!) { #{parent_query} { #{query_name}#{field_signature} { #{PAGE_INFO_FIELDS} nodes { ...#{fragment_name} } } } }"
52
+ end
53
+
54
+ def fragments_string
55
+ @fragments.map(&:to_s).join(' ')
56
+ end
57
+
58
+ def field_signature
59
+ params = build_field_parameters(@variables.compact)
60
+ params.empty? ? "" : "(#{params.join(', ')})"
61
+ end
62
+
63
+ def build_field_parameters(variables)
64
+ variables.map do |key, value|
65
+ "#{key.to_s.camelize(:lower)}: #{format_inline_value(key, value)}"
66
+ end
67
+ end
68
+
69
+ def format_inline_value(key, value)
70
+ case value
71
+ when Integer, TrueClass, FalseClass then value.to_s
72
+ when String then STRING_KEYS_NEEDING_QUOTES.include?(key.to_sym) ? "\"#{value}\"" : value
73
+ else value.to_s
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ class Node
6
+ # Renders raw GraphQL strings verbatim.
7
+ # Used for custom GraphQL snippets that don't fit other node types.
8
+ class Raw < Node
9
+ def to_s(*)
10
+ @arguments[:raw_graphql]
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ class Node
6
+ # Renders a root-level connection query (no parent record).
7
+ # Example:
8
+ # query {
9
+ # customers(first: 10) {
10
+ # pageInfo {
11
+ # hasNextPage
12
+ # hasPreviousPage
13
+ # startCursor
14
+ # endCursor
15
+ # }
16
+ # nodes {
17
+ # ...CustomerFragment
18
+ # }
19
+ # }
20
+ # }
21
+ class RootConnection < Node
22
+ attr_reader :query_name, :fragment_name, :variables, :fragments, :singular
23
+
24
+ def initialize(query_name:, fragment_name:, variables: {}, fragments: [], singular: false)
25
+ @query_name = query_name
26
+ @fragment_name = fragment_name
27
+ @variables = variables
28
+ @fragments = fragments
29
+ @singular = singular
30
+ super(name: query_name)
31
+ end
32
+
33
+ def to_s(*)
34
+ if @singular
35
+ render_singular_connection
36
+ else
37
+ render_nodes_connection
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def render_singular_connection
44
+ "#{fragments_string} query { #{query_name}#{field_signature} { ...#{fragment_name} } }"
45
+ end
46
+
47
+ def render_nodes_connection
48
+ "#{fragments_string} query { #{query_name}#{field_signature} { #{PAGE_INFO_FIELDS} nodes { ...#{fragment_name} } } }"
49
+ end
50
+
51
+ def fragments_string
52
+ @fragments.map(&:to_s).join(' ')
53
+ end
54
+
55
+ def field_signature
56
+ params = build_field_parameters(@variables.compact)
57
+ params.empty? ? "" : "(#{params.join(', ')})"
58
+ end
59
+
60
+ def build_field_parameters(variables)
61
+ variables.map do |key, value|
62
+ "#{key.to_s.camelize(:lower)}: #{format_inline_value(key, value)}"
63
+ end
64
+ end
65
+
66
+ def format_inline_value(key, value)
67
+ case value
68
+ when Integer, TrueClass, FalseClass then value.to_s
69
+ when String then STRING_KEYS_NEEDING_QUOTES.include?(key.to_sym) ? "\"#{value}\"" : value
70
+ else value.to_s
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end