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.
- checksums.yaml +4 -4
- data/.rubocop.yml +4 -0
- data/README.md +187 -56
- data/lib/active_shopify_graphql/configuration.rb +2 -15
- data/lib/active_shopify_graphql/connections/connection_loader.rb +141 -0
- data/lib/active_shopify_graphql/gid_helper.rb +2 -0
- data/lib/active_shopify_graphql/graphql_associations.rb +245 -0
- data/lib/active_shopify_graphql/loader.rb +147 -126
- data/lib/active_shopify_graphql/loader_context.rb +2 -47
- data/lib/active_shopify_graphql/loader_proxy.rb +67 -0
- data/lib/active_shopify_graphql/loaders/admin_api_loader.rb +1 -17
- data/lib/active_shopify_graphql/loaders/customer_account_api_loader.rb +10 -26
- data/lib/active_shopify_graphql/model/associations.rb +94 -0
- data/lib/active_shopify_graphql/model/attributes.rb +48 -0
- data/lib/active_shopify_graphql/model/connections.rb +174 -0
- data/lib/active_shopify_graphql/model/finder_methods.rb +155 -0
- data/lib/active_shopify_graphql/model/graphql_type_resolver.rb +89 -0
- data/lib/active_shopify_graphql/model/loader_switchable.rb +79 -0
- data/lib/active_shopify_graphql/model/metafield_attributes.rb +59 -0
- data/lib/active_shopify_graphql/{base.rb → model.rb} +30 -16
- data/lib/active_shopify_graphql/model_builder.rb +53 -0
- data/lib/active_shopify_graphql/query/node/collection.rb +78 -0
- data/lib/active_shopify_graphql/query/node/connection.rb +16 -0
- data/lib/active_shopify_graphql/query/node/current_customer.rb +37 -0
- data/lib/active_shopify_graphql/query/node/field.rb +23 -0
- data/lib/active_shopify_graphql/query/node/fragment.rb +17 -0
- data/lib/active_shopify_graphql/query/node/nested_connection.rb +79 -0
- data/lib/active_shopify_graphql/query/node/raw.rb +15 -0
- data/lib/active_shopify_graphql/query/node/root_connection.rb +76 -0
- data/lib/active_shopify_graphql/query/node/single_record.rb +36 -0
- data/lib/active_shopify_graphql/query/node/singular.rb +16 -0
- data/lib/active_shopify_graphql/query/node.rb +95 -0
- data/lib/active_shopify_graphql/query/query_builder.rb +290 -0
- data/lib/active_shopify_graphql/query/relation.rb +424 -0
- data/lib/active_shopify_graphql/query/scope.rb +219 -0
- data/lib/active_shopify_graphql/response/page_info.rb +40 -0
- data/lib/active_shopify_graphql/response/paginated_result.rb +114 -0
- data/lib/active_shopify_graphql/response/response_mapper.rb +242 -0
- data/lib/active_shopify_graphql/search_query/hash_condition_formatter.rb +107 -0
- data/lib/active_shopify_graphql/search_query/parameter_binder.rb +68 -0
- data/lib/active_shopify_graphql/search_query/value_sanitizer.rb +24 -0
- data/lib/active_shopify_graphql/search_query.rb +34 -84
- data/lib/active_shopify_graphql/version.rb +1 -1
- data/lib/active_shopify_graphql.rb +30 -28
- metadata +47 -15
- data/lib/active_shopify_graphql/associations.rb +0 -90
- data/lib/active_shopify_graphql/attributes.rb +0 -50
- data/lib/active_shopify_graphql/connection_loader.rb +0 -96
- data/lib/active_shopify_graphql/connections.rb +0 -198
- data/lib/active_shopify_graphql/finder_methods.rb +0 -159
- data/lib/active_shopify_graphql/fragment_builder.rb +0 -228
- data/lib/active_shopify_graphql/graphql_type_resolver.rb +0 -91
- data/lib/active_shopify_graphql/includes_scope.rb +0 -48
- data/lib/active_shopify_graphql/loader_switchable.rb +0 -180
- data/lib/active_shopify_graphql/metafield_attributes.rb +0 -61
- data/lib/active_shopify_graphql/query_node.rb +0 -173
- data/lib/active_shopify_graphql/query_tree.rb +0 -225
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|