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,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ # Handles mapping GraphQL responses to model attributes.
5
+ # Refactored to use LoaderContext and unified mapping methods.
6
+ class ResponseMapper
7
+ attr_reader :context
8
+
9
+ def initialize(context)
10
+ @context = context
11
+ end
12
+
13
+ # Map GraphQL response to attributes using declared attribute metadata
14
+ # @param response_data [Hash] The full GraphQL response
15
+ # @param root_path [Array<String>] Path to the data root (e.g., ["data", "customer"])
16
+ # @return [Hash] Mapped attributes
17
+ def map_response(response_data, root_path: nil)
18
+ root_path ||= ["data", @context.query_name]
19
+ root_data = response_data.dig(*root_path)
20
+ return {} unless root_data
21
+
22
+ map_node_to_attributes(root_data)
23
+ end
24
+
25
+ # Map a single node's data to attributes (used for both root and nested)
26
+ def map_node_to_attributes(node_data)
27
+ return {} unless node_data
28
+
29
+ result = {}
30
+ @context.defined_attributes.each do |attr_name, config|
31
+ value = extract_and_transform_value(node_data, config, attr_name)
32
+ result[attr_name] = value
33
+ end
34
+ result
35
+ end
36
+
37
+ # Extract connection data from GraphQL response for eager loading
38
+ def extract_connection_data(response_data, root_path: nil)
39
+ return {} if @context.included_connections.empty?
40
+
41
+ root_path ||= ["data", @context.query_name]
42
+ root_data = response_data.dig(*root_path)
43
+ return {} unless root_data
44
+
45
+ extract_connections_from_node(root_data)
46
+ end
47
+
48
+ # Extract connections from a node (reusable for nested connections)
49
+ def extract_connections_from_node(node_data)
50
+ return {} if @context.included_connections.empty?
51
+
52
+ connections = @context.connections
53
+ return {} if connections.empty?
54
+
55
+ normalized_includes = FragmentBuilder.normalize_includes(@context.included_connections)
56
+ connection_cache = {}
57
+
58
+ normalized_includes.each do |connection_name, nested_includes|
59
+ connection_config = connections[connection_name]
60
+ next unless connection_config
61
+
62
+ records = extract_connection_records(node_data, connection_config, nested_includes)
63
+ connection_cache[connection_name] = records if records
64
+ end
65
+
66
+ connection_cache
67
+ end
68
+
69
+ # Map nested connection response (when loading via parent query)
70
+ def map_nested_connection_response(response_data, connection_field_name, parent, connection_config = nil)
71
+ parent_type = parent.class.graphql_type_for_loader(@context.loader_class)
72
+ parent_query_name = parent_type.downcase
73
+ connection_type = connection_config&.dig(:type) || :connection
74
+
75
+ if connection_type == :singular
76
+ node_data = response_data.dig("data", parent_query_name, connection_field_name)
77
+ return nil unless node_data
78
+
79
+ build_model_instance(node_data)
80
+ else
81
+ edges = response_data.dig("data", parent_query_name, connection_field_name, "edges")
82
+ return [] unless edges
83
+
84
+ edges.filter_map do |edge|
85
+ node_data = edge["node"]
86
+ build_model_instance(node_data) if node_data
87
+ end
88
+ end
89
+ end
90
+
91
+ # Map root connection response
92
+ def map_connection_response(response_data, query_name, connection_config = nil)
93
+ connection_type = connection_config&.dig(:type) || :connection
94
+
95
+ if connection_type == :singular
96
+ node_data = response_data.dig("data", query_name)
97
+ return nil unless node_data
98
+
99
+ build_model_instance(node_data)
100
+ else
101
+ edges = response_data.dig("data", query_name, "edges")
102
+ return [] unless edges
103
+
104
+ edges.filter_map do |edge|
105
+ node_data = edge["node"]
106
+ build_model_instance(node_data) if node_data
107
+ end
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ def extract_and_transform_value(node_data, config, attr_name)
114
+ path_parts = config[:path].split('.')
115
+ value = node_data.dig(*path_parts)
116
+
117
+ value = apply_defaults_and_transforms(value, config)
118
+ validate_null_constraint!(value, config, attr_name)
119
+ coerce_value(value, config[:type])
120
+ end
121
+
122
+ def apply_defaults_and_transforms(value, config)
123
+ if value.nil?
124
+ return config[:default] unless config[:default].nil?
125
+
126
+ return config[:transform]&.call(value)
127
+ end
128
+
129
+ config[:transform] ? config[:transform].call(value) : value
130
+ end
131
+
132
+ def validate_null_constraint!(value, config, attr_name)
133
+ return unless !config[:null] && value.nil?
134
+
135
+ raise ArgumentError, "Attribute '#{attr_name}' (GraphQL path: '#{config[:path]}') cannot be null but received nil"
136
+ end
137
+
138
+ def coerce_value(value, type)
139
+ return nil if value.nil?
140
+ return value if value.is_a?(Array) # Preserve arrays
141
+
142
+ type_caster(type).cast(value)
143
+ end
144
+
145
+ def type_caster(type)
146
+ case type
147
+ when :string then ActiveModel::Type::String.new
148
+ when :integer then ActiveModel::Type::Integer.new
149
+ when :float then ActiveModel::Type::Float.new
150
+ when :boolean then ActiveModel::Type::Boolean.new
151
+ when :datetime then ActiveModel::Type::DateTime.new
152
+ else ActiveModel::Type::Value.new
153
+ end
154
+ end
155
+
156
+ def extract_connection_records(node_data, connection_config, nested_includes)
157
+ query_name = connection_config[:query_name]
158
+ connection_type = connection_config[:type] || :connection
159
+ target_class = connection_config[:class_name].constantize
160
+
161
+ if connection_type == :singular
162
+ item_data = node_data[query_name]
163
+ return nil unless item_data
164
+
165
+ build_nested_model_instance(item_data, target_class, nested_includes)
166
+ else
167
+ edges = node_data.dig(query_name, "edges")
168
+ return nil unless edges
169
+
170
+ edges.filter_map do |edge|
171
+ item_data = edge["node"]
172
+ build_nested_model_instance(item_data, target_class, nested_includes) if item_data
173
+ end
174
+ end
175
+ end
176
+
177
+ def build_model_instance(node_data)
178
+ return nil unless node_data
179
+
180
+ attributes = map_node_to_attributes(node_data)
181
+ @context.model_class.new(attributes)
182
+ end
183
+
184
+ def build_nested_model_instance(node_data, target_class, nested_includes)
185
+ nested_context = @context.for_model(target_class, new_connections: nested_includes)
186
+ nested_mapper = ResponseMapper.new(nested_context)
187
+
188
+ attributes = nested_mapper.map_node_to_attributes(node_data)
189
+ instance = target_class.new(attributes)
190
+
191
+ # Handle nested connections recursively
192
+ if nested_includes.any?
193
+ nested_data = nested_mapper.extract_connections_from_node(node_data)
194
+ nested_data.each do |nested_name, nested_records|
195
+ instance.send("#{nested_name}=", nested_records)
196
+ end
197
+ end
198
+
199
+ instance
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ # Represents a Shopify search query, converting Ruby conditions into Shopify's search syntax
5
+ class SearchQuery
6
+ def initialize(conditions = {})
7
+ @conditions = conditions
8
+ end
9
+
10
+ # Converts conditions to Shopify search query string
11
+ # @return [String] The Shopify query string
12
+ def to_s
13
+ return "" if @conditions.empty?
14
+
15
+ query_parts = @conditions.map do |key, value|
16
+ format_condition(key.to_s, value)
17
+ end
18
+
19
+ query_parts.join(" AND ")
20
+ end
21
+
22
+ private
23
+
24
+ # Formats a single query condition into Shopify's query syntax
25
+ # @param key [String] The attribute name
26
+ # @param value [Object] The attribute value
27
+ # @return [String] The formatted query condition
28
+ def format_condition(key, value)
29
+ case value
30
+ when String
31
+ format_string_condition(key, value)
32
+ when Numeric, true, false
33
+ "#{key}:#{value}"
34
+ when Hash
35
+ format_range_condition(key, value)
36
+ else
37
+ "#{key}:#{value}"
38
+ end
39
+ end
40
+
41
+ # Formats a string condition with proper quoting
42
+ def format_string_condition(key, value)
43
+ # Handle special string values and escape quotes
44
+ if value.include?(" ") && !value.start_with?('"')
45
+ # Multi-word values should be quoted
46
+ "#{key}:\"#{value.gsub('"', '\\"')}\""
47
+ else
48
+ "#{key}:#{value}"
49
+ end
50
+ end
51
+
52
+ # Formats a range condition (e.g., { created_at: { gte: '2024-01-01' } })
53
+ def format_range_condition(key, value)
54
+ range_parts = value.map do |operator, range_value|
55
+ case operator.to_sym
56
+ when :gt, :>
57
+ "#{key}:>#{range_value}"
58
+ when :gte, :>=
59
+ "#{key}:>=#{range_value}"
60
+ when :lt, :<
61
+ "#{key}:<#{range_value}"
62
+ when :lte, :<=
63
+ "#{key}:<=#{range_value}"
64
+ else
65
+ raise ArgumentError, "Unsupported range operator: #{operator}"
66
+ end
67
+ end
68
+ range_parts.join(" ")
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_support/inflector'
5
+ require 'active_support/concern'
6
+ require 'active_support/core_ext/object/blank'
7
+ require 'active_model'
8
+ require 'globalid'
9
+
10
+ require_relative "active_shopify_graphql/version"
11
+ require_relative "active_shopify_graphql/configuration"
12
+ require_relative "active_shopify_graphql/gid_helper"
13
+ require_relative "active_shopify_graphql/graphql_type_resolver"
14
+ require_relative "active_shopify_graphql/loader_context"
15
+ require_relative "active_shopify_graphql/query_node"
16
+ require_relative "active_shopify_graphql/fragment_builder"
17
+ require_relative "active_shopify_graphql/query_tree"
18
+ require_relative "active_shopify_graphql/response_mapper"
19
+ require_relative "active_shopify_graphql/connection_loader"
20
+ require_relative "active_shopify_graphql/loader"
21
+ require_relative "active_shopify_graphql/loaders/admin_api_loader"
22
+ require_relative "active_shopify_graphql/loaders/customer_account_api_loader"
23
+ require_relative "active_shopify_graphql/loader_switchable"
24
+ require_relative "active_shopify_graphql/finder_methods"
25
+ require_relative "active_shopify_graphql/associations"
26
+ require_relative "active_shopify_graphql/connections"
27
+ require_relative "active_shopify_graphql/attributes"
28
+ require_relative "active_shopify_graphql/metafield_attributes"
29
+ require_relative "active_shopify_graphql/search_query"
30
+ require_relative "active_shopify_graphql/base"
31
+
32
+ module ActiveShopifyGraphQL
33
+ class Error < StandardError; end
34
+ end
metadata ADDED
@@ -0,0 +1,147 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: active_shopify_graphql
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nicolò Rebughini
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date:
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activemodel
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '7.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '7.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '7.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: globalid
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.3'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.3'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.0'
83
+ description: ActiveShopifyGraphQL provides an ActiveRecord-like interface for interacting
84
+ with Shopify's GraphQL APIs, supporting both Admin API and Customer Account API
85
+ with automatic query building and response mapping.
86
+ email:
87
+ - nicolorebughini@nebulab.com
88
+ executables: []
89
+ extensions: []
90
+ extra_rdoc_files: []
91
+ files:
92
+ - ".github/workflows/lint.yml"
93
+ - ".github/workflows/test.yml"
94
+ - ".rubocop.yml"
95
+ - AGENTS.md
96
+ - LICENSE.txt
97
+ - README.md
98
+ - Rakefile
99
+ - lib/active_shopify_graphql.rb
100
+ - lib/active_shopify_graphql/associations.rb
101
+ - lib/active_shopify_graphql/attributes.rb
102
+ - lib/active_shopify_graphql/base.rb
103
+ - lib/active_shopify_graphql/configuration.rb
104
+ - lib/active_shopify_graphql/connection_loader.rb
105
+ - lib/active_shopify_graphql/connections.rb
106
+ - lib/active_shopify_graphql/connections/connection_proxy.rb
107
+ - lib/active_shopify_graphql/finder_methods.rb
108
+ - lib/active_shopify_graphql/fragment_builder.rb
109
+ - lib/active_shopify_graphql/gid_helper.rb
110
+ - lib/active_shopify_graphql/graphql_type_resolver.rb
111
+ - lib/active_shopify_graphql/loader.rb
112
+ - lib/active_shopify_graphql/loader_context.rb
113
+ - lib/active_shopify_graphql/loader_switchable.rb
114
+ - lib/active_shopify_graphql/loaders/admin_api_loader.rb
115
+ - lib/active_shopify_graphql/loaders/customer_account_api_loader.rb
116
+ - lib/active_shopify_graphql/metafield_attributes.rb
117
+ - lib/active_shopify_graphql/query_node.rb
118
+ - lib/active_shopify_graphql/query_tree.rb
119
+ - lib/active_shopify_graphql/response_mapper.rb
120
+ - lib/active_shopify_graphql/search_query.rb
121
+ - lib/active_shopify_graphql/version.rb
122
+ homepage: https://github.com/nebulab/active_shopify_graphql
123
+ licenses:
124
+ - MIT
125
+ metadata:
126
+ homepage_uri: https://github.com/nebulab/active_shopify_graphql
127
+ source_code_uri: https://github.com/nebulab/active_shopify_graphql
128
+ post_install_message:
129
+ rdoc_options: []
130
+ require_paths:
131
+ - lib
132
+ required_ruby_version: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - ">="
135
+ - !ruby/object:Gem::Version
136
+ version: 3.2.0
137
+ required_rubygems_version: !ruby/object:Gem::Requirement
138
+ requirements:
139
+ - - ">="
140
+ - !ruby/object:Gem::Version
141
+ version: '0'
142
+ requirements: []
143
+ rubygems_version: 3.5.11
144
+ signing_key:
145
+ specification_version: 4
146
+ summary: An ActiveRecord-like interface for Shopify GraphQL APIs
147
+ test_files: []