active_shopify_graphql 0.1.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.
- checksums.yaml +7 -0
- data/.github/workflows/lint.yml +35 -0
- data/.github/workflows/test.yml +35 -0
- data/.rubocop.yml +50 -0
- data/AGENTS.md +53 -0
- data/LICENSE.txt +21 -0
- data/README.md +544 -0
- data/Rakefile +8 -0
- data/lib/active_shopify_graphql/associations.rb +90 -0
- data/lib/active_shopify_graphql/attributes.rb +49 -0
- data/lib/active_shopify_graphql/base.rb +29 -0
- data/lib/active_shopify_graphql/configuration.rb +29 -0
- data/lib/active_shopify_graphql/connection_loader.rb +96 -0
- data/lib/active_shopify_graphql/connections/connection_proxy.rb +112 -0
- data/lib/active_shopify_graphql/connections.rb +170 -0
- data/lib/active_shopify_graphql/finder_methods.rb +154 -0
- data/lib/active_shopify_graphql/fragment_builder.rb +195 -0
- data/lib/active_shopify_graphql/gid_helper.rb +54 -0
- data/lib/active_shopify_graphql/graphql_type_resolver.rb +91 -0
- data/lib/active_shopify_graphql/loader.rb +183 -0
- data/lib/active_shopify_graphql/loader_context.rb +88 -0
- data/lib/active_shopify_graphql/loader_switchable.rb +121 -0
- data/lib/active_shopify_graphql/loaders/admin_api_loader.rb +32 -0
- data/lib/active_shopify_graphql/loaders/customer_account_api_loader.rb +71 -0
- data/lib/active_shopify_graphql/metafield_attributes.rb +61 -0
- data/lib/active_shopify_graphql/query_node.rb +160 -0
- data/lib/active_shopify_graphql/query_tree.rb +204 -0
- data/lib/active_shopify_graphql/response_mapper.rb +202 -0
- data/lib/active_shopify_graphql/search_query.rb +71 -0
- data/lib/active_shopify_graphql/version.rb +5 -0
- data/lib/active_shopify_graphql.rb +34 -0
- metadata +147 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
# Provides capability to switch between different loaders within the same model
|
|
5
|
+
module LoaderSwitchable
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
# Generic method to execute with a specific loader
|
|
9
|
+
# @param loader_class [Class] The loader class to use
|
|
10
|
+
# @yield [Object] Block to execute with the loader
|
|
11
|
+
# @return [Object] Result of the block
|
|
12
|
+
def with_loader(loader_class, &_block)
|
|
13
|
+
old_loader = Thread.current[:active_shopify_graphql_loader]
|
|
14
|
+
Thread.current[:active_shopify_graphql_loader] = loader_class.new(self.class)
|
|
15
|
+
|
|
16
|
+
if block_given?
|
|
17
|
+
yield(self)
|
|
18
|
+
else
|
|
19
|
+
self
|
|
20
|
+
end
|
|
21
|
+
ensure
|
|
22
|
+
Thread.current[:active_shopify_graphql_loader] = old_loader
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Executes with the admin API loader
|
|
26
|
+
# @return [self]
|
|
27
|
+
def with_admin_api(&block)
|
|
28
|
+
with_loader(ActiveShopifyGraphQL::Loaders::AdminApiLoader, &block)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Executes with the customer account API loader
|
|
32
|
+
# @return [self]
|
|
33
|
+
def with_customer_account_api(&block)
|
|
34
|
+
with_loader(ActiveShopifyGraphQL::Loaders::CustomerAccountApiLoader, &block)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class_methods do
|
|
38
|
+
# @!method use_loader(loader_class)
|
|
39
|
+
# Sets the default loader class for this model.
|
|
40
|
+
#
|
|
41
|
+
# @param loader_class [Class] The loader class to use as default
|
|
42
|
+
# @example
|
|
43
|
+
# class Customer < ActiveRecord::Base
|
|
44
|
+
# use_loader ActiveShopifyGraphQL::Loaders::CustomerAccountApiLoader
|
|
45
|
+
# end
|
|
46
|
+
def use_loader(loader_class)
|
|
47
|
+
@default_loader_class = loader_class
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Define loader-specific attribute and graphql_type overrides
|
|
51
|
+
# @param loader_class [Class] The loader class to override attributes for
|
|
52
|
+
def for_loader(loader_class, &block)
|
|
53
|
+
@current_loader_context = loader_class
|
|
54
|
+
@loader_contexts ||= {}
|
|
55
|
+
@loader_contexts[loader_class] ||= {}
|
|
56
|
+
instance_eval(&block) if block_given?
|
|
57
|
+
@current_loader_context = nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Class-level method to execute with admin API loader
|
|
61
|
+
# @return [LoaderProxy] Proxy object with find method
|
|
62
|
+
def with_admin_api
|
|
63
|
+
LoaderProxy.new(self, ActiveShopifyGraphQL::Loaders::AdminApiLoader.new(self))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Class-level method to execute with customer account API loader
|
|
67
|
+
# @return [LoaderProxy] Proxy object with find method
|
|
68
|
+
def with_customer_account_api(token = nil)
|
|
69
|
+
LoaderProxy.new(self, ActiveShopifyGraphQL::Loaders::CustomerAccountApiLoader.new(self, token))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Returns the default loader class (either set via DSL or inferred)
|
|
75
|
+
# @return [Class] The default loader class
|
|
76
|
+
def default_loader_class
|
|
77
|
+
@default_loader_class ||= ActiveShopifyGraphQL::Loaders::AdminApiLoader
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Simple proxy class to handle loader delegation
|
|
82
|
+
class LoaderProxy
|
|
83
|
+
def initialize(model_class, loader)
|
|
84
|
+
@model_class = model_class
|
|
85
|
+
@loader = loader
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def find(id = nil)
|
|
89
|
+
# For Customer Account API, if no ID is provided, load the current customer
|
|
90
|
+
if id.nil? && @loader.is_a?(ActiveShopifyGraphQL::Loaders::CustomerAccountApiLoader)
|
|
91
|
+
attributes = @loader.load_attributes
|
|
92
|
+
return nil if attributes.nil?
|
|
93
|
+
|
|
94
|
+
return @model_class.new(attributes)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# For other cases, require ID and use standard flow
|
|
98
|
+
return nil if id.nil?
|
|
99
|
+
|
|
100
|
+
gid = GidHelper.normalize_gid(id, @model_class.model_name.name.demodulize)
|
|
101
|
+
|
|
102
|
+
attributes = @loader.load_attributes(gid)
|
|
103
|
+
return nil if attributes.nil?
|
|
104
|
+
|
|
105
|
+
@model_class.new(attributes)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Delegate where to the model class with the specific loader
|
|
109
|
+
def where(*args, **options)
|
|
110
|
+
@model_class.where(*args, **options.merge(loader: @loader))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
attr_reader :loader
|
|
114
|
+
|
|
115
|
+
def inspect
|
|
116
|
+
"#{@model_class.name}(with_#{@loader.class.name.demodulize})"
|
|
117
|
+
end
|
|
118
|
+
alias to_s inspect
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
module Loaders
|
|
5
|
+
class AdminApiLoader < Loader
|
|
6
|
+
def initialize(model_class = nil, selected_attributes: nil, included_connections: nil)
|
|
7
|
+
super(model_class, selected_attributes: selected_attributes, included_connections: included_connections)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def perform_graphql_query(query, **variables)
|
|
11
|
+
log_query(query, variables) if should_log?
|
|
12
|
+
|
|
13
|
+
client = ActiveShopifyGraphQL.configuration.admin_api_client
|
|
14
|
+
raise Error, "Admin API client not configured. Please configure it using ActiveShopifyGraphQL.configure" unless client
|
|
15
|
+
|
|
16
|
+
client.execute(query, **variables)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def should_log?
|
|
22
|
+
ActiveShopifyGraphQL.configuration.log_queries && ActiveShopifyGraphQL.configuration.logger
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def log_query(query, variables)
|
|
26
|
+
logger = ActiveShopifyGraphQL.configuration.logger
|
|
27
|
+
logger.info("ActiveShopifyGraphQL Query (Admin API):\n#{query}")
|
|
28
|
+
logger.info("ActiveShopifyGraphQL Variables:\n#{variables}")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
module Loaders
|
|
5
|
+
class CustomerAccountApiLoader < Loader
|
|
6
|
+
def initialize(model_class = nil, token = nil, selected_attributes: nil, included_connections: nil)
|
|
7
|
+
super(model_class, selected_attributes: selected_attributes, included_connections: included_connections)
|
|
8
|
+
@token = token
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Override to handle Customer queries that don't need an ID
|
|
12
|
+
def graphql_query(model_type = nil)
|
|
13
|
+
type = model_type || graphql_type
|
|
14
|
+
if type == 'Customer'
|
|
15
|
+
customer_only_query(type)
|
|
16
|
+
else
|
|
17
|
+
super(type)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Override load_attributes to handle the Customer case
|
|
22
|
+
def load_attributes(id = nil)
|
|
23
|
+
type = graphql_type
|
|
24
|
+
query = graphql_query(type)
|
|
25
|
+
|
|
26
|
+
variables = type == 'Customer' ? {} : { id: id }
|
|
27
|
+
response_data = perform_graphql_query(query, **variables)
|
|
28
|
+
|
|
29
|
+
return nil if response_data.nil?
|
|
30
|
+
|
|
31
|
+
map_response_to_attributes(response_data)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def client
|
|
35
|
+
client_class = ActiveShopifyGraphQL.configuration.customer_account_client_class
|
|
36
|
+
raise Error, "Customer Account API client class not configured" unless client_class
|
|
37
|
+
|
|
38
|
+
@client ||= client_class.from_config(@token)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def perform_graphql_query(query, **variables)
|
|
42
|
+
log_query(query, variables) if should_log?
|
|
43
|
+
client.query(query, variables)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def should_log?
|
|
49
|
+
ActiveShopifyGraphQL.configuration.log_queries && ActiveShopifyGraphQL.configuration.logger
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def log_query(query, variables)
|
|
53
|
+
logger = ActiveShopifyGraphQL.configuration.logger
|
|
54
|
+
logger.info("ActiveShopifyGraphQL Query (Customer Account API):\n#{query}")
|
|
55
|
+
logger.info("ActiveShopifyGraphQL Variables:\n#{variables}")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def customer_only_query(model_type = nil)
|
|
59
|
+
type = model_type || graphql_type
|
|
60
|
+
compact = ActiveShopifyGraphQL.configuration.compact_queries
|
|
61
|
+
frag = fragment
|
|
62
|
+
|
|
63
|
+
if compact
|
|
64
|
+
"#{frag} query getCurrentCustomer { #{query_name(type)} { ...#{fragment_name(type)} } }"
|
|
65
|
+
else
|
|
66
|
+
"#{frag}\n\nquery getCurrentCustomer {\n #{query_name(type)} {\n ...#{fragment_name(type)}\n }\n}\n"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
module MetafieldAttributes
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
class_methods do
|
|
8
|
+
# Define a metafield attribute for this model.
|
|
9
|
+
#
|
|
10
|
+
# @param name [Symbol] The Ruby attribute name
|
|
11
|
+
# @param namespace [String] The metafield namespace
|
|
12
|
+
# @param key [String] The metafield key
|
|
13
|
+
# @param type [Symbol] The type for coercion (:string, :integer, :float, :boolean, :datetime, :json)
|
|
14
|
+
# @param null [Boolean] Whether the attribute can be null (default: true)
|
|
15
|
+
# @param default [Object] Default value when GraphQL response is nil
|
|
16
|
+
# @param transform [Proc] Custom transform block for the value
|
|
17
|
+
def metafield_attribute(name, namespace:, key:, type: :string, null: true, default: nil, transform: nil)
|
|
18
|
+
@metafields ||= {}
|
|
19
|
+
@metafields[name] = { namespace: namespace, key: key, type: type }
|
|
20
|
+
|
|
21
|
+
# Build metafield config
|
|
22
|
+
alias_name = "#{infer_path(name)}Metafield"
|
|
23
|
+
value_field = type == :json ? 'jsonValue' : 'value'
|
|
24
|
+
path = "#{alias_name}.#{value_field}"
|
|
25
|
+
|
|
26
|
+
config = {
|
|
27
|
+
path: path,
|
|
28
|
+
type: type,
|
|
29
|
+
null: null,
|
|
30
|
+
default: default,
|
|
31
|
+
transform: transform,
|
|
32
|
+
is_metafield: true,
|
|
33
|
+
metafield_alias: alias_name,
|
|
34
|
+
metafield_namespace: namespace,
|
|
35
|
+
metafield_key: key
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if @current_loader_context
|
|
39
|
+
@loader_contexts[@current_loader_context][name] = config
|
|
40
|
+
else
|
|
41
|
+
@base_attributes ||= {}
|
|
42
|
+
@base_attributes[name] = config
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
attr_accessor name unless method_defined?(name) || method_defined?("#{name}=")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Get metafields defined for this model
|
|
49
|
+
def metafields
|
|
50
|
+
@metafields || {}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Infer GraphQL path from Ruby attribute name (delegates to Attributes if available)
|
|
56
|
+
def infer_path(name)
|
|
57
|
+
name.to_s.gsub(/_([a-z])/) { ::Regexp.last_match(1).upcase }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Represents a node in the GraphQL query tree
|
|
4
|
+
# This allows building the complete query structure before converting to a string
|
|
5
|
+
class QueryNode
|
|
6
|
+
attr_reader :name, :arguments, :children, :node_type, :alias_name
|
|
7
|
+
|
|
8
|
+
# @param name [String] The field name (e.g., 'id', 'displayName', 'orders')
|
|
9
|
+
# @param alias_name [String] Optional field alias (e.g., 'myAlias' for 'myAlias: fieldName')
|
|
10
|
+
# @param arguments [Hash] Field arguments (e.g., { first: 10, sortKey: 'CREATED_AT' })
|
|
11
|
+
# @param node_type [Symbol] Type of node: :field, :connection, :singular, :fragment
|
|
12
|
+
# @param children [Array<QueryNode>] Child nodes for nested structures
|
|
13
|
+
def initialize(name:, alias_name: nil, arguments: {}, node_type: :field, children: [])
|
|
14
|
+
@name = name
|
|
15
|
+
@alias_name = alias_name
|
|
16
|
+
@arguments = arguments
|
|
17
|
+
@node_type = node_type
|
|
18
|
+
@children = children
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Add a child node
|
|
22
|
+
def add_child(node)
|
|
23
|
+
@children << node
|
|
24
|
+
node
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Check if node has children
|
|
28
|
+
def has_children?
|
|
29
|
+
@children.any?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Convert node to GraphQL string
|
|
33
|
+
def to_s(indent_level: 0)
|
|
34
|
+
case @node_type
|
|
35
|
+
when :field
|
|
36
|
+
render_field(indent_level: indent_level)
|
|
37
|
+
when :connection
|
|
38
|
+
render_connection(indent_level: indent_level)
|
|
39
|
+
when :singular
|
|
40
|
+
render_singular(indent_level: indent_level)
|
|
41
|
+
when :fragment
|
|
42
|
+
render_fragment
|
|
43
|
+
else
|
|
44
|
+
raise ArgumentError, "Unknown node type: #{@node_type}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def compact?
|
|
51
|
+
ActiveShopifyGraphQL.configuration.compact_queries
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def render_field(indent_level:)
|
|
55
|
+
# Simple field with no children
|
|
56
|
+
field_name = @alias_name ? "#{@alias_name}: #{@name}" : @name
|
|
57
|
+
args_string = format_arguments
|
|
58
|
+
full_name = "#{field_name}#{args_string}"
|
|
59
|
+
|
|
60
|
+
return full_name unless has_children?
|
|
61
|
+
|
|
62
|
+
# Field with nested structure (e.g., defaultAddress { city })
|
|
63
|
+
indent = compact? ? "" : " " * indent_level
|
|
64
|
+
nested_indent = compact? ? "" : " " * (indent_level + 1)
|
|
65
|
+
|
|
66
|
+
nested_fields = @children.map { |child| child.to_s(indent_level: indent_level + 1) }
|
|
67
|
+
|
|
68
|
+
if compact?
|
|
69
|
+
"#{full_name} { #{nested_fields.join(' ')} }"
|
|
70
|
+
else
|
|
71
|
+
separator = "\n"
|
|
72
|
+
"#{full_name} {#{separator}#{nested_indent}#{nested_fields.join("\n#{nested_indent}")}#{separator}#{indent}}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def render_connection(indent_level:)
|
|
77
|
+
args_string = format_arguments
|
|
78
|
+
|
|
79
|
+
indent = compact? ? "" : " " * indent_level
|
|
80
|
+
nested_indent = compact? ? "" : " " * (indent_level + 1)
|
|
81
|
+
|
|
82
|
+
# Build nested fields from children
|
|
83
|
+
nested_fields = @children.map { |child| child.to_s(indent_level: indent_level + 2) }
|
|
84
|
+
fields_string = nested_fields.join(compact? ? " " : "\n#{nested_indent} ")
|
|
85
|
+
|
|
86
|
+
if compact?
|
|
87
|
+
"#{@name}#{args_string} { edges { node { #{fields_string} } } }"
|
|
88
|
+
else
|
|
89
|
+
<<~GRAPHQL.strip
|
|
90
|
+
#{@name}#{args_string} {
|
|
91
|
+
#{nested_indent}edges {
|
|
92
|
+
#{nested_indent} node {
|
|
93
|
+
#{nested_indent} #{fields_string}
|
|
94
|
+
#{nested_indent} }
|
|
95
|
+
#{nested_indent}}
|
|
96
|
+
#{indent}}
|
|
97
|
+
GRAPHQL
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def render_singular(indent_level:)
|
|
102
|
+
args_string = format_arguments
|
|
103
|
+
|
|
104
|
+
indent = compact? ? "" : " " * indent_level
|
|
105
|
+
nested_indent = compact? ? "" : " " * (indent_level + 1)
|
|
106
|
+
|
|
107
|
+
# Build nested fields from children
|
|
108
|
+
nested_fields = @children.map { |child| child.to_s(indent_level: indent_level + 1) }
|
|
109
|
+
fields_string = nested_fields.join(compact? ? " " : "\n#{nested_indent}")
|
|
110
|
+
|
|
111
|
+
if compact?
|
|
112
|
+
"#{@name}#{args_string} { #{fields_string} }"
|
|
113
|
+
else
|
|
114
|
+
"#{@name}#{args_string} {\n#{nested_indent}#{fields_string}\n#{indent}}"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def render_fragment
|
|
119
|
+
# Fragment fields are the children
|
|
120
|
+
fields = @children.map { |child| child.to_s(indent_level: 0) }
|
|
121
|
+
all_fields = fields.join(compact? ? " " : "\n")
|
|
122
|
+
|
|
123
|
+
if compact?
|
|
124
|
+
"fragment #{@name} on #{@arguments[:on]} { #{all_fields} }"
|
|
125
|
+
else
|
|
126
|
+
"fragment #{@name} on #{@arguments[:on]} {\n#{all_fields}\n}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def format_arguments
|
|
131
|
+
return "" if @arguments.empty?
|
|
132
|
+
|
|
133
|
+
args = @arguments.map do |key, value|
|
|
134
|
+
# Convert Ruby snake_case to GraphQL camelCase
|
|
135
|
+
graphql_key = key.to_s.camelize(:lower)
|
|
136
|
+
|
|
137
|
+
# Format value based on type
|
|
138
|
+
formatted_value = case value
|
|
139
|
+
when String
|
|
140
|
+
# Check if it needs quotes (query parameter vs enum values)
|
|
141
|
+
# For metafields and most strings, add quotes
|
|
142
|
+
if %i[namespace key].include?(key.to_sym)
|
|
143
|
+
"\"#{value}\""
|
|
144
|
+
elsif key.to_sym == :query
|
|
145
|
+
"\"#{value}\""
|
|
146
|
+
else
|
|
147
|
+
value
|
|
148
|
+
end
|
|
149
|
+
when Symbol
|
|
150
|
+
value.to_s
|
|
151
|
+
else
|
|
152
|
+
value
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
"#{graphql_key}: #{formatted_value}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
"(#{args.join(', ')})"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
# Builds complete GraphQL queries from a LoaderContext.
|
|
5
|
+
# Refactored for Single Responsibility - only handles query string generation.
|
|
6
|
+
# Fragment building is delegated to FragmentBuilder.
|
|
7
|
+
class QueryTree
|
|
8
|
+
attr_reader :context
|
|
9
|
+
|
|
10
|
+
def initialize(context)
|
|
11
|
+
@context = context
|
|
12
|
+
@fragments = []
|
|
13
|
+
@query_config = {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Class-level factory methods for building complete queries
|
|
17
|
+
|
|
18
|
+
def self.build_single_record_query(context)
|
|
19
|
+
new(context).tap do |tree|
|
|
20
|
+
tree.add_fragment(FragmentBuilder.new(context).build)
|
|
21
|
+
tree.set_query_config(
|
|
22
|
+
type: :single_record,
|
|
23
|
+
model_type: context.graphql_type,
|
|
24
|
+
query_name: context.query_name,
|
|
25
|
+
fragment_name: context.fragment_name
|
|
26
|
+
)
|
|
27
|
+
end.to_s
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.build_collection_query(context, query_name:, variables:, connection_type: :nodes_only)
|
|
31
|
+
new(context).tap do |tree|
|
|
32
|
+
tree.add_fragment(FragmentBuilder.new(context).build)
|
|
33
|
+
tree.set_query_config(
|
|
34
|
+
type: :collection,
|
|
35
|
+
model_type: context.graphql_type,
|
|
36
|
+
query_name: query_name,
|
|
37
|
+
fragment_name: context.fragment_name,
|
|
38
|
+
variables: variables,
|
|
39
|
+
connection_type: connection_type
|
|
40
|
+
)
|
|
41
|
+
end.to_s
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.build_connection_query(context, query_name:, variables:, parent_query: nil, connection_type: :connection)
|
|
45
|
+
new(context).tap do |tree|
|
|
46
|
+
tree.add_fragment(FragmentBuilder.new(context).build)
|
|
47
|
+
tree.set_query_config(
|
|
48
|
+
type: :connection,
|
|
49
|
+
query_name: query_name,
|
|
50
|
+
fragment_name: context.fragment_name,
|
|
51
|
+
variables: variables,
|
|
52
|
+
parent_query: parent_query,
|
|
53
|
+
connection_type: connection_type
|
|
54
|
+
)
|
|
55
|
+
end.to_s
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Delegate normalize_includes to FragmentBuilder
|
|
59
|
+
def self.normalize_includes(includes)
|
|
60
|
+
FragmentBuilder.normalize_includes(includes)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Helper methods (kept for backward compatibility)
|
|
64
|
+
def self.query_name(graphql_type)
|
|
65
|
+
graphql_type.downcase
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.fragment_name(graphql_type)
|
|
69
|
+
"#{graphql_type}Fragment"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def add_fragment(fragment_node)
|
|
73
|
+
@fragments << fragment_node
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def set_query_config(config)
|
|
77
|
+
@query_config = config
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def to_s
|
|
81
|
+
case @query_config[:type]
|
|
82
|
+
when :single_record then render_single_record_query
|
|
83
|
+
when :collection then render_collection_query
|
|
84
|
+
when :connection then render_connection_query
|
|
85
|
+
else ""
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def compact?
|
|
92
|
+
ActiveShopifyGraphQL.configuration.compact_queries
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def fragments_string
|
|
96
|
+
@fragments.map(&:to_s).join(compact? ? " " : "\n\n")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def field_signature(variables)
|
|
100
|
+
params = build_field_parameters(variables.compact)
|
|
101
|
+
params.empty? ? "" : "(#{params.join(', ')})"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def render_single_record_query
|
|
105
|
+
type = @query_config[:model_type]
|
|
106
|
+
query_name = @query_config[:query_name]
|
|
107
|
+
fragment_name = @query_config[:fragment_name]
|
|
108
|
+
|
|
109
|
+
if compact?
|
|
110
|
+
"#{fragments_string} query get#{type}($id: ID!) { #{query_name}(id: $id) { ...#{fragment_name} } }"
|
|
111
|
+
else
|
|
112
|
+
"#{fragments_string}\n\nquery get#{type}($id: ID!) {\n #{query_name}(id: $id) {\n ...#{fragment_name}\n }\n}\n"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def render_collection_query
|
|
117
|
+
type = @query_config[:model_type]
|
|
118
|
+
query_name = @query_config[:query_name]
|
|
119
|
+
fragment_name = @query_config[:fragment_name]
|
|
120
|
+
variables = @query_config[:variables] || {}
|
|
121
|
+
connection_type = @query_config[:connection_type] || :nodes_only
|
|
122
|
+
|
|
123
|
+
field_sig = field_signature(variables)
|
|
124
|
+
|
|
125
|
+
if compact?
|
|
126
|
+
body = wrap_connection_body_compact(fragment_name, connection_type)
|
|
127
|
+
"#{fragments_string} query get#{type.pluralize} { #{query_name}#{field_sig} { #{body} } }"
|
|
128
|
+
else
|
|
129
|
+
body = wrap_connection_body_formatted(fragment_name, connection_type, 2)
|
|
130
|
+
"#{fragments_string}\nquery get#{type.pluralize} {\n #{query_name}#{field_sig} {\n#{body}\n }\n}\n"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def render_connection_query
|
|
135
|
+
query_name = @query_config[:query_name]
|
|
136
|
+
fragment_name = @query_config[:fragment_name]
|
|
137
|
+
variables = @query_config[:variables] || {}
|
|
138
|
+
parent_query = @query_config[:parent_query]
|
|
139
|
+
connection_type = @query_config[:connection_type] || :connection
|
|
140
|
+
|
|
141
|
+
field_sig = field_signature(variables)
|
|
142
|
+
|
|
143
|
+
if parent_query
|
|
144
|
+
render_nested_connection_query(query_name, fragment_name, field_sig, parent_query, connection_type)
|
|
145
|
+
else
|
|
146
|
+
render_root_connection_query(query_name, fragment_name, field_sig, connection_type)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def render_nested_connection_query(query_name, fragment_name, field_sig, parent_query, connection_type)
|
|
151
|
+
if compact?
|
|
152
|
+
body = wrap_connection_body_compact(fragment_name, connection_type)
|
|
153
|
+
"#{fragments_string} query($id: ID!) { #{parent_query} { #{query_name}#{field_sig} { #{body} } } }"
|
|
154
|
+
else
|
|
155
|
+
body = wrap_connection_body_formatted(fragment_name, connection_type, 3)
|
|
156
|
+
"#{fragments_string}\nquery($id: ID!) {\n #{parent_query} {\n #{query_name}#{field_sig} {\n#{body}\n }\n }\n}\n"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def render_root_connection_query(query_name, fragment_name, field_sig, connection_type)
|
|
161
|
+
if compact?
|
|
162
|
+
body = wrap_connection_body_compact(fragment_name, connection_type)
|
|
163
|
+
"#{fragments_string} query { #{query_name}#{field_sig} { #{body} } }"
|
|
164
|
+
else
|
|
165
|
+
body = wrap_connection_body_formatted(fragment_name, connection_type, 2)
|
|
166
|
+
"#{fragments_string}\nquery {\n #{query_name}#{field_sig} {\n#{body}\n }\n}\n"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def wrap_connection_body_compact(fragment_name, connection_type)
|
|
171
|
+
case connection_type
|
|
172
|
+
when :singular then "...#{fragment_name}"
|
|
173
|
+
when :nodes_only then "nodes { ...#{fragment_name} }"
|
|
174
|
+
else "edges { node { ...#{fragment_name} } }"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def wrap_connection_body_formatted(fragment_name, connection_type, indent_level)
|
|
179
|
+
indent = " " * indent_level
|
|
180
|
+
case connection_type
|
|
181
|
+
when :singular
|
|
182
|
+
"#{indent}...#{fragment_name}"
|
|
183
|
+
when :nodes_only
|
|
184
|
+
"#{indent}nodes {\n#{indent} ...#{fragment_name}\n#{indent}}"
|
|
185
|
+
else
|
|
186
|
+
"#{indent}edges {\n#{indent} node {\n#{indent} ...#{fragment_name}\n#{indent} }\n#{indent}}"
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def build_field_parameters(variables)
|
|
191
|
+
variables.map do |key, value|
|
|
192
|
+
"#{key.to_s.camelize(:lower)}: #{format_inline_value(key, value)}"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def format_inline_value(key, value)
|
|
197
|
+
case value
|
|
198
|
+
when Integer, TrueClass, FalseClass then value.to_s
|
|
199
|
+
when String then key.to_sym == :query ? "\"#{value}\"" : value
|
|
200
|
+
else value.to_s
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|