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,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
@@ -1,180 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActiveShopifyGraphQL
4
- # Provides capability to switch between different loaders within the same model
5
- module LoaderSwitchable
6
- extend ActiveSupport::Concern
7
-
8
- # Generic method to execute with a specific loader
9
- # @param loader_class [Class] The loader class to use
10
- # @yield [Object] Block to execute with the loader
11
- # @return [Object] Result of the block
12
- def with_loader(loader_class, &_block)
13
- old_loader = Thread.current[:active_shopify_graphql_loader]
14
- Thread.current[:active_shopify_graphql_loader] = loader_class.new(self.class)
15
-
16
- if block_given?
17
- yield(self)
18
- else
19
- self
20
- end
21
- ensure
22
- Thread.current[:active_shopify_graphql_loader] = old_loader
23
- end
24
-
25
- # Executes with the admin API loader
26
- # @return [self]
27
- def with_admin_api(&block)
28
- with_loader(ActiveShopifyGraphQL::Loaders::AdminApiLoader, &block)
29
- end
30
-
31
- # Executes with the customer account API loader
32
- # @return [self]
33
- def with_customer_account_api(&block)
34
- with_loader(ActiveShopifyGraphQL::Loaders::CustomerAccountApiLoader, &block)
35
- end
36
-
37
- class_methods do
38
- # @!method use_loader(loader_class)
39
- # Sets the default loader class for this model.
40
- #
41
- # @param loader_class [Class] The loader class to use as default
42
- # @example
43
- # class Customer < ActiveRecord::Base
44
- # use_loader ActiveShopifyGraphQL::Loaders::CustomerAccountApiLoader
45
- # end
46
- def use_loader(loader_class)
47
- @default_loader_class = loader_class
48
- end
49
-
50
- # Define loader-specific attribute and graphql_type overrides
51
- # @param loader_class [Class] The loader class to override attributes for
52
- def for_loader(loader_class, &block)
53
- @current_loader_context = loader_class
54
- @loader_contexts ||= {}
55
- @loader_contexts[loader_class] ||= {}
56
- instance_eval(&block) if block_given?
57
- @current_loader_context = nil
58
- end
59
-
60
- # Class-level method to execute with admin API loader
61
- # @return [LoaderProxy] Proxy object with find method
62
- def with_admin_api
63
- LoaderProxy.new(self, ActiveShopifyGraphQL::Loaders::AdminApiLoader.new(self))
64
- end
65
-
66
- # Class-level method to execute with customer account API loader
67
- # @return [LoaderProxy] Proxy object with find method
68
- def with_customer_account_api(token = nil)
69
- LoaderProxy.new(self, ActiveShopifyGraphQL::Loaders::CustomerAccountApiLoader.new(self, token))
70
- end
71
-
72
- private
73
-
74
- # Returns the default loader class (either set via DSL or inferred)
75
- # @return [Class] The default loader class
76
- def default_loader_class
77
- @default_loader_class ||= ActiveShopifyGraphQL::Loaders::AdminApiLoader
78
- end
79
- end
80
-
81
- # Simple proxy class to handle loader delegation
82
- class LoaderProxy
83
- def initialize(model_class, loader, included_connections: [], selected_attributes: nil)
84
- @model_class = model_class
85
- @loader = loader
86
- @included_connections = included_connections
87
- @selected_attributes = selected_attributes
88
- end
89
-
90
- def includes(*connection_names)
91
- # Validate connections exist
92
- @model_class.send(:validate_includes_connections!, connection_names) if @model_class.respond_to?(:validate_includes_connections!, true)
93
-
94
- # Collect connections with eager_load: true
95
- auto_included_connections = @model_class.connections.select { |_name, config| config[:eager_load] }.keys
96
-
97
- # Merge manual and automatic connections
98
- all_included_connections = (@included_connections + connection_names + auto_included_connections).uniq
99
-
100
- # Create a new loader with the included connections
101
- new_loader = @loader.class.new(
102
- @model_class,
103
- *loader_extra_args,
104
- selected_attributes: @selected_attributes,
105
- included_connections: all_included_connections
106
- )
107
-
108
- LoaderProxy.new(
109
- @model_class,
110
- new_loader,
111
- included_connections: all_included_connections,
112
- selected_attributes: @selected_attributes
113
- )
114
- end
115
-
116
- def select(*attribute_names)
117
- new_selected = attribute_names.map(&:to_sym)
118
-
119
- # Create a new loader with the selected attributes
120
- new_loader = @loader.class.new(
121
- @model_class,
122
- *loader_extra_args,
123
- selected_attributes: new_selected,
124
- included_connections: @included_connections
125
- )
126
-
127
- LoaderProxy.new(
128
- @model_class,
129
- new_loader,
130
- included_connections: @included_connections,
131
- selected_attributes: new_selected
132
- )
133
- end
134
-
135
- def find(id = nil)
136
- # For Customer Account API, if no ID is provided, load the current customer
137
- if id.nil? && @loader.is_a?(ActiveShopifyGraphQL::Loaders::CustomerAccountApiLoader)
138
- attributes = @loader.load_attributes
139
- return nil if attributes.nil?
140
-
141
- return @model_class.new(attributes)
142
- end
143
-
144
- # For other cases, require ID and use standard flow
145
- return nil if id.nil?
146
-
147
- gid = GidHelper.normalize_gid(id, @model_class.model_name.name.demodulize)
148
-
149
- attributes = @loader.load_attributes(gid)
150
- return nil if attributes.nil?
151
-
152
- @model_class.new(attributes)
153
- end
154
-
155
- # Delegate where to the model class with the specific loader
156
- def where(*args, **options)
157
- @model_class.where(*args, **options.merge(loader: @loader))
158
- end
159
-
160
- attr_reader :loader
161
-
162
- def inspect
163
- "#{@model_class.name}(with_#{@loader.class.name.demodulize})"
164
- end
165
- alias to_s inspect
166
-
167
- private
168
-
169
- # Returns extra arguments needed when creating a new loader of the same type
170
- # For CustomerAccountApiLoader, this includes the token
171
- def loader_extra_args
172
- if @loader.is_a?(ActiveShopifyGraphQL::Loaders::CustomerAccountApiLoader)
173
- [@loader.instance_variable_get(:@token)]
174
- else
175
- []
176
- end
177
- end
178
- end
179
- end
180
- end
@@ -1,61 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActiveShopifyGraphQL
4
- module MetafieldAttributes
5
- extend ActiveSupport::Concern
6
-
7
- class_methods do
8
- # Define a metafield attribute for this model.
9
- #
10
- # @param name [Symbol] The Ruby attribute name
11
- # @param namespace [String] The metafield namespace
12
- # @param key [String] The metafield key
13
- # @param type [Symbol] The type for coercion (:string, :integer, :float, :boolean, :datetime, :json)
14
- # @param null [Boolean] Whether the attribute can be null (default: true)
15
- # @param default [Object] Default value when GraphQL response is nil
16
- # @param transform [Proc] Custom transform block for the value
17
- def metafield_attribute(name, namespace:, key:, type: :string, null: true, default: nil, transform: nil)
18
- @metafields ||= {}
19
- @metafields[name] = { namespace: namespace, key: key, type: type }
20
-
21
- # Build metafield config
22
- alias_name = "#{infer_path(name)}Metafield"
23
- value_field = type == :json ? 'jsonValue' : 'value'
24
- path = "#{alias_name}.#{value_field}"
25
-
26
- config = {
27
- path: path,
28
- type: type,
29
- null: null,
30
- default: default,
31
- transform: transform,
32
- is_metafield: true,
33
- metafield_alias: alias_name,
34
- metafield_namespace: namespace,
35
- metafield_key: key
36
- }
37
-
38
- if @current_loader_context
39
- @loader_contexts[@current_loader_context][name] = config
40
- else
41
- @base_attributes ||= {}
42
- @base_attributes[name] = config
43
- end
44
-
45
- attr_accessor name unless method_defined?(name) || method_defined?("#{name}=")
46
- end
47
-
48
- # Get metafields defined for this model
49
- def metafields
50
- @metafields || {}
51
- end
52
-
53
- private
54
-
55
- # Infer GraphQL path from Ruby attribute name (delegates to Attributes if available)
56
- def infer_path(name)
57
- name.to_s.gsub(/_([a-z])/) { ::Regexp.last_match(1).upcase }
58
- end
59
- end
60
- end
61
- end