active_shopify_graphql 0.1.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 (32) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/lint.yml +35 -0
  3. data/.github/workflows/test.yml +35 -0
  4. data/.rubocop.yml +50 -0
  5. data/AGENTS.md +53 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +544 -0
  8. data/Rakefile +8 -0
  9. data/lib/active_shopify_graphql/associations.rb +90 -0
  10. data/lib/active_shopify_graphql/attributes.rb +49 -0
  11. data/lib/active_shopify_graphql/base.rb +29 -0
  12. data/lib/active_shopify_graphql/configuration.rb +29 -0
  13. data/lib/active_shopify_graphql/connection_loader.rb +96 -0
  14. data/lib/active_shopify_graphql/connections/connection_proxy.rb +112 -0
  15. data/lib/active_shopify_graphql/connections.rb +170 -0
  16. data/lib/active_shopify_graphql/finder_methods.rb +154 -0
  17. data/lib/active_shopify_graphql/fragment_builder.rb +195 -0
  18. data/lib/active_shopify_graphql/gid_helper.rb +54 -0
  19. data/lib/active_shopify_graphql/graphql_type_resolver.rb +91 -0
  20. data/lib/active_shopify_graphql/loader.rb +183 -0
  21. data/lib/active_shopify_graphql/loader_context.rb +88 -0
  22. data/lib/active_shopify_graphql/loader_switchable.rb +121 -0
  23. data/lib/active_shopify_graphql/loaders/admin_api_loader.rb +32 -0
  24. data/lib/active_shopify_graphql/loaders/customer_account_api_loader.rb +71 -0
  25. data/lib/active_shopify_graphql/metafield_attributes.rb +61 -0
  26. data/lib/active_shopify_graphql/query_node.rb +160 -0
  27. data/lib/active_shopify_graphql/query_tree.rb +204 -0
  28. data/lib/active_shopify_graphql/response_mapper.rb +202 -0
  29. data/lib/active_shopify_graphql/search_query.rb +71 -0
  30. data/lib/active_shopify_graphql/version.rb +5 -0
  31. data/lib/active_shopify_graphql.rb +34 -0
  32. metadata +147 -0
@@ -0,0 +1,195 @@
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
+
34
+ # Build a tree structure for nested paths
35
+ @context.defined_attributes.each_value do |config|
36
+ if config[:is_metafield]
37
+ store_metafield_config(metafield_aliases, config)
38
+ else
39
+ build_path_tree(path_tree, config[:path])
40
+ end
41
+ end
42
+
43
+ # Convert tree to QueryNode objects
44
+ nodes_from_tree(path_tree) + metafield_nodes(metafield_aliases)
45
+ end
46
+
47
+ # Build QueryNode objects for all connections (protected for recursive calls)
48
+ def build_connection_nodes
49
+ return [] if @context.included_connections.empty?
50
+
51
+ connections = @context.connections
52
+ return [] if connections.empty?
53
+
54
+ normalized_includes = normalize_includes(@context.included_connections)
55
+
56
+ normalized_includes.filter_map do |connection_name, nested_includes|
57
+ connection_config = connections[connection_name]
58
+ next unless connection_config
59
+
60
+ build_connection_node(connection_config, nested_includes)
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def store_metafield_config(metafield_aliases, config)
67
+ alias_name = config[:metafield_alias]
68
+ value_field = config[:type] == :json ? 'jsonValue' : 'value'
69
+
70
+ metafield_aliases[alias_name] = {
71
+ namespace: config[:metafield_namespace],
72
+ key: config[:metafield_key],
73
+ value_field: value_field
74
+ }
75
+ end
76
+
77
+ def build_path_tree(path_tree, path)
78
+ path_parts = path.split('.')
79
+ current_level = path_tree
80
+
81
+ path_parts.each_with_index do |part, index|
82
+ if index == path_parts.length - 1
83
+ current_level[part] = true
84
+ else
85
+ current_level[part] ||= {}
86
+ current_level = current_level[part]
87
+ end
88
+ end
89
+ end
90
+
91
+ def nodes_from_tree(tree)
92
+ tree.map do |key, value|
93
+ if value == true
94
+ QueryNode.new(name: key, node_type: :field)
95
+ else
96
+ children = nodes_from_tree(value)
97
+ QueryNode.new(name: key, node_type: :field, children: children)
98
+ end
99
+ end
100
+ end
101
+
102
+ def metafield_nodes(metafield_aliases)
103
+ metafield_aliases.map do |alias_name, config|
104
+ value_node = QueryNode.new(name: config[:value_field], node_type: :field)
105
+ QueryNode.new(
106
+ name: "metafield",
107
+ alias_name: alias_name,
108
+ arguments: { namespace: config[:namespace], key: config[:key] },
109
+ node_type: :field,
110
+ children: [value_node]
111
+ )
112
+ end
113
+ end
114
+
115
+ def build_connection_node(connection_config, nested_includes)
116
+ target_class = connection_config[:class_name].constantize
117
+ target_context = @context.for_model(target_class, new_connections: nested_includes)
118
+
119
+ # Build child nodes for the target model
120
+ child_nodes = build_target_field_nodes(target_context, nested_includes)
121
+
122
+ query_name = connection_config[:query_name]
123
+ connection_type = connection_config[:type] || :connection
124
+ formatted_args = (connection_config[:default_arguments] || {}).transform_keys(&:to_sym)
125
+
126
+ node_type = connection_type == :singular ? :singular : :connection
127
+ QueryNode.new(
128
+ name: query_name,
129
+ arguments: formatted_args,
130
+ node_type: node_type,
131
+ children: child_nodes
132
+ )
133
+ end
134
+
135
+ def build_target_field_nodes(target_context, nested_includes)
136
+ # Build attribute nodes
137
+ attribute_nodes = if target_context.defined_attributes.any?
138
+ FragmentBuilder.new(target_context.with_connections([])).build_field_nodes
139
+ else
140
+ [QueryNode.new(name: "id", node_type: :field)]
141
+ end
142
+
143
+ # Build nested connection nodes
144
+ return attribute_nodes if nested_includes.empty?
145
+
146
+ nested_builder = FragmentBuilder.new(target_context)
147
+ nested_connection_nodes = nested_builder.build_connection_nodes
148
+ attribute_nodes + nested_connection_nodes
149
+ end
150
+
151
+ # Normalize includes from various formats to a consistent hash structure
152
+ def normalize_includes(includes)
153
+ includes = Array(includes)
154
+ includes.each_with_object({}) do |inc, normalized|
155
+ case inc
156
+ when Hash
157
+ inc.each do |key, value|
158
+ key = key.to_sym
159
+ normalized[key] ||= []
160
+ case value
161
+ when Hash then normalized[key] << value
162
+ when Array then normalized[key].concat(value)
163
+ else normalized[key] << value
164
+ end
165
+ end
166
+ when Symbol, String
167
+ normalized[inc.to_sym] ||= []
168
+ end
169
+ end
170
+ end
171
+
172
+ class << self
173
+ # Expose for external use (QueryTree needs this)
174
+ def normalize_includes(includes)
175
+ includes = Array(includes)
176
+ includes.each_with_object({}) do |inc, normalized|
177
+ case inc
178
+ when Hash
179
+ inc.each do |key, value|
180
+ key = key.to_sym
181
+ normalized[key] ||= []
182
+ case value
183
+ when Hash then normalized[key] << value
184
+ when Array then normalized[key].concat(value)
185
+ else normalized[key] << value
186
+ end
187
+ end
188
+ when Symbol, String
189
+ normalized[inc.to_sym] ||= []
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ # Helper module for handling Shopify Global IDs (GIDs)
5
+ # Provides utilities for parsing and building GIDs according to the URI::GID standard
6
+ module GidHelper
7
+ # Normalize an ID value to a proper Shopify GID format
8
+ # If the ID is already a valid GID, returns it as-is
9
+ # Otherwise, builds a new GID using the provided model name
10
+ #
11
+ # @param id [String, Integer] The ID value (can be numeric or existing GID)
12
+ # @param model_name [String] The GraphQL type name (e.g., "Customer", "Order")
13
+ # @return [String] The normalized GID in format "gid://shopify/ModelName/id"
14
+ #
15
+ # @example
16
+ # normalize_gid(123, "Customer")
17
+ # # => "gid://shopify/Customer/123"
18
+ #
19
+ # normalize_gid("gid://shopify/Customer/123", "Customer")
20
+ # # => "gid://shopify/Customer/123"
21
+ #
22
+ def self.normalize_gid(id, model_name)
23
+ # Check if id is already a valid GID
24
+ begin
25
+ parsed_gid = URI::GID.parse(id)
26
+ return id if parsed_gid
27
+ rescue URI::InvalidURIError, URI::BadURIError, ArgumentError
28
+ # Not a valid GID, proceed to build one
29
+ end
30
+
31
+ # Build GID from the provided ID and model name
32
+ URI::GID.build(app: "shopify", model_name: model_name, model_id: id).to_s
33
+ end
34
+
35
+ # Check if a value is a valid Shopify GID
36
+ #
37
+ # @param value [String] The value to check
38
+ # @return [Boolean] true if the value is a valid GID, false otherwise
39
+ #
40
+ # @example
41
+ # valid_gid?("gid://shopify/Customer/123")
42
+ # # => true
43
+ #
44
+ # valid_gid?("123")
45
+ # # => false
46
+ #
47
+ def self.valid_gid?(value)
48
+ URI::GID.parse(value)
49
+ true
50
+ rescue URI::InvalidURIError, URI::BadURIError, ArgumentError
51
+ false
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,91 @@
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
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_model/type'
4
+ require 'global_id'
5
+
6
+ module ActiveShopifyGraphQL
7
+ # Base loader class that orchestrates GraphQL query execution and response mapping.
8
+ # Refactored to use LoaderContext for cleaner parameter management.
9
+ class Loader
10
+ class << self
11
+ # Set or get the GraphQL type for this loader
12
+ def graphql_type(type = nil)
13
+ return @graphql_type = type if type
14
+
15
+ # Try to get GraphQL type from associated model class first
16
+ return model_class.graphql_type_for_loader(self) if model_class
17
+
18
+ @graphql_type || raise(NotImplementedError, "#{self} must define graphql_type")
19
+ end
20
+
21
+ # Get the model class associated with this loader
22
+ def model_class
23
+ @model_class ||= infer_model_class
24
+ end
25
+
26
+ attr_writer :model_class
27
+
28
+ # Get attributes from the model class for this loader
29
+ def defined_attributes
30
+ return {} unless model_class
31
+
32
+ model_class.attributes_for_loader(self)
33
+ end
34
+
35
+ private
36
+
37
+ def infer_model_class
38
+ return nil unless @graphql_type
39
+
40
+ Object.const_get(@graphql_type)
41
+ rescue NameError
42
+ nil
43
+ end
44
+ end
45
+
46
+ # Initialize loader with optional model class and configuration
47
+ def initialize(model_class = nil, selected_attributes: nil, included_connections: nil, **)
48
+ @model_class = model_class || self.class.model_class
49
+ @selected_attributes = selected_attributes&.map(&:to_sym)
50
+ @included_connections = included_connections || []
51
+ end
52
+
53
+ # Build the LoaderContext for this loader instance
54
+ def context
55
+ @context ||= LoaderContext.new(
56
+ graphql_type: graphql_type,
57
+ loader_class: self.class,
58
+ defined_attributes: defined_attributes,
59
+ model_class: @model_class,
60
+ included_connections: @included_connections
61
+ )
62
+ end
63
+
64
+ # Get GraphQL type for this loader instance
65
+ def graphql_type
66
+ GraphqlTypeResolver.resolve(model_class: @model_class, loader_class: self.class)
67
+ end
68
+
69
+ # Get defined attributes for this loader instance
70
+ def defined_attributes
71
+ attrs = if @model_class
72
+ @model_class.attributes_for_loader(self.class)
73
+ else
74
+ self.class.defined_attributes
75
+ end
76
+
77
+ filter_selected_attributes(attrs)
78
+ end
79
+
80
+ # Returns the complete GraphQL fragment
81
+ def fragment
82
+ FragmentBuilder.new(context).build
83
+ end
84
+
85
+ # Delegate query building methods
86
+ def query_name(model_type = nil)
87
+ (model_type || graphql_type).downcase
88
+ end
89
+
90
+ def fragment_name(model_type = nil)
91
+ "#{model_type || graphql_type}Fragment"
92
+ end
93
+
94
+ def graphql_query(_model_type = nil)
95
+ QueryTree.build_single_record_query(context)
96
+ end
97
+
98
+ # Map the GraphQL response to model attributes
99
+ def map_response_to_attributes(response_data)
100
+ mapper = ResponseMapper.new(context)
101
+ attributes = mapper.map_response(response_data)
102
+
103
+ # If we have included connections, extract and cache them
104
+ if @included_connections.any?
105
+ connection_data = mapper.extract_connection_data(response_data)
106
+ attributes[:_connection_cache] = connection_data unless connection_data.empty?
107
+ end
108
+
109
+ attributes
110
+ end
111
+
112
+ # Executes the GraphQL query and returns the mapped attributes hash
113
+ def load_attributes(id)
114
+ query = graphql_query
115
+ response_data = perform_graphql_query(query, id: id)
116
+
117
+ return nil if response_data.nil?
118
+
119
+ map_response_to_attributes(response_data)
120
+ end
121
+
122
+ # Executes a collection query using Shopify's search syntax
123
+ def load_collection(conditions = {}, limit: 250)
124
+ search_query = SearchQuery.new(conditions)
125
+ collection_query_name = query_name.pluralize
126
+ variables = { query: search_query.to_s, first: limit }
127
+
128
+ query = QueryTree.build_collection_query(
129
+ context,
130
+ query_name: collection_query_name,
131
+ variables: variables,
132
+ connection_type: :nodes_only
133
+ )
134
+
135
+ response = perform_graphql_query(query, **variables)
136
+ validate_search_response(response)
137
+ map_collection_response(response, collection_query_name)
138
+ end
139
+
140
+ # Load records for a connection query
141
+ def load_connection_records(query_name, variables, parent = nil, connection_config = nil)
142
+ connection_loader = ConnectionLoader.new(context, loader_instance: self)
143
+ connection_loader.load_records(query_name, variables, parent, connection_config)
144
+ end
145
+
146
+ # Abstract method for executing GraphQL queries
147
+ def perform_graphql_query(query, **variables)
148
+ raise NotImplementedError, "#{self.class} must implement perform_graphql_query"
149
+ end
150
+
151
+ private
152
+
153
+ def filter_selected_attributes(attrs)
154
+ return attrs unless @selected_attributes
155
+
156
+ selected = {}
157
+ (@selected_attributes + [:id]).uniq.each do |attr|
158
+ selected[attr] = attrs[attr] if attrs.key?(attr)
159
+ end
160
+ selected
161
+ end
162
+
163
+ def validate_search_response(response)
164
+ return unless response.dig("extensions", "search")
165
+
166
+ warnings = response["extensions"]["search"].flat_map { |s| s["warnings"] || [] }
167
+ return if warnings.empty?
168
+
169
+ messages = warnings.map { |w| "#{w['field']}: #{w['message']}" }
170
+ raise ArgumentError, "Shopify query validation failed: #{messages.join(', ')}"
171
+ end
172
+
173
+ def map_collection_response(response_data, collection_query_name)
174
+ nodes = response_data.dig("data", collection_query_name, "nodes")
175
+ return [] unless nodes&.any?
176
+
177
+ nodes.filter_map do |node_data|
178
+ single_response = { "data" => { query_name => node_data } }
179
+ map_response_to_attributes(single_response)
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ # Value object that encapsulates the shared context needed across query building,
5
+ # response mapping, and connection loading operations.
6
+ class LoaderContext
7
+ attr_reader :graphql_type, :loader_class, :defined_attributes, :model_class, :included_connections
8
+
9
+ def initialize(graphql_type:, loader_class:, defined_attributes:, model_class:, included_connections: [])
10
+ @graphql_type = graphql_type
11
+ @loader_class = loader_class
12
+ @defined_attributes = defined_attributes
13
+ @model_class = model_class
14
+ @included_connections = Array(included_connections)
15
+ end
16
+
17
+ # Create a new context with different included connections (for nested loading)
18
+ def with_connections(new_connections)
19
+ self.class.new(
20
+ graphql_type: graphql_type,
21
+ loader_class: loader_class,
22
+ defined_attributes: defined_attributes,
23
+ model_class: model_class,
24
+ included_connections: new_connections
25
+ )
26
+ end
27
+
28
+ # Create a new context for a different model (for connection targets)
29
+ def for_model(new_model_class, new_graphql_type: nil, new_attributes: nil, new_connections: [])
30
+ self.class.new(
31
+ graphql_type: new_graphql_type || infer_graphql_type(new_model_class),
32
+ loader_class: loader_class,
33
+ defined_attributes: new_attributes || infer_attributes(new_model_class),
34
+ model_class: new_model_class,
35
+ included_connections: new_connections
36
+ )
37
+ end
38
+
39
+ # Helper methods delegated from context
40
+ def query_name
41
+ graphql_type.downcase
42
+ end
43
+
44
+ def fragment_name
45
+ "#{graphql_type}Fragment"
46
+ end
47
+
48
+ def connections
49
+ return {} unless model_class
50
+
51
+ model_class.connections
52
+ end
53
+
54
+ def ==(other)
55
+ other.is_a?(LoaderContext) &&
56
+ graphql_type == other.graphql_type &&
57
+ loader_class == other.loader_class &&
58
+ defined_attributes == other.defined_attributes &&
59
+ model_class == other.model_class &&
60
+ included_connections == other.included_connections
61
+ end
62
+ alias eql? ==
63
+
64
+ def hash
65
+ [graphql_type, loader_class, defined_attributes, model_class, included_connections].hash
66
+ end
67
+
68
+ private
69
+
70
+ def infer_graphql_type(klass)
71
+ if klass.respond_to?(:graphql_type_for_loader)
72
+ klass.graphql_type_for_loader(loader_class)
73
+ elsif klass.respond_to?(:graphql_type)
74
+ klass.graphql_type
75
+ elsif klass.respond_to?(:name) && klass.name
76
+ klass.name.demodulize
77
+ else
78
+ raise ArgumentError, "Cannot infer graphql_type for #{klass}"
79
+ end
80
+ end
81
+
82
+ def infer_attributes(klass)
83
+ return klass.attributes_for_loader(loader_class) if klass.respond_to?(:attributes_for_loader)
84
+
85
+ {}
86
+ end
87
+ end
88
+ end