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
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL::Model::Attributes
4
+ extend ActiveSupport::Concern
5
+
6
+ class_methods do
7
+ # Define an attribute with automatic GraphQL path inference and type coercion.
8
+ #
9
+ # @param name [Symbol] The Ruby attribute name
10
+ # @param path [String] The GraphQL field path (auto-inferred if not provided)
11
+ # @param type [Symbol] The type for coercion (:string, :integer, :float, :boolean, :datetime)
12
+ # @param null [Boolean] Whether the attribute can be null (default: true)
13
+ # @param default [Object] Default value when GraphQL response is nil
14
+ # @param transform [Proc] Custom transform block for the value
15
+ # @param raw_graphql [String] Raw GraphQL string to inject directly (escape hatch for unsupported features)
16
+ def attribute(name, path: nil, type: :string, null: true, default: nil, transform: nil, raw_graphql: nil)
17
+ path ||= infer_path(name)
18
+ config = { path: path, type: type, null: null, default: default, transform: transform, raw_graphql: raw_graphql }
19
+
20
+ if @current_loader_context
21
+ # Store in loader-specific context
22
+ @loader_contexts[@current_loader_context][name] = config
23
+ else
24
+ # Store in base attributes
25
+ @base_attributes ||= {}
26
+ @base_attributes[name] = config
27
+ end
28
+
29
+ # Always create attr_accessor
30
+ attr_accessor name unless method_defined?(name) || method_defined?("#{name}=")
31
+ end
32
+
33
+ # Get attributes for a specific loader class, merging base with loader-specific overrides.
34
+ def attributes_for_loader(loader_class)
35
+ base = @base_attributes || {}
36
+ overrides = @loader_contexts&.dig(loader_class) || {}
37
+
38
+ base.merge(overrides) { |_key, base_val, override_val| base_val.merge(override_val) }
39
+ end
40
+
41
+ private
42
+
43
+ # Infer GraphQL path from Ruby attribute name (snake_case -> camelCase)
44
+ def infer_path(name)
45
+ name.to_s.gsub(/_([a-z])/) { ::Regexp.last_match(1).upcase }
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL::Model::Connections
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ class << self
8
+ def connections
9
+ @connections ||= {}
10
+ end
11
+
12
+ attr_writer :connections
13
+ end
14
+ end
15
+
16
+ class_methods do
17
+ # Define a singular connection (returns a single object)
18
+ # @see #connection
19
+ def has_one_connected(name, inverse_of: nil, **options)
20
+ connection(name, type: :singular, inverse_of: inverse_of, **options)
21
+ end
22
+
23
+ # Define a plural connection (returns a collection via nodes)
24
+ # @see #connection
25
+ def has_many_connected(name, inverse_of: nil, **options)
26
+ connection(name, type: :connection, inverse_of: inverse_of, **options)
27
+ end
28
+
29
+ # Define a GraphQL connection to another ActiveShopifyGraphQL model
30
+ # @param name [Symbol] The connection name (e.g., :orders)
31
+ # @param class_name [String] The target model class name (defaults to name.to_s.classify)
32
+ # @param query_name [String] The GraphQL query field name (auto-determined based on nested/root-level)
33
+ # @param foreign_key [String] The field to filter by (auto-determined for root-level queries)
34
+ # @param loader_class [Class] Custom loader class to use (defaults to model's default loader)
35
+ # @param eager_load [Boolean] Whether to automatically eager load this connection (default: false)
36
+ # @param type [Symbol] The type of connection (:connection, :singular). Default is :connection.
37
+ # @param default_arguments [Hash] Default arguments to pass to the GraphQL query (e.g. first: 10)
38
+ # @param inverse_of [Symbol] The name of the inverse connection on the target model (optional)
39
+ 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)
40
+ # Infer defaults
41
+ connection_class_name = class_name || name.to_s.classify
42
+
43
+ # Set query_name - default to camelCase for nested fields
44
+ connection_query_name = query_name || name.to_s.camelize(:lower)
45
+
46
+ connection_loader_class = loader_class
47
+
48
+ # Store connection metadata
49
+ connections[name] = {
50
+ class_name: connection_class_name,
51
+ query_name: connection_query_name,
52
+ foreign_key: foreign_key,
53
+ loader_class: connection_loader_class,
54
+ eager_load: eager_load,
55
+ type: type,
56
+ nested: true, # Always treated as nested (accessed via parent field)
57
+ target_class_name: connection_class_name,
58
+ original_name: name,
59
+ default_arguments: default_arguments,
60
+ inverse_of: inverse_of
61
+ }
62
+
63
+ # Validate inverse relationship if specified (validation is deferred to runtime)
64
+ validate_inverse_of!(name, connection_class_name, inverse_of) if inverse_of
65
+
66
+ # Define the connection method that returns a proxy
67
+ define_method name do |**options|
68
+ # Check if this connection was eager loaded
69
+ return @_connection_cache[name] if @_connection_cache&.key?(name)
70
+
71
+ config = self.class.connections[name]
72
+ if config[:type] == :singular
73
+ # Lazy load singular association
74
+ loader_class = config[:loader_class] || self.class.default_loader.class
75
+ target_class = config[:class_name].constantize
76
+ loader = loader_class.new(target_class)
77
+
78
+ # Load the record
79
+ records = loader.load_connection_records(config[:query_name], options, self, config)
80
+
81
+ # Populate inverse cache if inverse_of is specified
82
+ populate_inverse_cache_for_connection(records, config, self)
83
+
84
+ # Cache it
85
+ @_connection_cache ||= {}
86
+ @_connection_cache[name] = records
87
+ records
88
+ elsif options.empty?
89
+ # If no runtime options are provided, reuse existing proxy if it exists
90
+ @_connection_proxies ||= {}
91
+ @_connection_proxies[name] ||= ActiveShopifyGraphQL::Connections::ConnectionProxy.new(
92
+ parent: self,
93
+ connection_name: name,
94
+ connection_config: self.class.connections[name],
95
+ options: options
96
+ )
97
+ else
98
+ # Create a new proxy for custom options (don't cache these)
99
+ Connections::ConnectionProxy.new(
100
+ parent: self,
101
+ connection_name: name,
102
+ connection_config: self.class.connections[name],
103
+ options: options
104
+ )
105
+ end
106
+ end
107
+
108
+ # Define setter method for testing/caching
109
+ define_method "#{name}=" do |value|
110
+ @_connection_cache ||= {}
111
+ @_connection_cache[name] = value
112
+ end
113
+ end
114
+
115
+ private
116
+
117
+ def validate_inverse_of!(_name, _target_class_name, _inverse_name)
118
+ # Validation is deferred until runtime when connections are actually used
119
+ # This allows class definitions to be in any order
120
+ # The validation logic will be checked when inverse cache is populated
121
+ nil
122
+ end
123
+
124
+ def validate_includes_connections!(connection_names)
125
+ connection_names.each do |name|
126
+ if name.is_a?(Hash)
127
+ name.each do |key, value|
128
+ raise ArgumentError, "Invalid connection for #{self.name}: #{key}. Available connections: #{connections.keys.join(', ')}" unless connections.key?(key.to_sym)
129
+
130
+ # Recursively validate nested connections
131
+ target_class = connections[key.to_sym][:class_name].constantize
132
+ if target_class.respond_to?(:validate_includes_connections!, true)
133
+ nested_names = value.is_a?(Array) ? value : [value]
134
+ target_class.send(:validate_includes_connections!, nested_names)
135
+ end
136
+ end
137
+ else
138
+ raise ArgumentError, "Invalid connection for #{self.name}: #{name}. Available connections: #{connections.keys.join(', ')}" unless connections.key?(name.to_sym)
139
+ end
140
+ end
141
+ end
142
+ end
143
+
144
+ # Instance method to populate inverse cache for lazy-loaded connections
145
+
146
+ def populate_inverse_cache_for_connection(records, connection_config, parent)
147
+ return unless connection_config[:inverse_of]
148
+ return if records.nil? || (records.is_a?(Array) && records.empty?)
149
+
150
+ inverse_name = connection_config[:inverse_of]
151
+ target_class = connection_config[:class_name].constantize
152
+
153
+ # Ensure target class has the inverse connection defined
154
+ return unless target_class.respond_to?(:connections) && target_class.connections[inverse_name]
155
+
156
+ inverse_type = target_class.connections[inverse_name][:type]
157
+ records_array = records.is_a?(Array) ? records : [records]
158
+
159
+ records_array.each do |record|
160
+ next unless record
161
+
162
+ record.instance_variable_set(:@_connection_cache, {}) unless record.instance_variable_get(:@_connection_cache)
163
+ cache = record.instance_variable_get(:@_connection_cache)
164
+
165
+ cache[inverse_name] =
166
+ if inverse_type == :singular
167
+ parent
168
+ else
169
+ # For collection inverses, wrap parent in an array
170
+ [parent]
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL::Model::FinderMethods
4
+ extend ActiveSupport::Concern
5
+
6
+ class_methods do
7
+ # Returns a Relation for the model that can be chained
8
+ # @return [Relation] A new relation for this model
9
+ def all
10
+ ActiveShopifyGraphQL::Query::Relation.new(self)
11
+ end
12
+
13
+ # Find a single record by ID
14
+ # @param id [String, Integer] The record ID (will be converted to GID automatically)
15
+ # @param loader [ActiveShopifyGraphQL::Loader] The loader to use for fetching data (deprecated, use Relation chain)
16
+ # @return [Object] The model instance
17
+ # @raise [ActiveShopifyGraphQL::ObjectNotFoundError] If the record is not found
18
+ def find(id)
19
+ all.find(id)
20
+ end
21
+
22
+ # Returns the default loader for this model's queries
23
+ # @return [ActiveGraphQL::Loader] The default loader instance
24
+ def default_loader
25
+ if respond_to?(:default_loader_instance)
26
+ default_loader_instance
27
+ else
28
+ @default_loader ||= begin
29
+ # Collect connections with eager_load: true
30
+ eagerly_loaded_connections = connections.select { |_name, config| config[:eager_load] }.keys
31
+
32
+ default_loader_class.new(
33
+ self,
34
+ included_connections: eagerly_loaded_connections
35
+ )
36
+ end
37
+ end
38
+ end
39
+
40
+ # Allows setting a custom default loader (useful for testing)
41
+ # @param loader [ActiveGraphQL::Loader] The loader to set as default
42
+ def default_loader=(loader)
43
+ @default_loader = loader
44
+ end
45
+
46
+ # Find a single record by attribute conditions
47
+ # @param conditions [Hash] The conditions to query
48
+ # @return [Object, nil] The first matching model instance or nil if not found
49
+ #
50
+ # @example
51
+ # Customer.find_by(email: "john@example.com")
52
+ # Customer.find_by(first_name: "John", country: "Canada")
53
+ # Customer.find_by(orders_count: { gte: 5 })
54
+ def find_by(conditions = {}, **options)
55
+ all.find_by(conditions.empty? ? options : conditions)
56
+ end
57
+
58
+ # Query for multiple records using attribute conditions
59
+ # Returns a Relation that supports chaining .limit(), .includes(), .find_by() and .in_pages()
60
+ #
61
+ # Supports three query styles:
62
+ # 1. Hash-based (safe, with automatic sanitization) - burden on library
63
+ # 2. String-based (raw query, no sanitization) - burden on developer
64
+ # 3. String with parameter binding (safe, with sanitization) - burden on library
65
+ #
66
+ # @param conditions_or_first_condition [Hash, String] The conditions to query
67
+ # @param args [Array] Additional positional arguments for parameter binding
68
+ # @param options [Hash] Named parameters for parameter binding
69
+ # @return [Relation] A chainable relation
70
+ #
71
+ # @example Hash-based query (safe, escaped)
72
+ # Customer.where(email: "john@example.com").to_a
73
+ # # => produces: query:"email:'john@example.com'"
74
+ #
75
+ # @example String-based query (raw, allows wildcards)
76
+ # ProductVariant.where("sku:*").to_a
77
+ # # => produces: query:"sku:*" (wildcard matching enabled)
78
+ #
79
+ # @example String with positional parameter binding (safe)
80
+ # ProductVariant.where("sku:? product_id:?", "Good ol' value", 123).to_a
81
+ # # => produces: query:"sku:'Good ol\\' value' product_id:123"
82
+ #
83
+ # @example String with named parameter binding (safe)
84
+ # ProductVariant.where("sku::sku product_id::id", { sku: "A-SKU", id: 123 }).to_a
85
+ # ProductVariant.where("sku::sku", sku: "A-SKU").to_a
86
+ # # => produces: query:"sku:'A-SKU' product_id:123"
87
+ #
88
+ # @example With limit
89
+ # Customer.where(first_name: "John").limit(100).to_a
90
+ #
91
+ # @example With pagination block
92
+ # Customer.where(orders_count: { gte: 5 }).in_pages(of: 50) do |page|
93
+ # page.each { |customer| process(customer) }
94
+ # end
95
+ def where(conditions_or_first_condition = {}, *args, **options)
96
+ all.where(conditions_or_first_condition, *args, **options)
97
+ end
98
+
99
+ # Select specific attributes to optimize GraphQL queries
100
+ # @param attributes [Symbol] The attributes to select
101
+ # @return [Relation] A relation with selected attributes
102
+ #
103
+ # @example
104
+ # Customer.select(:id, :email).find(123)
105
+ # Customer.select(:id, :email).where(first_name: "John")
106
+ def select(*attributes)
107
+ all.select(*attributes)
108
+ end
109
+
110
+ # Include connections for eager loading
111
+ # @param connection_names [Array<Symbol>] Connection names to include
112
+ # @return [Relation] A relation with connections included
113
+ #
114
+ # @example
115
+ # Customer.includes(:orders).find(123)
116
+ # Customer.includes(:orders, :addresses).where(country: "Canada")
117
+ def includes(*connection_names)
118
+ all.includes(*connection_names)
119
+ end
120
+
121
+ private
122
+
123
+ # Validates that selected attributes exist in the model
124
+ # @param attributes [Array<Symbol>] The attributes to validate
125
+ # @raise [ArgumentError] If any attribute is invalid
126
+ def validate_select_attributes!(attributes)
127
+ return if attributes.empty?
128
+
129
+ available_attrs = available_select_attributes
130
+ invalid_attrs = attributes - available_attrs
131
+
132
+ return unless invalid_attrs.any?
133
+
134
+ raise ArgumentError, "Invalid attributes for #{name}: #{invalid_attrs.join(', ')}. " \
135
+ "Available attributes are: #{available_attrs.join(', ')}"
136
+ end
137
+
138
+ # Gets all available attributes for selection
139
+ # @return [Array<Symbol>] Available attribute names
140
+ def available_select_attributes
141
+ attrs = []
142
+
143
+ # Get attributes from the model class
144
+ loader_class = default_loader.class
145
+ model_attrs = attributes_for_loader(loader_class)
146
+ attrs.concat(model_attrs.keys)
147
+
148
+ # Get attributes from the loader class
149
+ loader_attrs = default_loader.class.defined_attributes
150
+ attrs.concat(loader_attrs.keys)
151
+
152
+ attrs.map(&:to_sym).uniq.sort
153
+ end
154
+ end
155
+ end
@@ -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)