active_shopify_graphql 0.3.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -0
  3. data/README.md +187 -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/graphql_associations.rb +245 -0
  8. data/lib/active_shopify_graphql/loader.rb +147 -126
  9. data/lib/active_shopify_graphql/loader_context.rb +2 -47
  10. data/lib/active_shopify_graphql/loader_proxy.rb +67 -0
  11. data/lib/active_shopify_graphql/loaders/admin_api_loader.rb +1 -17
  12. data/lib/active_shopify_graphql/loaders/customer_account_api_loader.rb +10 -26
  13. data/lib/active_shopify_graphql/model/associations.rb +94 -0
  14. data/lib/active_shopify_graphql/model/attributes.rb +48 -0
  15. data/lib/active_shopify_graphql/model/connections.rb +174 -0
  16. data/lib/active_shopify_graphql/model/finder_methods.rb +155 -0
  17. data/lib/active_shopify_graphql/model/graphql_type_resolver.rb +89 -0
  18. data/lib/active_shopify_graphql/model/loader_switchable.rb +79 -0
  19. data/lib/active_shopify_graphql/model/metafield_attributes.rb +59 -0
  20. data/lib/active_shopify_graphql/{base.rb → model.rb} +30 -16
  21. data/lib/active_shopify_graphql/model_builder.rb +53 -0
  22. data/lib/active_shopify_graphql/query/node/collection.rb +78 -0
  23. data/lib/active_shopify_graphql/query/node/connection.rb +16 -0
  24. data/lib/active_shopify_graphql/query/node/current_customer.rb +37 -0
  25. data/lib/active_shopify_graphql/query/node/field.rb +23 -0
  26. data/lib/active_shopify_graphql/query/node/fragment.rb +17 -0
  27. data/lib/active_shopify_graphql/query/node/nested_connection.rb +79 -0
  28. data/lib/active_shopify_graphql/query/node/raw.rb +15 -0
  29. data/lib/active_shopify_graphql/query/node/root_connection.rb +76 -0
  30. data/lib/active_shopify_graphql/query/node/single_record.rb +36 -0
  31. data/lib/active_shopify_graphql/query/node/singular.rb +16 -0
  32. data/lib/active_shopify_graphql/query/node.rb +95 -0
  33. data/lib/active_shopify_graphql/query/query_builder.rb +290 -0
  34. data/lib/active_shopify_graphql/query/relation.rb +424 -0
  35. data/lib/active_shopify_graphql/query/scope.rb +219 -0
  36. data/lib/active_shopify_graphql/response/page_info.rb +40 -0
  37. data/lib/active_shopify_graphql/response/paginated_result.rb +114 -0
  38. data/lib/active_shopify_graphql/response/response_mapper.rb +242 -0
  39. data/lib/active_shopify_graphql/search_query/hash_condition_formatter.rb +107 -0
  40. data/lib/active_shopify_graphql/search_query/parameter_binder.rb +68 -0
  41. data/lib/active_shopify_graphql/search_query/value_sanitizer.rb +24 -0
  42. data/lib/active_shopify_graphql/search_query.rb +34 -84
  43. data/lib/active_shopify_graphql/version.rb +1 -1
  44. data/lib/active_shopify_graphql.rb +30 -28
  45. metadata +47 -15
  46. data/lib/active_shopify_graphql/associations.rb +0 -90
  47. data/lib/active_shopify_graphql/attributes.rb +0 -50
  48. data/lib/active_shopify_graphql/connection_loader.rb +0 -96
  49. data/lib/active_shopify_graphql/connections.rb +0 -198
  50. data/lib/active_shopify_graphql/finder_methods.rb +0 -159
  51. data/lib/active_shopify_graphql/fragment_builder.rb +0 -228
  52. data/lib/active_shopify_graphql/graphql_type_resolver.rb +0 -91
  53. data/lib/active_shopify_graphql/includes_scope.rb +0 -48
  54. data/lib/active_shopify_graphql/loader_switchable.rb +0 -180
  55. data/lib/active_shopify_graphql/metafield_attributes.rb +0 -61
  56. data/lib/active_shopify_graphql/query_node.rb +0 -173
  57. data/lib/active_shopify_graphql/query_tree.rb +0 -225
  58. data/lib/active_shopify_graphql/response_mapper.rb +0 -249
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Response
5
+ # Represents a page of results from a paginated GraphQL query.
6
+ # Lazily builds model instances from attribute hashes on access.
7
+ # Provides methods to navigate between pages and access pagination metadata.
8
+ #
9
+ # @example Manual pagination
10
+ # page = ProductVariant.where(sku: "*").in_pages(of: 10)
11
+ # page.has_next_page? # => true
12
+ # next_page = page.next_page
13
+ #
14
+ # @example Iteration with block
15
+ # ProductVariant.where(sku: "*").in_pages(of: 10) do |page|
16
+ # page.each { |variant| process(variant) }
17
+ # end
18
+ class PaginatedResult
19
+ include Enumerable
20
+
21
+ attr_reader :page_info, :query_scope
22
+
23
+ def initialize(attributes:, model_class:, page_info:, query_scope:)
24
+ @attributes = attributes
25
+ @model_class = model_class
26
+ @page_info = page_info
27
+ @query_scope = query_scope
28
+ @records = nil # Lazily built
29
+ end
30
+
31
+ # Get the records for this page (builds instances on first access)
32
+ def records
33
+ @records ||= ModelBuilder.build_many(@model_class, @attributes)
34
+ end
35
+
36
+ # Iterate over records in this page
37
+ def each(&block)
38
+ records.each(&block)
39
+ end
40
+
41
+ # Access records by index
42
+ def [](index)
43
+ records[index]
44
+ end
45
+
46
+ # Number of records in this page
47
+ def size
48
+ @attributes.size
49
+ end
50
+ alias length size
51
+
52
+ # Check if this page has records
53
+ def empty?
54
+ @attributes.empty?
55
+ end
56
+
57
+ # Check if there is a next page
58
+ def has_next_page?
59
+ @page_info.has_next_page?
60
+ end
61
+
62
+ # Check if there is a previous page
63
+ def has_previous_page?
64
+ @page_info.has_previous_page?
65
+ end
66
+
67
+ # Cursor pointing to the start of this page
68
+ def start_cursor
69
+ @page_info.start_cursor
70
+ end
71
+
72
+ # Cursor pointing to the end of this page
73
+ def end_cursor
74
+ @page_info.end_cursor
75
+ end
76
+
77
+ # Fetch the next page of results
78
+ # @return [PaginatedResult, nil] The next page or nil if no more pages
79
+ def next_page
80
+ return nil unless has_next_page?
81
+
82
+ @query_scope.fetch_page(after: end_cursor)
83
+ end
84
+
85
+ # Fetch the previous page of results
86
+ # @return [PaginatedResult, nil] The previous page or nil if no previous pages
87
+ def previous_page
88
+ return nil unless has_previous_page?
89
+
90
+ @query_scope.fetch_page(before: start_cursor)
91
+ end
92
+
93
+ # Convert to array (useful for compatibility)
94
+ def to_a
95
+ records.dup
96
+ end
97
+
98
+ # Return all records across all pages
99
+ # Warning: This will make multiple API calls if there are many pages
100
+ # @return [Array] All records from all pages
101
+ def all_records
102
+ all = records.dup
103
+ current = self
104
+
105
+ while current.has_next_page?
106
+ current = current.next_page
107
+ all.concat(current.records)
108
+ end
109
+
110
+ all
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Response
5
+ # Handles mapping GraphQL responses to model attributes.
6
+ # Refactored to use LoaderContext and unified mapping methods.
7
+ class ResponseMapper
8
+ attr_reader :context
9
+
10
+ def initialize(context)
11
+ @context = context
12
+ end
13
+
14
+ # Map GraphQL response to attributes using declared attribute metadata
15
+ # @param response_data [Hash] The full GraphQL response
16
+ # @param root_path [Array<String>] Path to the data root (e.g., ["data", "customer"])
17
+ # @return [Hash] Mapped attributes
18
+ def map_response(response_data, root_path: nil)
19
+ root_path ||= ["data", @context.query_name]
20
+ root_data = response_data.dig(*root_path)
21
+ return {} unless root_data
22
+
23
+ map_node_to_attributes(root_data)
24
+ end
25
+
26
+ # Map a single node's data to attributes (used for both root and nested)
27
+ def map_node_to_attributes(node_data)
28
+ return {} unless node_data
29
+
30
+ result = {}
31
+ @context.defined_attributes.each do |attr_name, config|
32
+ value = extract_and_transform_value(node_data, config, attr_name)
33
+ result[attr_name] = value
34
+ end
35
+ result
36
+ end
37
+
38
+ # Extract connection data from GraphQL response for eager loading
39
+ def extract_connection_data(response_data, root_path: nil, parent_instance: nil)
40
+ return {} if @context.included_connections.empty?
41
+
42
+ root_path ||= ["data", @context.query_name]
43
+ root_data = response_data.dig(*root_path)
44
+ return {} unless root_data
45
+
46
+ extract_connections_from_node(root_data, parent_instance)
47
+ end
48
+
49
+ # Extract connections from a node (reusable for nested connections)
50
+ def extract_connections_from_node(node_data, parent_instance = nil)
51
+ return {} if @context.included_connections.empty?
52
+
53
+ connections = @context.connections
54
+ return {} if connections.empty?
55
+
56
+ normalized_includes = Query::QueryBuilder.normalize_includes(@context.included_connections)
57
+ connection_cache = {}
58
+
59
+ normalized_includes.each do |connection_name, nested_includes|
60
+ connection_config = connections[connection_name]
61
+ next unless connection_config
62
+
63
+ records = extract_connection_records(node_data, connection_config, nested_includes, parent_instance: parent_instance)
64
+ connection_cache[connection_name] = records if records
65
+ end
66
+
67
+ connection_cache
68
+ end
69
+
70
+ # Map nested connection response (when loading via parent query)
71
+ # Returns attributes instead of instances
72
+ def map_nested_connection_response(response_data, connection_field_name, parent, connection_config = nil)
73
+ parent_type = parent.class.graphql_type_for_loader(@context.loader_class)
74
+ parent_query_name = parent_type.camelize(:lower)
75
+ connection_type = connection_config&.dig(:type) || :connection
76
+
77
+ if connection_type == :singular
78
+ node_data = response_data.dig("data", parent_query_name, connection_field_name)
79
+ return nil unless node_data
80
+
81
+ map_node_to_attributes(node_data)
82
+ else
83
+ nodes = response_data.dig("data", parent_query_name, connection_field_name, "nodes")
84
+ return [] unless nodes
85
+
86
+ nodes.filter_map do |node_data|
87
+ map_node_to_attributes(node_data) if node_data
88
+ end
89
+ end
90
+ end
91
+
92
+ # Map root connection response
93
+ # Returns attributes instead of instances
94
+ def map_connection_response(response_data, query_name, connection_config = nil)
95
+ connection_type = connection_config&.dig(:type) || :connection
96
+
97
+ if connection_type == :singular
98
+ node_data = response_data.dig("data", query_name)
99
+ return nil unless node_data
100
+
101
+ map_node_to_attributes(node_data)
102
+ else
103
+ nodes = response_data.dig("data", query_name, "nodes")
104
+ return [] unless nodes
105
+
106
+ nodes.filter_map do |node_data|
107
+ map_node_to_attributes(node_data) if node_data
108
+ end
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ def extract_and_transform_value(node_data, config, attr_name)
115
+ path = config[:path]
116
+
117
+ value = if config[:raw_graphql]
118
+ # For raw_graphql, the alias is the attr_name, then dig using path if nested
119
+ raw_data = node_data[attr_name.to_s]
120
+ if path.include?('.')
121
+ # Path is relative to the aliased root
122
+ path_parts = path.split('.')[1..] # Skip the first part (attr_name itself)
123
+ path_parts.any? ? raw_data&.dig(*path_parts) : raw_data
124
+ else
125
+ raw_data
126
+ end
127
+ elsif path.include?('.')
128
+ # Nested path - dig using the full path
129
+ path_parts = path.split('.')
130
+ node_data.dig(*path_parts)
131
+ else
132
+ # Simple path - use attr_name as key (matches the alias in the query)
133
+ node_data[attr_name.to_s]
134
+ end
135
+
136
+ value = apply_defaults_and_transforms(value, config)
137
+ validate_null_constraint!(value, config, attr_name)
138
+ coerce_value(value, config[:type])
139
+ end
140
+
141
+ def apply_defaults_and_transforms(value, config)
142
+ if value.nil?
143
+ return config[:default] unless config[:default].nil?
144
+
145
+ return config[:transform]&.call(value)
146
+ end
147
+
148
+ config[:transform] ? config[:transform].call(value) : value
149
+ end
150
+
151
+ def validate_null_constraint!(value, config, attr_name)
152
+ return unless !config[:null] && value.nil?
153
+
154
+ raise ArgumentError, "Attribute '#{attr_name}' (GraphQL path: '#{config[:path]}') cannot be null but received nil"
155
+ end
156
+
157
+ def coerce_value(value, type)
158
+ return nil if value.nil?
159
+ return value if value.is_a?(Array) # Preserve arrays
160
+
161
+ type_caster(type).cast(value)
162
+ end
163
+
164
+ def type_caster(type)
165
+ case type
166
+ when :string then ActiveModel::Type::String.new
167
+ when :integer then ActiveModel::Type::Integer.new
168
+ when :float then ActiveModel::Type::Float.new
169
+ when :boolean then ActiveModel::Type::Boolean.new
170
+ when :datetime then ActiveModel::Type::DateTime.new
171
+ else ActiveModel::Type::Value.new
172
+ end
173
+ end
174
+
175
+ def extract_connection_records(node_data, connection_config, nested_includes, parent_instance: nil)
176
+ # Use original_name (Ruby attr name) as the response key since we alias connections
177
+ response_key = connection_config[:original_name].to_s
178
+ connection_type = connection_config[:type] || :connection
179
+ target_class = connection_config[:class_name].constantize
180
+
181
+ if connection_type == :singular
182
+ item_data = node_data[response_key]
183
+ return nil unless item_data
184
+
185
+ build_nested_model_instance(item_data, target_class, nested_includes,
186
+ parent_instance: parent_instance,
187
+ connection_config: connection_config)
188
+ else
189
+ nodes = node_data.dig(response_key, "nodes")
190
+ return nil unless nodes
191
+
192
+ nodes.filter_map do |item_data|
193
+ if item_data
194
+ build_nested_model_instance(item_data, target_class, nested_includes,
195
+ parent_instance: parent_instance,
196
+ connection_config: connection_config)
197
+ end
198
+ end
199
+ end
200
+ end
201
+
202
+ # Build a nested model instance with inverse_of wiring
203
+ # Used for eager-loaded connections that go into the connection cache
204
+ def build_nested_model_instance(node_data, target_class, nested_includes, parent_instance: nil, connection_config: nil)
205
+ nested_context = @context.for_model(target_class, new_connections: nested_includes)
206
+ nested_mapper = ResponseMapper.new(nested_context)
207
+
208
+ attributes = nested_mapper.map_node_to_attributes(node_data)
209
+ instance = target_class.new(attributes)
210
+
211
+ # Populate inverse cache if inverse_of is specified
212
+ if parent_instance && connection_config && connection_config[:inverse_of]
213
+ inverse_name = connection_config[:inverse_of]
214
+ instance.instance_variable_set(:@_connection_cache, {}) unless instance.instance_variable_get(:@_connection_cache)
215
+ cache = instance.instance_variable_get(:@_connection_cache)
216
+
217
+ # Check the type of the inverse connection to determine how to cache
218
+ if target_class.respond_to?(:connections) && target_class.connections[inverse_name]
219
+ inverse_type = target_class.connections[inverse_name][:type]
220
+ cache[inverse_name] =
221
+ if inverse_type == :singular
222
+ parent_instance
223
+ else
224
+ # For collection inverses, wrap parent in an array
225
+ [parent_instance]
226
+ end
227
+ end
228
+ end
229
+
230
+ # Handle nested connections recursively (instance becomes parent for its children)
231
+ if nested_includes.any?
232
+ nested_data = nested_mapper.extract_connections_from_node(node_data, instance)
233
+ nested_data.each do |nested_name, nested_records|
234
+ instance.send("#{nested_name}=", nested_records)
235
+ end
236
+ end
237
+
238
+ instance
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "value_sanitizer"
4
+
5
+ module ActiveShopifyGraphQL
6
+ class SearchQuery
7
+ # Formats hash-based query conditions with proper sanitization
8
+ class HashConditionFormatter
9
+ # Formats hash conditions into a Shopify search query string
10
+ # @param conditions [Hash] The conditions to format
11
+ # @return [String] The formatted query string
12
+ def self.format(conditions)
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
+ # Formats a single query condition
23
+ # @param key [String] The attribute name
24
+ # @param value [Object] The attribute value
25
+ # @return [String] The formatted query condition
26
+ def self.format_condition(key, value)
27
+ case value
28
+ when Array
29
+ format_array_condition(key, 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 an array condition with OR clauses
42
+ # @param key [String] The attribute name
43
+ # @param values [Array] The array of values
44
+ # @return [String] The formatted query with OR clauses wrapped in parentheses
45
+ def self.format_array_condition(key, values)
46
+ return "" if values.empty?
47
+ return format_condition(key, values.first) if values.size == 1
48
+
49
+ or_parts = values.map do |value|
50
+ format_single_value(key, value)
51
+ end
52
+
53
+ "(#{or_parts.join(' OR ')})"
54
+ end
55
+
56
+ # Formats a single value for use in array OR clauses
57
+ # @param key [String] The attribute name
58
+ # @param value [Object] The attribute value
59
+ # @return [String] The formatted key:value pair
60
+ def self.format_single_value(key, value)
61
+ case value
62
+ when String
63
+ format_string_condition(key, value)
64
+ when Numeric, true, false
65
+ "#{key}:#{value}"
66
+ else
67
+ "#{key}:#{value}"
68
+ end
69
+ end
70
+
71
+ # Formats a string condition with proper quoting and sanitization
72
+ # @param key [String] The attribute name
73
+ # @param value [String] The string value
74
+ # @return [String] The formatted condition
75
+ def self.format_string_condition(key, value)
76
+ escaped_value = ValueSanitizer.sanitize(value)
77
+ "#{key}:'#{escaped_value}'"
78
+ end
79
+
80
+ # Formats a range condition (e.g., { created_at: { gte: '2024-01-01' } })
81
+ # @param key [String] The attribute name
82
+ # @param value [Hash] The range conditions
83
+ # @return [String] The formatted range condition
84
+ def self.format_range_condition(key, value)
85
+ range_parts = value.map do |operator, range_value|
86
+ case operator.to_sym
87
+ when :gt, :>
88
+ "#{key}:>#{range_value}"
89
+ when :gte, :>=
90
+ "#{key}:>=#{range_value}"
91
+ when :lt, :<
92
+ "#{key}:<#{range_value}"
93
+ when :lte, :<=
94
+ "#{key}:<=#{range_value}"
95
+ else
96
+ raise ArgumentError, "Unsupported range operator: #{operator}"
97
+ end
98
+ end
99
+ range_parts.join(" ")
100
+ end
101
+
102
+ private_class_method :format_condition, :format_array_condition,
103
+ :format_single_value, :format_string_condition,
104
+ :format_range_condition
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "value_sanitizer"
4
+
5
+ module ActiveShopifyGraphQL
6
+ class SearchQuery
7
+ # Binds parameters to string queries with proper sanitization
8
+ # Supports both positional (?) and named (:param) placeholders
9
+ class ParameterBinder
10
+ # Binds parameters to a query string
11
+ # @param query_string [String] The query with placeholders
12
+ # @param args [Array] Positional arguments or a hash of named parameters
13
+ # @return [String] The query with bound and escaped parameters
14
+ def self.bind(query_string, *args)
15
+ return query_string if args.empty?
16
+
17
+ if args.first.is_a?(Hash)
18
+ bind_named_parameters(query_string, args.first)
19
+ else
20
+ bind_positional_parameters(query_string, args)
21
+ end
22
+ end
23
+
24
+ # Binds positional parameters (?)
25
+ # @param query_string [String] The query with ? placeholders
26
+ # @param values [Array] The values to bind
27
+ # @return [String] The query with bound parameters
28
+ def self.bind_positional_parameters(query_string, values)
29
+ result = query_string.dup
30
+ values.each do |value|
31
+ result = result.sub("?", format_value(value))
32
+ end
33
+ result
34
+ end
35
+
36
+ # Binds named parameters (:name)
37
+ # @param query_string [String] The query with :name placeholders
38
+ # @param params [Hash] The parameters hash
39
+ # @return [String] The query with bound parameters
40
+ def self.bind_named_parameters(query_string, params)
41
+ result = query_string.dup
42
+ params.each do |key, value|
43
+ placeholder = ":#{key}"
44
+ result = result.gsub(placeholder, format_value(value))
45
+ end
46
+ result
47
+ end
48
+
49
+ # Formats a value for safe insertion into query
50
+ # @param value [Object] The value to format
51
+ # @return [String] The formatted value
52
+ def self.format_value(value)
53
+ case value
54
+ when String
55
+ "'#{ValueSanitizer.sanitize(value)}'"
56
+ when Numeric, true, false
57
+ value.to_s
58
+ when nil
59
+ "null"
60
+ else
61
+ "'#{ValueSanitizer.sanitize(value.to_s)}'"
62
+ end
63
+ end
64
+
65
+ private_class_method :bind_positional_parameters, :bind_named_parameters, :format_value
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ class SearchQuery
5
+ # Sanitizes values by escaping special characters for Shopify search syntax
6
+ class ValueSanitizer
7
+ # Sanitizes a value by escaping special characters
8
+ # @param value [String] The value to sanitize
9
+ # @return [String] The sanitized value
10
+ def self.sanitize(value)
11
+ value
12
+ .gsub('\\', '\\\\\\\\') # Escape backslashes first: \ becomes \\
13
+ .gsub('"', '\\"') # Escape double quotes with a single backslash
14
+ # Escape single quotes: O'Reilly becomes O\\'Reilly
15
+ # Why 4 backslashes? The escaping happens in layers:
16
+ # 1. Ruby string literal: "\\\\\\\\''" = literal string "\\\\''"
17
+ # 2. String interpolation in "#{key}:'#{escaped_value}'": the \\\' becomes \\'
18
+ # 3. Final GraphQL query: customers(query: "title:'O\\'Reilly'")
19
+ # The double backslash is required by Shopify's search syntax to properly escape the single quote
20
+ .gsub("'", "\\\\\\\\'")
21
+ end
22
+ end
23
+ end
24
+ end
@@ -1,103 +1,53 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "search_query/hash_condition_formatter"
4
+ require_relative "search_query/parameter_binder"
5
+
3
6
  module ActiveShopifyGraphQL
4
7
  # Represents a Shopify search query, converting Ruby conditions into Shopify's search syntax
8
+ # Supports hash-based conditions (with sanitization), string-based conditions (raw), and parameter binding
9
+ #
10
+ # @example Hash-based query (safe, with sanitization)
11
+ # SearchQuery.new(sku: "ABC-123").to_s
12
+ # # => "sku:'ABC-123'"
13
+ #
14
+ # @example String-based query (raw, user responsibility for safety)
15
+ # SearchQuery.new("sku:* AND product_id:123").to_s
16
+ # # => "sku:* AND product_id:123"
17
+ #
18
+ # @example String with positional parameter binding
19
+ # SearchQuery.new("sku:? product_id:?", "Good ol' value", 123).to_s
20
+ # # => "sku:'Good ol\\' value' product_id:123"
21
+ #
22
+ # @example String with named parameter binding
23
+ # SearchQuery.new("sku::sku product_id::product_id", { sku: "A-SKU", product_id: 123 }).to_s
24
+ # # => "sku:'A-SKU' product_id:123"
5
25
  class SearchQuery
6
- def initialize(conditions = {})
26
+ def initialize(conditions = {}, *args)
7
27
  @conditions = conditions
28
+ @args = args
8
29
  end
9
30
 
10
31
  # Converts conditions to Shopify search query string
11
32
  # @return [String] The Shopify query string
12
33
  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 Array
31
- format_array_condition(key, value)
32
- when String
33
- format_string_condition(key, value)
34
- when Numeric, true, false
35
- "#{key}:#{value}"
34
+ case @conditions
36
35
  when Hash
37
- format_range_condition(key, value)
38
- else
39
- "#{key}:#{value}"
40
- end
41
- end
42
-
43
- # Formats an array condition with OR clauses
44
- # @param key [String] The attribute name
45
- # @param values [Array] The array of values
46
- # @return [String] The formatted query with OR clauses wrapped in parentheses
47
- def format_array_condition(key, values)
48
- return "" if values.empty?
49
- return format_condition(key, values.first) if values.size == 1
50
-
51
- or_parts = values.map do |value|
52
- format_single_value(key, value)
53
- end
54
-
55
- "(#{or_parts.join(' OR ')})"
56
- end
57
-
58
- # Formats a single value for use in array OR clauses
59
- # @param key [String] The attribute name
60
- # @param value [Object] The attribute value
61
- # @return [String] The formatted key:value pair
62
- def format_single_value(key, value)
63
- case value
36
+ HashConditionFormatter.format(@conditions)
64
37
  when String
65
- format_string_condition(key, value)
66
- when Numeric, true, false
67
- "#{key}:#{value}"
68
- else
69
- "#{key}:#{value}"
70
- end
71
- end
72
-
73
- # Formats a string condition with proper quoting
74
- def format_string_condition(key, value)
75
- # Handle special string values and escape quotes
76
- if value.include?(" ") && !value.start_with?('"')
77
- # Multi-word values should be quoted
78
- "#{key}:\"#{value.gsub('"', '\\"')}\""
79
- else
80
- "#{key}:#{value}"
81
- end
82
- end
83
-
84
- # Formats a range condition (e.g., { created_at: { gte: '2024-01-01' } })
85
- def format_range_condition(key, value)
86
- range_parts = value.map do |operator, range_value|
87
- case operator.to_sym
88
- when :gt, :>
89
- "#{key}:>#{range_value}"
90
- when :gte, :>=
91
- "#{key}:>=#{range_value}"
92
- when :lt, :<
93
- "#{key}:<#{range_value}"
94
- when :lte, :<=
95
- "#{key}:<=#{range_value}"
38
+ if @args.empty?
39
+ @conditions
96
40
  else
97
- raise ArgumentError, "Unsupported range operator: #{operator}"
41
+ ParameterBinder.bind(@conditions, *@args)
98
42
  end
43
+ when Array
44
+ # Handle [query_string, *binding_args] format from FinderMethods#where
45
+ query_string = @conditions.first
46
+ binding_args = @conditions[1..]
47
+ ParameterBinder.bind(query_string, *binding_args)
48
+ else
49
+ ""
99
50
  end
100
- range_parts.join(" ")
101
51
  end
102
52
  end
103
53
  end