active_shopify_graphql 0.4.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +3 -0
  3. data/README.md +158 -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/loader.rb +147 -126
  8. data/lib/active_shopify_graphql/loader_context.rb +2 -47
  9. data/lib/active_shopify_graphql/loader_proxy.rb +67 -0
  10. data/lib/active_shopify_graphql/loaders/admin_api_loader.rb +1 -17
  11. data/lib/active_shopify_graphql/loaders/customer_account_api_loader.rb +10 -26
  12. data/lib/active_shopify_graphql/model/associations.rb +94 -0
  13. data/lib/active_shopify_graphql/model/attributes.rb +48 -0
  14. data/lib/active_shopify_graphql/model/connections.rb +174 -0
  15. data/lib/active_shopify_graphql/model/finder_methods.rb +155 -0
  16. data/lib/active_shopify_graphql/model/graphql_type_resolver.rb +89 -0
  17. data/lib/active_shopify_graphql/model/loader_switchable.rb +79 -0
  18. data/lib/active_shopify_graphql/model/metafield_attributes.rb +59 -0
  19. data/lib/active_shopify_graphql/{base.rb → model.rb} +30 -16
  20. data/lib/active_shopify_graphql/model_builder.rb +53 -0
  21. data/lib/active_shopify_graphql/query/node/collection.rb +78 -0
  22. data/lib/active_shopify_graphql/query/node/connection.rb +16 -0
  23. data/lib/active_shopify_graphql/query/node/current_customer.rb +37 -0
  24. data/lib/active_shopify_graphql/query/node/field.rb +23 -0
  25. data/lib/active_shopify_graphql/query/node/fragment.rb +17 -0
  26. data/lib/active_shopify_graphql/query/node/nested_connection.rb +79 -0
  27. data/lib/active_shopify_graphql/query/node/raw.rb +15 -0
  28. data/lib/active_shopify_graphql/query/node/root_connection.rb +76 -0
  29. data/lib/active_shopify_graphql/query/node/single_record.rb +36 -0
  30. data/lib/active_shopify_graphql/query/node/singular.rb +16 -0
  31. data/lib/active_shopify_graphql/query/node.rb +95 -0
  32. data/lib/active_shopify_graphql/query/query_builder.rb +290 -0
  33. data/lib/active_shopify_graphql/query/relation.rb +424 -0
  34. data/lib/active_shopify_graphql/query/scope.rb +219 -0
  35. data/lib/active_shopify_graphql/response/page_info.rb +40 -0
  36. data/lib/active_shopify_graphql/response/paginated_result.rb +114 -0
  37. data/lib/active_shopify_graphql/response/response_mapper.rb +242 -0
  38. data/lib/active_shopify_graphql/search_query/hash_condition_formatter.rb +107 -0
  39. data/lib/active_shopify_graphql/search_query/parameter_binder.rb +68 -0
  40. data/lib/active_shopify_graphql/search_query/value_sanitizer.rb +24 -0
  41. data/lib/active_shopify_graphql/search_query.rb +34 -84
  42. data/lib/active_shopify_graphql/version.rb +1 -1
  43. data/lib/active_shopify_graphql.rb +29 -29
  44. metadata +46 -15
  45. data/lib/active_shopify_graphql/associations.rb +0 -94
  46. data/lib/active_shopify_graphql/attributes.rb +0 -50
  47. data/lib/active_shopify_graphql/connection_loader.rb +0 -96
  48. data/lib/active_shopify_graphql/connections.rb +0 -198
  49. data/lib/active_shopify_graphql/finder_methods.rb +0 -182
  50. data/lib/active_shopify_graphql/fragment_builder.rb +0 -228
  51. data/lib/active_shopify_graphql/graphql_type_resolver.rb +0 -91
  52. data/lib/active_shopify_graphql/includes_scope.rb +0 -48
  53. data/lib/active_shopify_graphql/loader_switchable.rb +0 -180
  54. data/lib/active_shopify_graphql/metafield_attributes.rb +0 -61
  55. data/lib/active_shopify_graphql/query_node.rb +0 -173
  56. data/lib/active_shopify_graphql/query_tree.rb +0 -225
  57. data/lib/active_shopify_graphql/response_mapper.rb +0 -249
@@ -1,96 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'global_id'
4
-
5
- module ActiveShopifyGraphQL
6
- # Handles loading records for GraphQL connections.
7
- # Refactored to use LoaderContext for cleaner parameter passing.
8
- class ConnectionLoader
9
- attr_reader :context
10
-
11
- def initialize(context, loader_instance:)
12
- @context = context
13
- @loader_instance = loader_instance
14
- end
15
-
16
- # Load records for a connection query
17
- # @param query_name [String] The connection field name (e.g., 'orders', 'addresses')
18
- # @param variables [Hash] The GraphQL variables (first, sort_key, reverse, query)
19
- # @param parent [Object] The parent object that owns this connection
20
- # @param connection_config [Hash] The connection configuration
21
- # @return [Array<Object>] Array of model instances
22
- def load_records(query_name, variables, parent = nil, connection_config = nil)
23
- is_nested = connection_config&.dig(:nested) || parent.respond_to?(:id)
24
-
25
- if is_nested && parent
26
- load_nested_connection(query_name, variables, parent, connection_config)
27
- else
28
- load_root_connection(query_name, variables, connection_config)
29
- end
30
- end
31
-
32
- private
33
-
34
- def load_nested_connection(query_name, variables, parent, connection_config)
35
- parent_type = parent.class.graphql_type_for_loader(@context.loader_class)
36
- parent_query_name = parent_type.camelize(:lower)
37
- connection_type = connection_config&.dig(:type) || :connection
38
-
39
- query = QueryTree.build_connection_query(
40
- @context,
41
- query_name: query_name,
42
- variables: variables,
43
- parent_query: "#{parent_query_name}(id: $id)",
44
- connection_type: connection_type
45
- )
46
-
47
- parent_id = extract_gid(parent)
48
- response_data = @loader_instance.perform_graphql_query(query, id: parent_id)
49
-
50
- return [] if response_data.nil?
51
-
52
- mapper = ResponseMapper.new(@context)
53
- mapper.map_nested_connection_response(response_data, query_name, parent, connection_config)
54
- end
55
-
56
- def load_root_connection(query_name, variables, connection_config)
57
- connection_type = connection_config&.dig(:type) || :connection
58
-
59
- query = QueryTree.build_connection_query(
60
- @context,
61
- query_name: query_name,
62
- variables: variables,
63
- parent_query: nil,
64
- connection_type: connection_type
65
- )
66
-
67
- response_data = @loader_instance.perform_graphql_query(query)
68
-
69
- return [] if response_data.nil?
70
-
71
- mapper = ResponseMapper.new(@context)
72
- mapper.map_connection_response(response_data, query_name, connection_config)
73
- end
74
-
75
- def extract_gid(parent)
76
- return parent.gid if parent.respond_to?(:gid) && !parent.gid.nil?
77
-
78
- id_value = parent.id
79
- parent_type = resolve_parent_type(parent)
80
-
81
- GidHelper.normalize_gid(id_value, parent_type)
82
- end
83
-
84
- def resolve_parent_type(parent)
85
- klass = parent.class
86
-
87
- if klass.respond_to?(:graphql_type_for_loader)
88
- klass.graphql_type_for_loader(@context.loader_class)
89
- elsif klass.respond_to?(:graphql_type)
90
- klass.graphql_type
91
- else
92
- klass.name
93
- end
94
- end
95
- end
96
- end
@@ -1,198 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "connections/connection_proxy"
4
-
5
- module ActiveShopifyGraphQL
6
- module Connections
7
- extend ActiveSupport::Concern
8
-
9
- included do
10
- class << self
11
- attr_accessor :connections
12
- end
13
-
14
- self.connections = {}
15
- end
16
-
17
- class_methods do
18
- # Define a singular connection (returns a single object)
19
- # @see #connection
20
- def has_one_connected(name, inverse_of: nil, **options)
21
- connection(name, type: :singular, inverse_of: inverse_of, **options)
22
- end
23
-
24
- # Define a plural connection (returns a collection via edges)
25
- # @see #connection
26
- def has_many_connected(name, inverse_of: nil, **options)
27
- connection(name, type: :connection, inverse_of: inverse_of, **options)
28
- end
29
-
30
- # Define a GraphQL connection to another ActiveShopifyGraphQL model
31
- # @param name [Symbol] The connection name (e.g., :orders)
32
- # @param class_name [String] The target model class name (defaults to name.to_s.classify)
33
- # @param query_name [String] The GraphQL query field name (auto-determined based on nested/root-level)
34
- # @param foreign_key [String] The field to filter by (auto-determined for root-level queries)
35
- # @param loader_class [Class] Custom loader class to use (defaults to model's default loader)
36
- # @param eager_load [Boolean] Whether to automatically eager load this connection (default: false)
37
- # @param type [Symbol] The type of connection (:connection, :singular). Default is :connection.
38
- # @param default_arguments [Hash] Default arguments to pass to the GraphQL query (e.g. first: 10)
39
- # @param inverse_of [Symbol] The name of the inverse connection on the target model (optional)
40
- def connection(name, class_name: nil, query_name: nil, foreign_key: nil, loader_class: nil, eager_load: false, type: :connection, default_arguments: {}, inverse_of: nil)
41
- # Infer defaults
42
- connection_class_name = class_name || name.to_s.classify
43
-
44
- # Set query_name - default to camelCase for nested fields
45
- connection_query_name = query_name || name.to_s.camelize(:lower)
46
-
47
- connection_loader_class = loader_class
48
-
49
- # Store connection metadata
50
- connections[name] = {
51
- class_name: connection_class_name,
52
- query_name: connection_query_name,
53
- foreign_key: foreign_key,
54
- loader_class: connection_loader_class,
55
- eager_load: eager_load,
56
- type: type,
57
- nested: true, # Always treated as nested (accessed via parent field)
58
- target_class_name: connection_class_name,
59
- original_name: name,
60
- default_arguments: default_arguments,
61
- inverse_of: inverse_of
62
- }
63
-
64
- # Validate inverse relationship if specified (validation is deferred to runtime)
65
- validate_inverse_of!(name, connection_class_name, inverse_of) if inverse_of
66
-
67
- # Define the connection method that returns a proxy
68
- define_method name do |**options|
69
- # Check if this connection was eager loaded
70
- return @_connection_cache[name] if @_connection_cache&.key?(name)
71
-
72
- config = self.class.connections[name]
73
- if config[:type] == :singular
74
- # Lazy load singular association
75
- loader_class = config[:loader_class] || self.class.default_loader.class
76
- target_class = config[:class_name].constantize
77
- loader = loader_class.new(target_class)
78
-
79
- # Load the record
80
- records = loader.load_connection_records(config[:query_name], options, self, config)
81
-
82
- # Populate inverse cache if inverse_of is specified
83
- populate_inverse_cache_for_connection(records, config, self)
84
-
85
- # Cache it
86
- @_connection_cache ||= {}
87
- @_connection_cache[name] = records
88
- records
89
- elsif options.empty?
90
- # If no runtime options are provided, reuse existing proxy if it exists
91
- @_connection_proxies ||= {}
92
- @_connection_proxies[name] ||= ConnectionProxy.new(
93
- parent: self,
94
- connection_name: name,
95
- connection_config: self.class.connections[name],
96
- options: options
97
- )
98
- else
99
- # Create a new proxy for custom options (don't cache these)
100
- ConnectionProxy.new(
101
- parent: self,
102
- connection_name: name,
103
- connection_config: self.class.connections[name],
104
- options: options
105
- )
106
- end
107
- end
108
-
109
- # Define setter method for testing/caching
110
- define_method "#{name}=" do |value|
111
- @_connection_cache ||= {}
112
- @_connection_cache[name] = value
113
- end
114
- end
115
-
116
- # Load records with eager-loaded connections
117
- # @param *connection_names [Symbol, Hash] The connection names to eager load
118
- # @return [Class] A modified class for method chaining
119
- #
120
- # @example
121
- # Customer.includes(:orders).find(123)
122
- # Customer.includes(:orders, :addresses).where(email: "john@example.com")
123
- # Order.includes(line_items: :variant)
124
- def includes(*connection_names)
125
- # Validate connections exist
126
- validate_includes_connections!(connection_names)
127
-
128
- # Collect connections with eager_load: true
129
- auto_included_connections = connections.select { |_name, config| config[:eager_load] }.keys
130
-
131
- # Merge manual and automatic connections
132
- all_included_connections = (connection_names + auto_included_connections).uniq
133
-
134
- # Create a scope object that holds the included connections
135
- IncludesScope.new(self, all_included_connections)
136
- end
137
-
138
- private
139
-
140
- def validate_inverse_of!(_name, _target_class_name, _inverse_name)
141
- # Validation is deferred until runtime when connections are actually used
142
- # This allows class definitions to be in any order
143
- # The validation logic will be checked when inverse cache is populated
144
- nil
145
- end
146
-
147
- def validate_includes_connections!(connection_names)
148
- connection_names.each do |name|
149
- if name.is_a?(Hash)
150
- name.each do |key, value|
151
- raise ArgumentError, "Invalid connection for #{self.name}: #{key}. Available connections: #{connections.keys.join(', ')}" unless connections.key?(key.to_sym)
152
-
153
- # Recursively validate nested connections
154
- target_class = connections[key.to_sym][:class_name].constantize
155
- if target_class.respond_to?(:validate_includes_connections!, true)
156
- nested_names = value.is_a?(Array) ? value : [value]
157
- target_class.send(:validate_includes_connections!, nested_names)
158
- end
159
- end
160
- else
161
- raise ArgumentError, "Invalid connection for #{self.name}: #{name}. Available connections: #{connections.keys.join(', ')}" unless connections.key?(name.to_sym)
162
- end
163
- end
164
- end
165
- end
166
-
167
- # Instance method to populate inverse cache for lazy-loaded connections
168
-
169
- def populate_inverse_cache_for_connection(records, connection_config, parent)
170
- return unless connection_config[:inverse_of]
171
- return if records.nil? || (records.is_a?(Array) && records.empty?)
172
-
173
- inverse_name = connection_config[:inverse_of]
174
- target_class = connection_config[:class_name].constantize
175
-
176
- # Ensure target class has the inverse connection defined
177
- return unless target_class.respond_to?(:connections) && target_class.connections[inverse_name]
178
-
179
- inverse_type = target_class.connections[inverse_name][:type]
180
- records_array = records.is_a?(Array) ? records : [records]
181
-
182
- records_array.each do |record|
183
- next unless record
184
-
185
- record.instance_variable_set(:@_connection_cache, {}) unless record.instance_variable_get(:@_connection_cache)
186
- cache = record.instance_variable_get(:@_connection_cache)
187
-
188
- cache[inverse_name] =
189
- if inverse_type == :singular
190
- parent
191
- else
192
- # For collection inverses, wrap parent in an array
193
- [parent]
194
- end
195
- end
196
- end
197
- end
198
- end
@@ -1,182 +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] The model instance
12
- # @raise [ActiveShopifyGraphQL::ObjectNotFoundError] If the record is not found
13
- def find(id, loader: default_loader)
14
- gid = GidHelper.normalize_gid(id, model_name.name.demodulize)
15
-
16
- # If we have included connections, we need to handle inverse_of properly
17
- result =
18
- if loader.has_included_connections?
19
- loader.load_with_instance(gid, self)
20
- else
21
- attributes = loader.load_attributes(gid)
22
- attributes.nil? ? nil : new(attributes)
23
- end
24
-
25
- raise ObjectNotFoundError, "Couldn't find #{name} with id=#{id}" if result.nil?
26
-
27
- result
28
- end
29
-
30
- # Returns the default loader for this model's queries
31
- # @return [ActiveGraphQL::Loader] The default loader instance
32
- def default_loader
33
- if respond_to?(:default_loader_instance)
34
- default_loader_instance
35
- else
36
- @default_loader ||= begin
37
- # Collect connections with eager_load: true
38
- eagerly_loaded_connections = connections.select { |_name, config| config[:eager_load] }.keys
39
-
40
- default_loader_class.new(
41
- self,
42
- included_connections: eagerly_loaded_connections
43
- )
44
- end
45
- end
46
- end
47
-
48
- # Allows setting a custom default loader (useful for testing)
49
- # @param loader [ActiveGraphQL::Loader] The loader to set as default
50
- def default_loader=(loader)
51
- @default_loader = loader
52
- end
53
-
54
- # Select specific attributes to optimize GraphQL queries
55
- # @param *attributes [Symbol] The attributes to select
56
- # @return [Class] A class with modified default loader for method chaining
57
- #
58
- # @example
59
- # Customer.select(:id, :email).find(123)
60
- # Customer.select(:id, :email).where(first_name: "John")
61
- def select(*attributes)
62
- # Validate attributes exist
63
- attrs = Array(attributes).flatten.map(&:to_sym)
64
- validate_select_attributes!(attrs)
65
-
66
- # Create a new class that inherits from self with a modified default loader
67
- selected_class = Class.new(self)
68
-
69
- # Override the default_loader method to return a loader with selected attributes
70
- selected_class.define_singleton_method(:default_loader) do
71
- @default_loader ||= superclass.default_loader.class.new(
72
- superclass,
73
- selected_attributes: attrs
74
- )
75
- end
76
-
77
- # Preserve the original class name and model name for GraphQL operations
78
- selected_class.define_singleton_method(:name) { superclass.name }
79
- selected_class.define_singleton_method(:model_name) { superclass.model_name }
80
-
81
- selected_class
82
- end
83
-
84
- # Find a single record by attribute conditions
85
- # @param conditions [Hash] The conditions to query (e.g., { email: "example@test.com", first_name: "John" })
86
- # @param options [Hash] Options hash containing loader
87
- # @option options [ActiveShopifyGraphQL::Loader] :loader The loader to use for fetching data
88
- # @return [Object, nil] The first matching model instance or nil if not found
89
- # @raise [ArgumentError] If any attribute is not valid for querying
90
- #
91
- # @example
92
- # # Keyword argument style (recommended)
93
- # Customer.find_by(email: "john@example.com")
94
- # Customer.find_by(first_name: "John", country: "Canada")
95
- # Customer.find_by(orders_count: { gte: 5 })
96
- #
97
- # # Hash style with options
98
- # Customer.find_by({ email: "john@example.com" }, loader: custom_loader)
99
- def find_by(conditions_or_first_condition = {}, *args, **options)
100
- where(conditions_or_first_condition, *args, **options.merge(limit: 1)).first
101
- end
102
-
103
- # Query for multiple records using attribute conditions
104
- # @param conditions [Hash] The conditions to query (e.g., { email: "example@test.com", first_name: "John" })
105
- # @param options [Hash] Options hash containing loader and limit (when first arg is a Hash)
106
- # @option options [ActiveShopifyGraphQL::Loader] :loader The loader to use for fetching data
107
- # @option options [Integer] :limit The maximum number of records to return (default: 250, max: 250)
108
- # @return [Array<Object>] Array of model instances
109
- # @raise [ArgumentError] If any attribute is not valid for querying
110
- #
111
- # @example
112
- # # Keyword argument style (recommended)
113
- # Customer.where(email: "john@example.com")
114
- # Customer.where(first_name: "John", country: "Canada")
115
- # Customer.where(orders_count: { gte: 5 })
116
- # Customer.where(created_at: { gte: "2024-01-01", lt: "2024-02-01" })
117
- #
118
- # # Hash style with options
119
- # Customer.where({ email: "john@example.com" }, loader: custom_loader, limit: 100)
120
- def where(conditions_or_first_condition = {}, *args, **options)
121
- # Handle both syntaxes:
122
- # where(email: "john@example.com") - keyword args become options
123
- # where({ email: "john@example.com" }, loader: custom_loader) - explicit hash + options
124
- if conditions_or_first_condition.is_a?(Hash) && !conditions_or_first_condition.empty?
125
- # Explicit hash provided as first argument
126
- conditions = conditions_or_first_condition
127
- # Any additional options passed as keyword args or second hash argument
128
- final_options = args.first.is_a?(Hash) ? options.merge(args.first) : options
129
- else
130
- # Keyword arguments style - conditions come from options, excluding known option keys
131
- known_option_keys = %i[loader limit]
132
- conditions = options.except(*known_option_keys)
133
- final_options = options.slice(*known_option_keys)
134
- end
135
-
136
- loader = final_options[:loader] || default_loader
137
- limit = final_options[:limit] || 250
138
-
139
- # Ensure loader has model class set - needed for graphql_type inference
140
- loader.instance_variable_set(:@model_class, self) if loader.instance_variable_get(:@model_class).nil?
141
-
142
- attributes_array = loader.load_collection(conditions, limit: limit)
143
-
144
- attributes_array.map { |attributes| new(attributes) }
145
- end
146
-
147
- private
148
-
149
- # Validates that selected attributes exist in the model
150
- # @param attributes [Array<Symbol>] The attributes to validate
151
- # @raise [ArgumentError] If any attribute is invalid
152
- def validate_select_attributes!(attributes)
153
- return if attributes.empty?
154
-
155
- available_attrs = available_select_attributes
156
- invalid_attrs = attributes - available_attrs
157
-
158
- return unless invalid_attrs.any?
159
-
160
- raise ArgumentError, "Invalid attributes for #{name}: #{invalid_attrs.join(', ')}. " \
161
- "Available attributes are: #{available_attrs.join(', ')}"
162
- end
163
-
164
- # Gets all available attributes for selection
165
- # @return [Array<Symbol>] Available attribute names
166
- def available_select_attributes
167
- attrs = []
168
-
169
- # Get attributes from the model class
170
- loader_class = default_loader.class
171
- model_attrs = attributes_for_loader(loader_class)
172
- attrs.concat(model_attrs.keys)
173
-
174
- # Get attributes from the loader class
175
- loader_attrs = default_loader.class.defined_attributes
176
- attrs.concat(loader_attrs.keys)
177
-
178
- attrs.map(&:to_sym).uniq.sort
179
- end
180
- end
181
- end
182
- end