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,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ class Node
6
+ # Renders a single record query by ID.
7
+ # Example:
8
+ # query getCustomer($id: ID!) {
9
+ # customer(id: $id) {
10
+ # ...CustomerFragment
11
+ # }
12
+ # }
13
+ class SingleRecord < Node
14
+ attr_reader :model_type, :query_name, :fragment_name, :fragments
15
+
16
+ def initialize(model_type:, query_name:, fragment_name:, fragments: [])
17
+ @model_type = model_type
18
+ @query_name = query_name
19
+ @fragment_name = fragment_name
20
+ @fragments = fragments
21
+ super(name: query_name)
22
+ end
23
+
24
+ def to_s
25
+ "#{fragments_string} query get#{model_type}($id: ID!) { #{query_name}(id: $id) { ...#{fragment_name} } }"
26
+ end
27
+
28
+ private
29
+
30
+ def fragments_string
31
+ @fragments.map(&:to_s).join(' ')
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ class Node
6
+ # Renders singular association fields (has_one relationships).
7
+ # Example: `defaultAddress { city country zip }`
8
+ class Singular < Node
9
+ def to_s
10
+ nested_fields = render_children
11
+ "#{field_name_with_alias}#{format_arguments} { #{nested_fields.join(' ')} }"
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ # Abstract base class for all GraphQL query nodes.
6
+ # Provides shared attributes and helper methods for node rendering.
7
+ #
8
+ # Subclasses:
9
+ # - Node::Field - Simple and nested fields
10
+ # - Node::Singular - Singular associations (has_one)
11
+ # - Node::Connection - Collection associations using nodes pattern
12
+ # - Node::Fragment - Fragment definitions
13
+ # - Node::Raw - Raw GraphQL strings
14
+ # - Node::SingleRecord - Single record queries by ID
15
+ # - Node::CurrentCustomer - ID-less queries (Customer Account API)
16
+ # - Node::Collection - Collection queries with optional pagination
17
+ # - Node::NestedConnection - Nested connection queries
18
+ # - Node::RootConnection - Root-level connection queries
19
+ class Node
20
+ # Shared constants for query formatting
21
+ PAGE_INFO_FIELDS = "pageInfo { hasNextPage hasPreviousPage startCursor endCursor }"
22
+ STRING_KEYS_NEEDING_QUOTES = %i[query after before].freeze
23
+ attr_reader :name, :alias_name, :arguments, :children
24
+
25
+ # @param name [String] The field name (e.g., 'id', 'displayName', 'orders')
26
+ # @param alias_name [String] Optional field alias (e.g., 'myAlias' for 'myAlias: fieldName')
27
+ # @param arguments [Hash] Field arguments (e.g., { first: 10, sortKey: 'CREATED_AT' })
28
+ # @param children [Array<Query::Node>] Child nodes for nested structures
29
+ def initialize(name:, alias_name: nil, arguments: {}, children: [])
30
+ @name = name
31
+ @alias_name = alias_name
32
+ @arguments = arguments
33
+ @children = children
34
+ end
35
+
36
+ # Add a child node
37
+ def add_child(node)
38
+ @children << node
39
+ node
40
+ end
41
+
42
+ # Check if node has children
43
+ def has_children?
44
+ @children.any?
45
+ end
46
+
47
+ # Convert node to GraphQL string - must be implemented by subclasses
48
+ def to_s
49
+ raise NotImplementedError, "#{self.class} must implement #to_s"
50
+ end
51
+
52
+ private
53
+
54
+ def field_name_with_alias
55
+ @alias_name ? "#{@alias_name}: #{@name}" : @name
56
+ end
57
+
58
+ def format_arguments
59
+ return "" if @arguments.empty?
60
+
61
+ # Filter out internal arguments (like :on for fragments)
62
+ graphql_args = @arguments.except(:on)
63
+ return "" if graphql_args.empty?
64
+
65
+ args = graphql_args.map do |key, value|
66
+ graphql_key = key.to_s.camelize(:lower)
67
+ formatted_value = format_argument_value(key, value)
68
+ "#{graphql_key}: #{formatted_value}"
69
+ end
70
+
71
+ "(#{args.join(', ')})"
72
+ end
73
+
74
+ def format_argument_value(key, value)
75
+ case value
76
+ when String
77
+ # Keys that need quoted string values
78
+ if %i[namespace key query].include?(key.to_sym)
79
+ "\"#{value}\""
80
+ else
81
+ value
82
+ end
83
+ when Symbol
84
+ value.to_s
85
+ else
86
+ value
87
+ end
88
+ end
89
+
90
+ def render_children
91
+ @children.map(&:to_s)
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ # Builds complete GraphQL queries from a LoaderContext.
6
+ # Handles both fragment construction and query wrapping.
7
+ # Delegates rendering to polymorphic Node subclasses.
8
+ class QueryBuilder
9
+ attr_reader :context
10
+
11
+ def initialize(context)
12
+ @context = context
13
+ end
14
+
15
+ # === Class-level factory methods for building complete queries ===
16
+
17
+ def self.build_single_record_query(context)
18
+ builder = new(context)
19
+ fragment = builder.build_fragment
20
+
21
+ Node::SingleRecord.new(
22
+ model_type: context.graphql_type,
23
+ query_name: context.query_name,
24
+ fragment_name: context.fragment_name,
25
+ fragments: [fragment]
26
+ ).to_s
27
+ end
28
+
29
+ # Build a query that doesn't require an ID parameter (e.g., Customer Account API's current customer)
30
+ def self.build_current_customer_query(context, query_name: nil)
31
+ builder = new(context)
32
+ fragment = builder.build_fragment
33
+
34
+ Node::CurrentCustomer.new(
35
+ model_type: context.graphql_type,
36
+ query_name: query_name || context.query_name,
37
+ fragment_name: context.fragment_name,
38
+ fragments: [fragment]
39
+ ).to_s
40
+ end
41
+
42
+ def self.build_collection_query(context, query_name:, variables:, include_page_info: false)
43
+ builder = new(context)
44
+ fragment = builder.build_fragment
45
+
46
+ Node::Collection.new(
47
+ model_type: context.graphql_type,
48
+ query_name: query_name,
49
+ fragment_name: context.fragment_name,
50
+ variables: variables,
51
+ fragments: [fragment],
52
+ include_page_info: include_page_info
53
+ ).to_s
54
+ end
55
+
56
+ # Build a paginated collection query that includes pageInfo for cursor-based pagination
57
+ def self.build_paginated_collection_query(context, query_name:, variables:)
58
+ build_collection_query(context, query_name: query_name, variables: variables, include_page_info: true)
59
+ end
60
+
61
+ def self.build_connection_query(context, query_name:, variables:, parent_query: nil, singular: false)
62
+ builder = new(context)
63
+ fragment = builder.build_fragment
64
+
65
+ if parent_query
66
+ Node::NestedConnection.new(
67
+ query_name: query_name,
68
+ fragment_name: context.fragment_name,
69
+ variables: variables,
70
+ parent_query: parent_query,
71
+ fragments: [fragment],
72
+ singular: singular
73
+ ).to_s
74
+ else
75
+ Node::RootConnection.new(
76
+ query_name: query_name,
77
+ fragment_name: context.fragment_name,
78
+ variables: variables,
79
+ fragments: [fragment],
80
+ singular: singular
81
+ ).to_s
82
+ end
83
+ end
84
+
85
+ def self.normalize_includes(includes)
86
+ includes = Array(includes)
87
+ includes.each_with_object({}) do |inc, normalized|
88
+ case inc
89
+ when Hash
90
+ inc.each do |key, value|
91
+ key = key.to_sym
92
+ normalized[key] ||= []
93
+ case value
94
+ when Hash then normalized[key] << value
95
+ when Array then normalized[key].concat(value)
96
+ else normalized[key] << value
97
+ end
98
+ end
99
+ when Symbol, String
100
+ normalized[inc.to_sym] ||= []
101
+ end
102
+ end
103
+ end
104
+
105
+ def self.fragment_name(graphql_type)
106
+ "#{graphql_type}Fragment"
107
+ end
108
+
109
+ # === Instance methods for building fragments ===
110
+
111
+ # Build a complete fragment node with all fields and connections
112
+ def build_fragment
113
+ raise NotImplementedError, "#{@context.loader_class} must define attributes" if @context.defined_attributes.empty?
114
+
115
+ fragment_node = Node::Fragment.new(
116
+ name: @context.fragment_name,
117
+ arguments: { on: @context.graphql_type }
118
+ )
119
+
120
+ # Add field nodes from attributes
121
+ build_field_nodes.each { |node| fragment_node.add_child(node) }
122
+
123
+ # Add connection nodes
124
+ build_connection_nodes.each { |node| fragment_node.add_child(node) }
125
+
126
+ fragment_node
127
+ end
128
+
129
+ # Build field nodes from attribute definitions
130
+ def build_field_nodes
131
+ path_tree = {}
132
+ metafield_aliases = {}
133
+ raw_graphql_nodes = []
134
+ aliased_field_nodes = []
135
+
136
+ # Build a tree structure for nested paths
137
+ @context.defined_attributes.each do |attr_name, config|
138
+ if config[:raw_graphql]
139
+ raw_graphql_nodes << build_raw_graphql_node(attr_name, config[:raw_graphql])
140
+ elsif config[:is_metafield]
141
+ store_metafield_config(metafield_aliases, config)
142
+ else
143
+ path = config[:path]
144
+ if path.include?('.')
145
+ # Nested path - use tree structure (shared prefixes)
146
+ build_path_tree(path_tree, path)
147
+ else
148
+ # Simple path - add aliased field node
149
+ aliased_field_nodes << build_aliased_field_node(attr_name, path)
150
+ end
151
+ end
152
+ end
153
+
154
+ # Convert tree to Node objects
155
+ nodes_from_tree(path_tree) + aliased_field_nodes + metafield_nodes(metafield_aliases) + raw_graphql_nodes
156
+ end
157
+
158
+ # Build Node objects for all connections
159
+ def build_connection_nodes
160
+ return [] if @context.included_connections.empty?
161
+
162
+ connections = @context.connections
163
+ return [] if connections.empty?
164
+
165
+ normalized_includes = self.class.normalize_includes(@context.included_connections)
166
+
167
+ normalized_includes.filter_map do |connection_name, nested_includes|
168
+ connection_config = connections[connection_name]
169
+ next unless connection_config
170
+
171
+ build_connection_node(connection_config, nested_includes)
172
+ end
173
+ end
174
+
175
+ private
176
+
177
+ def store_metafield_config(metafield_aliases, config)
178
+ alias_name = config[:metafield_alias]
179
+ value_field = config[:type] == :json ? 'jsonValue' : 'value'
180
+
181
+ metafield_aliases[alias_name] = {
182
+ namespace: config[:metafield_namespace],
183
+ key: config[:metafield_key],
184
+ value_field: value_field
185
+ }
186
+ end
187
+
188
+ def build_raw_graphql_node(attr_name, raw_graphql)
189
+ # Prepend alias to raw GraphQL for predictable response mapping
190
+ aliased_raw_graphql = "#{attr_name}: #{raw_graphql}"
191
+ Node::Raw.new(
192
+ name: "raw",
193
+ arguments: { raw_graphql: aliased_raw_graphql }
194
+ )
195
+ end
196
+
197
+ def build_aliased_field_node(attr_name, path)
198
+ alias_name = attr_name.to_s
199
+ # Only add alias if the attr_name differs from the GraphQL field name
200
+ alias_name = nil if alias_name == path
201
+ Node::Field.new(name: path, alias_name: alias_name)
202
+ end
203
+
204
+ def build_path_tree(path_tree, path)
205
+ path_parts = path.split('.')
206
+ current_level = path_tree
207
+
208
+ path_parts.each_with_index do |part, index|
209
+ if index == path_parts.length - 1
210
+ current_level[part] = true
211
+ else
212
+ current_level[part] ||= {}
213
+ current_level = current_level[part]
214
+ end
215
+ end
216
+ end
217
+
218
+ def nodes_from_tree(tree)
219
+ tree.map do |key, value|
220
+ if value == true
221
+ Node::Field.new(name: key)
222
+ else
223
+ children = nodes_from_tree(value)
224
+ Node::Field.new(name: key, children: children)
225
+ end
226
+ end
227
+ end
228
+
229
+ def metafield_nodes(metafield_aliases)
230
+ metafield_aliases.map do |alias_name, config|
231
+ value_node = Node::Field.new(name: config[:value_field])
232
+ Node::Field.new(
233
+ name: "metafield",
234
+ alias_name: alias_name,
235
+ arguments: { namespace: config[:namespace], key: config[:key] },
236
+ children: [value_node]
237
+ )
238
+ end
239
+ end
240
+
241
+ def build_connection_node(connection_config, nested_includes)
242
+ target_class = connection_config[:class_name].constantize
243
+ target_context = @context.for_model(target_class, new_connections: nested_includes)
244
+
245
+ # Build child nodes for the target model
246
+ child_nodes = build_target_field_nodes(target_context, nested_includes)
247
+
248
+ query_name = connection_config[:query_name]
249
+ original_name = connection_config[:original_name]
250
+ connection_type = connection_config[:type] || :connection
251
+ formatted_args = (connection_config[:default_arguments] || {}).transform_keys(&:to_sym)
252
+
253
+ # Add alias if the connection name differs from the query name
254
+ alias_name = original_name.to_s == query_name ? nil : original_name.to_s
255
+
256
+ if connection_type == :singular
257
+ Node::Singular.new(
258
+ name: query_name,
259
+ alias_name: alias_name,
260
+ arguments: formatted_args,
261
+ children: child_nodes
262
+ )
263
+ else
264
+ Node::Connection.new(
265
+ name: query_name,
266
+ alias_name: alias_name,
267
+ arguments: formatted_args,
268
+ children: child_nodes
269
+ )
270
+ end
271
+ end
272
+
273
+ def build_target_field_nodes(target_context, nested_includes)
274
+ # Build attribute nodes
275
+ attribute_nodes = if target_context.defined_attributes.any?
276
+ QueryBuilder.new(target_context.for_model(target_context.model_class, new_connections: [])).build_field_nodes
277
+ else
278
+ [Node::Field.new(name: "id")]
279
+ end
280
+
281
+ # Build nested connection nodes
282
+ return attribute_nodes if nested_includes.empty?
283
+
284
+ nested_builder = QueryBuilder.new(target_context)
285
+ nested_connection_nodes = nested_builder.build_connection_nodes
286
+ attribute_nodes + nested_connection_nodes
287
+ end
288
+ end
289
+ end
290
+ end