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
@@ -1,159 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActiveShopifyGraphQL
4
- module FinderMethods
5
- extend ActiveSupport::Concern
6
-
7
- class_methods do
8
- # Find a single record by ID using the provided loader
9
- # @param id [String, Integer] The record ID (will be converted to GID automatically)
10
- # @param loader [ActiveShopifyGraphQL::Loader] The loader to use for fetching data
11
- # @return [Object, nil] The model instance or nil if not found
12
- def find(id, loader: default_loader)
13
- gid = GidHelper.normalize_gid(id, model_name.name.demodulize)
14
-
15
- # If we have included connections, we need to handle inverse_of properly
16
- if loader.respond_to?(:load_with_instance) && loader.has_included_connections?
17
- loader.load_with_instance(gid, self)
18
- else
19
- attributes = loader.load_attributes(gid)
20
- return nil if attributes.nil?
21
-
22
- new(attributes)
23
- end
24
- end
25
-
26
- # Returns the default loader for this model's queries
27
- # @return [ActiveGraphQL::Loader] The default loader instance
28
- def default_loader
29
- if respond_to?(:default_loader_instance)
30
- default_loader_instance
31
- else
32
- @default_loader ||= begin
33
- # Collect connections with eager_load: true
34
- eagerly_loaded_connections = connections.select { |_name, config| config[:eager_load] }.keys
35
-
36
- default_loader_class.new(
37
- self,
38
- included_connections: eagerly_loaded_connections
39
- )
40
- end
41
- end
42
- end
43
-
44
- # Allows setting a custom default loader (useful for testing)
45
- # @param loader [ActiveGraphQL::Loader] The loader to set as default
46
- def default_loader=(loader)
47
- @default_loader = loader
48
- end
49
-
50
- # Select specific attributes to optimize GraphQL queries
51
- # @param *attributes [Symbol] The attributes to select
52
- # @return [Class] A class with modified default loader for method chaining
53
- #
54
- # @example
55
- # Customer.select(:id, :email).find(123)
56
- # Customer.select(:id, :email).where(first_name: "John")
57
- def select(*attributes)
58
- # Validate attributes exist
59
- attrs = Array(attributes).flatten.map(&:to_sym)
60
- validate_select_attributes!(attrs)
61
-
62
- # Create a new class that inherits from self with a modified default loader
63
- selected_class = Class.new(self)
64
-
65
- # Override the default_loader method to return a loader with selected attributes
66
- selected_class.define_singleton_method(:default_loader) do
67
- @default_loader ||= superclass.default_loader.class.new(
68
- superclass,
69
- selected_attributes: attrs
70
- )
71
- end
72
-
73
- # Preserve the original class name and model name for GraphQL operations
74
- selected_class.define_singleton_method(:name) { superclass.name }
75
- selected_class.define_singleton_method(:model_name) { superclass.model_name }
76
-
77
- selected_class
78
- end
79
-
80
- # Query for multiple records using attribute conditions
81
- # @param conditions [Hash] The conditions to query (e.g., { email: "example@test.com", first_name: "John" })
82
- # @param options [Hash] Options hash containing loader and limit (when first arg is a Hash)
83
- # @option options [ActiveShopifyGraphQL::Loader] :loader The loader to use for fetching data
84
- # @option options [Integer] :limit The maximum number of records to return (default: 250, max: 250)
85
- # @return [Array<Object>] Array of model instances
86
- # @raise [ArgumentError] If any attribute is not valid for querying
87
- #
88
- # @example
89
- # # Keyword argument style (recommended)
90
- # Customer.where(email: "john@example.com")
91
- # Customer.where(first_name: "John", country: "Canada")
92
- # Customer.where(orders_count: { gte: 5 })
93
- # Customer.where(created_at: { gte: "2024-01-01", lt: "2024-02-01" })
94
- #
95
- # # Hash style with options
96
- # Customer.where({ email: "john@example.com" }, loader: custom_loader, limit: 100)
97
- def where(conditions_or_first_condition = {}, *args, **options)
98
- # Handle both syntaxes:
99
- # where(email: "john@example.com") - keyword args become options
100
- # where({ email: "john@example.com" }, loader: custom_loader) - explicit hash + options
101
- if conditions_or_first_condition.is_a?(Hash) && !conditions_or_first_condition.empty?
102
- # Explicit hash provided as first argument
103
- conditions = conditions_or_first_condition
104
- # Any additional options passed as keyword args or second hash argument
105
- final_options = args.first.is_a?(Hash) ? options.merge(args.first) : options
106
- else
107
- # Keyword arguments style - conditions come from options, excluding known option keys
108
- known_option_keys = %i[loader limit]
109
- conditions = options.except(*known_option_keys)
110
- final_options = options.slice(*known_option_keys)
111
- end
112
-
113
- loader = final_options[:loader] || default_loader
114
- limit = final_options[:limit] || 250
115
-
116
- # Ensure loader has model class set - needed for graphql_type inference
117
- loader.instance_variable_set(:@model_class, self) if loader.instance_variable_get(:@model_class).nil?
118
-
119
- attributes_array = loader.load_collection(conditions, limit: limit)
120
-
121
- attributes_array.map { |attributes| new(attributes) }
122
- end
123
-
124
- private
125
-
126
- # Validates that selected attributes exist in the model
127
- # @param attributes [Array<Symbol>] The attributes to validate
128
- # @raise [ArgumentError] If any attribute is invalid
129
- def validate_select_attributes!(attributes)
130
- return if attributes.empty?
131
-
132
- available_attrs = available_select_attributes
133
- invalid_attrs = attributes - available_attrs
134
-
135
- return unless invalid_attrs.any?
136
-
137
- raise ArgumentError, "Invalid attributes for #{name}: #{invalid_attrs.join(', ')}. " \
138
- "Available attributes are: #{available_attrs.join(', ')}"
139
- end
140
-
141
- # Gets all available attributes for selection
142
- # @return [Array<Symbol>] Available attribute names
143
- def available_select_attributes
144
- attrs = []
145
-
146
- # Get attributes from the model class
147
- loader_class = default_loader.class
148
- model_attrs = attributes_for_loader(loader_class)
149
- attrs.concat(model_attrs.keys)
150
-
151
- # Get attributes from the loader class
152
- loader_attrs = default_loader.class.defined_attributes
153
- attrs.concat(loader_attrs.keys)
154
-
155
- attrs.map(&:to_sym).uniq.sort
156
- end
157
- end
158
- end
159
- end
@@ -1,228 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActiveShopifyGraphQL
4
- # Builds GraphQL fragments from model attributes and connections.
5
- class FragmentBuilder
6
- def initialize(context)
7
- @context = context
8
- end
9
-
10
- # Build a complete fragment node with all fields and connections
11
- def build
12
- raise NotImplementedError, "#{@context.loader_class} must define attributes" if @context.defined_attributes.empty?
13
-
14
- fragment_node = QueryNode.new(
15
- name: @context.fragment_name,
16
- arguments: { on: @context.graphql_type },
17
- node_type: :fragment
18
- )
19
-
20
- # Add field nodes from attributes
21
- build_field_nodes.each { |node| fragment_node.add_child(node) }
22
-
23
- # Add connection nodes
24
- build_connection_nodes.each { |node| fragment_node.add_child(node) }
25
-
26
- fragment_node
27
- end
28
-
29
- # Build field nodes from attribute definitions (protected for recursive calls)
30
- def build_field_nodes
31
- path_tree = {}
32
- metafield_aliases = {}
33
- raw_graphql_nodes = []
34
- aliased_field_nodes = []
35
-
36
- # Build a tree structure for nested paths
37
- @context.defined_attributes.each do |attr_name, config|
38
- if config[:raw_graphql]
39
- raw_graphql_nodes << build_raw_graphql_node(attr_name, config[:raw_graphql])
40
- elsif config[:is_metafield]
41
- store_metafield_config(metafield_aliases, config)
42
- else
43
- path = config[:path]
44
- if path.include?('.')
45
- # Nested path - use tree structure (shared prefixes)
46
- build_path_tree(path_tree, path)
47
- else
48
- # Simple path - add aliased field node
49
- aliased_field_nodes << build_aliased_field_node(attr_name, path)
50
- end
51
- end
52
- end
53
-
54
- # Convert tree to QueryNode objects
55
- nodes_from_tree(path_tree) + aliased_field_nodes + metafield_nodes(metafield_aliases) + raw_graphql_nodes
56
- end
57
-
58
- # Build QueryNode objects for all connections (protected for recursive calls)
59
- def build_connection_nodes
60
- return [] if @context.included_connections.empty?
61
-
62
- connections = @context.connections
63
- return [] if connections.empty?
64
-
65
- normalized_includes = normalize_includes(@context.included_connections)
66
-
67
- normalized_includes.filter_map do |connection_name, nested_includes|
68
- connection_config = connections[connection_name]
69
- next unless connection_config
70
-
71
- build_connection_node(connection_config, nested_includes)
72
- end
73
- end
74
-
75
- private
76
-
77
- def store_metafield_config(metafield_aliases, config)
78
- alias_name = config[:metafield_alias]
79
- value_field = config[:type] == :json ? 'jsonValue' : 'value'
80
-
81
- metafield_aliases[alias_name] = {
82
- namespace: config[:metafield_namespace],
83
- key: config[:metafield_key],
84
- value_field: value_field
85
- }
86
- end
87
-
88
- def build_raw_graphql_node(attr_name, raw_graphql)
89
- # Prepend alias to raw GraphQL for predictable response mapping
90
- aliased_raw_graphql = "#{attr_name}: #{raw_graphql}"
91
- QueryNode.new(
92
- name: "raw",
93
- arguments: { raw_graphql: aliased_raw_graphql },
94
- node_type: :raw
95
- )
96
- end
97
-
98
- def build_aliased_field_node(attr_name, path)
99
- alias_name = attr_name.to_s
100
- # Only add alias if the attr_name differs from the GraphQL field name
101
- alias_name = nil if alias_name == path
102
- QueryNode.new(name: path, alias_name: alias_name, node_type: :field)
103
- end
104
-
105
- def build_path_tree(path_tree, path)
106
- path_parts = path.split('.')
107
- current_level = path_tree
108
-
109
- path_parts.each_with_index do |part, index|
110
- if index == path_parts.length - 1
111
- current_level[part] = true
112
- else
113
- current_level[part] ||= {}
114
- current_level = current_level[part]
115
- end
116
- end
117
- end
118
-
119
- def nodes_from_tree(tree)
120
- tree.map do |key, value|
121
- if value == true
122
- QueryNode.new(name: key, node_type: :field)
123
- else
124
- children = nodes_from_tree(value)
125
- QueryNode.new(name: key, node_type: :field, children: children)
126
- end
127
- end
128
- end
129
-
130
- def metafield_nodes(metafield_aliases)
131
- metafield_aliases.map do |alias_name, config|
132
- value_node = QueryNode.new(name: config[:value_field], node_type: :field)
133
- QueryNode.new(
134
- name: "metafield",
135
- alias_name: alias_name,
136
- arguments: { namespace: config[:namespace], key: config[:key] },
137
- node_type: :field,
138
- children: [value_node]
139
- )
140
- end
141
- end
142
-
143
- def build_connection_node(connection_config, nested_includes)
144
- target_class = connection_config[:class_name].constantize
145
- target_context = @context.for_model(target_class, new_connections: nested_includes)
146
-
147
- # Build child nodes for the target model
148
- child_nodes = build_target_field_nodes(target_context, nested_includes)
149
-
150
- query_name = connection_config[:query_name]
151
- original_name = connection_config[:original_name]
152
- connection_type = connection_config[:type] || :connection
153
- formatted_args = (connection_config[:default_arguments] || {}).transform_keys(&:to_sym)
154
-
155
- # Add alias if the connection name differs from the query name
156
- alias_name = original_name.to_s == query_name ? nil : original_name.to_s
157
-
158
- node_type = connection_type == :singular ? :singular : :connection
159
- QueryNode.new(
160
- name: query_name,
161
- alias_name: alias_name,
162
- arguments: formatted_args,
163
- node_type: node_type,
164
- children: child_nodes
165
- )
166
- end
167
-
168
- def build_target_field_nodes(target_context, nested_includes)
169
- # Build attribute nodes
170
- attribute_nodes = if target_context.defined_attributes.any?
171
- FragmentBuilder.new(target_context.with_connections([])).build_field_nodes
172
- else
173
- [QueryNode.new(name: "id", node_type: :field)]
174
- end
175
-
176
- # Build nested connection nodes
177
- return attribute_nodes if nested_includes.empty?
178
-
179
- nested_builder = FragmentBuilder.new(target_context)
180
- nested_connection_nodes = nested_builder.build_connection_nodes
181
- attribute_nodes + nested_connection_nodes
182
- end
183
-
184
- # Normalize includes from various formats to a consistent hash structure
185
- def normalize_includes(includes)
186
- includes = Array(includes)
187
- includes.each_with_object({}) do |inc, normalized|
188
- case inc
189
- when Hash
190
- inc.each do |key, value|
191
- key = key.to_sym
192
- normalized[key] ||= []
193
- case value
194
- when Hash then normalized[key] << value
195
- when Array then normalized[key].concat(value)
196
- else normalized[key] << value
197
- end
198
- end
199
- when Symbol, String
200
- normalized[inc.to_sym] ||= []
201
- end
202
- end
203
- end
204
-
205
- class << self
206
- # Expose for external use (QueryTree needs this)
207
- def normalize_includes(includes)
208
- includes = Array(includes)
209
- includes.each_with_object({}) do |inc, normalized|
210
- case inc
211
- when Hash
212
- inc.each do |key, value|
213
- key = key.to_sym
214
- normalized[key] ||= []
215
- case value
216
- when Hash then normalized[key] << value
217
- when Array then normalized[key].concat(value)
218
- else normalized[key] << value
219
- end
220
- end
221
- when Symbol, String
222
- normalized[inc.to_sym] ||= []
223
- end
224
- end
225
- end
226
- end
227
- end
228
- end
@@ -1,91 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActiveShopifyGraphQL
4
- # Centralizes GraphQL type resolution logic.
5
- module GraphqlTypeResolver
6
- extend ActiveSupport::Concern
7
-
8
- class_methods do
9
- # Set or get the base GraphQL type for this model.
10
- #
11
- # @param type [String, nil] The GraphQL type name to set, or nil to get
12
- # @return [String] The GraphQL type name
13
- # @raise [NotImplementedError] If no type is defined
14
- def graphql_type(type = nil)
15
- if type
16
- if @current_loader_context
17
- @loader_graphql_types ||= {}
18
- @loader_graphql_types[@current_loader_context] = type
19
- else
20
- @base_graphql_type = type
21
- end
22
- end
23
-
24
- @base_graphql_type || raise(NotImplementedError, "#{self} must define graphql_type")
25
- end
26
-
27
- # Get the GraphQL type for a specific loader class.
28
- # Resolution order:
29
- # 1. Loader-specific type defined via `for_loader`
30
- # 2. Base graphql_type defined on the model
31
- # 3. Type defined on the loader class itself
32
- # 4. Inferred from model class name
33
- #
34
- # @param loader_class [Class] The loader class to resolve type for
35
- # @return [String] The resolved GraphQL type
36
- # @raise [NotImplementedError] If no type can be resolved
37
- def graphql_type_for_loader(loader_class)
38
- # 1. Check loader-specific override
39
- return @loader_graphql_types[loader_class] if @loader_graphql_types&.key?(loader_class)
40
-
41
- # 2. Check base graphql_type
42
- return @base_graphql_type if @base_graphql_type
43
-
44
- # 3. Check loader class itself
45
- loader_type = loader_class.instance_variable_get(:@graphql_type)
46
- return loader_type if loader_type
47
-
48
- # 4. Infer from model name
49
- return name.demodulize if respond_to?(:name) && name
50
-
51
- raise NotImplementedError,
52
- "#{self} must define graphql_type or #{loader_class} must define graphql_type"
53
- end
54
-
55
- # Resolve the GraphQL type from any source (model class, loader, or value).
56
- # Useful for external callers that need to resolve type from various inputs.
57
- #
58
- # @param model_class [Class, nil] The model class
59
- # @param loader_class [Class, nil] The loader class
60
- # @return [String] The resolved GraphQL type
61
- def resolve_graphql_type(model_class: nil, loader_class: nil)
62
- if model_class.respond_to?(:graphql_type_for_loader) && loader_class
63
- model_class.graphql_type_for_loader(loader_class)
64
- elsif model_class.respond_to?(:graphql_type)
65
- model_class.graphql_type
66
- elsif loader_class.respond_to?(:graphql_type)
67
- loader_class.graphql_type
68
- elsif model_class.respond_to?(:name) && model_class.name
69
- model_class.name.demodulize
70
- else
71
- raise ArgumentError, "Cannot resolve graphql_type from provided arguments"
72
- end
73
- end
74
- end
75
-
76
- # Module-level resolver for convenience
77
- def self.resolve(model_class: nil, loader_class: nil)
78
- if model_class.respond_to?(:graphql_type_for_loader) && loader_class
79
- model_class.graphql_type_for_loader(loader_class)
80
- elsif model_class.respond_to?(:graphql_type)
81
- model_class.graphql_type
82
- elsif loader_class.respond_to?(:graphql_type)
83
- loader_class.graphql_type
84
- elsif model_class.respond_to?(:name) && model_class.name
85
- model_class.name.demodulize
86
- else
87
- raise ArgumentError, "Cannot resolve graphql_type"
88
- end
89
- end
90
- end
91
- end
@@ -1,48 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActiveShopifyGraphQL
4
- # A scope object that holds included connections for eager loading.
5
- # This allows chaining methods like find() and where() while maintaining
6
- # the included connections configuration.
7
- class IncludesScope
8
- attr_reader :model_class, :included_connections
9
-
10
- def initialize(model_class, included_connections)
11
- @model_class = model_class
12
- @included_connections = included_connections
13
- end
14
-
15
- # Delegate find to the model class with a custom loader
16
- def find(id, loader: nil)
17
- loader ||= default_loader
18
- @model_class.find(id, loader: loader)
19
- end
20
-
21
- # Delegate where to the model class with a custom loader
22
- def where(*args, **options)
23
- loader = options.delete(:loader) || default_loader
24
- @model_class.where(*args, **options.merge(loader: loader))
25
- end
26
-
27
- # Delegate select to create a new scope with select
28
- def select(*attributes)
29
- selected_scope = @model_class.select(*attributes)
30
- # Chain the includes on top of select
31
- IncludesScope.new(selected_scope, @included_connections)
32
- end
33
-
34
- # Allow chaining includes calls
35
- def includes(*connection_names)
36
- @model_class.includes(*(@included_connections + connection_names).uniq)
37
- end
38
-
39
- private
40
-
41
- def default_loader
42
- @default_loader ||= @model_class.default_loader.class.new(
43
- @model_class,
44
- included_connections: @included_connections
45
- )
46
- end
47
- end
48
- end