active_shopify_graphql 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +3 -0
- data/README.md +158 -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/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 +29 -29
- metadata +46 -15
- data/lib/active_shopify_graphql/associations.rb +0 -94
- 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 -182
- 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,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL::Model::Attributes
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
6
|
+
class_methods do
|
|
7
|
+
# Define an attribute with automatic GraphQL path inference and type coercion.
|
|
8
|
+
#
|
|
9
|
+
# @param name [Symbol] The Ruby attribute name
|
|
10
|
+
# @param path [String] The GraphQL field path (auto-inferred if not provided)
|
|
11
|
+
# @param type [Symbol] The type for coercion (:string, :integer, :float, :boolean, :datetime)
|
|
12
|
+
# @param null [Boolean] Whether the attribute can be null (default: true)
|
|
13
|
+
# @param default [Object] Default value when GraphQL response is nil
|
|
14
|
+
# @param transform [Proc] Custom transform block for the value
|
|
15
|
+
# @param raw_graphql [String] Raw GraphQL string to inject directly (escape hatch for unsupported features)
|
|
16
|
+
def attribute(name, path: nil, type: :string, null: true, default: nil, transform: nil, raw_graphql: nil)
|
|
17
|
+
path ||= infer_path(name)
|
|
18
|
+
config = { path: path, type: type, null: null, default: default, transform: transform, raw_graphql: raw_graphql }
|
|
19
|
+
|
|
20
|
+
if @current_loader_context
|
|
21
|
+
# Store in loader-specific context
|
|
22
|
+
@loader_contexts[@current_loader_context][name] = config
|
|
23
|
+
else
|
|
24
|
+
# Store in base attributes
|
|
25
|
+
@base_attributes ||= {}
|
|
26
|
+
@base_attributes[name] = config
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Always create attr_accessor
|
|
30
|
+
attr_accessor name unless method_defined?(name) || method_defined?("#{name}=")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Get attributes for a specific loader class, merging base with loader-specific overrides.
|
|
34
|
+
def attributes_for_loader(loader_class)
|
|
35
|
+
base = @base_attributes || {}
|
|
36
|
+
overrides = @loader_contexts&.dig(loader_class) || {}
|
|
37
|
+
|
|
38
|
+
base.merge(overrides) { |_key, base_val, override_val| base_val.merge(override_val) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# Infer GraphQL path from Ruby attribute name (snake_case -> camelCase)
|
|
44
|
+
def infer_path(name)
|
|
45
|
+
name.to_s.gsub(/_([a-z])/) { ::Regexp.last_match(1).upcase }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL::Model::Connections
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
6
|
+
included do
|
|
7
|
+
class << self
|
|
8
|
+
def connections
|
|
9
|
+
@connections ||= {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
attr_writer :connections
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class_methods do
|
|
17
|
+
# Define a singular connection (returns a single object)
|
|
18
|
+
# @see #connection
|
|
19
|
+
def has_one_connected(name, inverse_of: nil, **options)
|
|
20
|
+
connection(name, type: :singular, inverse_of: inverse_of, **options)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Define a plural connection (returns a collection via nodes)
|
|
24
|
+
# @see #connection
|
|
25
|
+
def has_many_connected(name, inverse_of: nil, **options)
|
|
26
|
+
connection(name, type: :connection, inverse_of: inverse_of, **options)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Define a GraphQL connection to another ActiveShopifyGraphQL model
|
|
30
|
+
# @param name [Symbol] The connection name (e.g., :orders)
|
|
31
|
+
# @param class_name [String] The target model class name (defaults to name.to_s.classify)
|
|
32
|
+
# @param query_name [String] The GraphQL query field name (auto-determined based on nested/root-level)
|
|
33
|
+
# @param foreign_key [String] The field to filter by (auto-determined for root-level queries)
|
|
34
|
+
# @param loader_class [Class] Custom loader class to use (defaults to model's default loader)
|
|
35
|
+
# @param eager_load [Boolean] Whether to automatically eager load this connection (default: false)
|
|
36
|
+
# @param type [Symbol] The type of connection (:connection, :singular). Default is :connection.
|
|
37
|
+
# @param default_arguments [Hash] Default arguments to pass to the GraphQL query (e.g. first: 10)
|
|
38
|
+
# @param inverse_of [Symbol] The name of the inverse connection on the target model (optional)
|
|
39
|
+
def connection(name, class_name: nil, query_name: nil, foreign_key: nil, loader_class: nil, eager_load: false, type: :connection, default_arguments: {}, inverse_of: nil)
|
|
40
|
+
# Infer defaults
|
|
41
|
+
connection_class_name = class_name || name.to_s.classify
|
|
42
|
+
|
|
43
|
+
# Set query_name - default to camelCase for nested fields
|
|
44
|
+
connection_query_name = query_name || name.to_s.camelize(:lower)
|
|
45
|
+
|
|
46
|
+
connection_loader_class = loader_class
|
|
47
|
+
|
|
48
|
+
# Store connection metadata
|
|
49
|
+
connections[name] = {
|
|
50
|
+
class_name: connection_class_name,
|
|
51
|
+
query_name: connection_query_name,
|
|
52
|
+
foreign_key: foreign_key,
|
|
53
|
+
loader_class: connection_loader_class,
|
|
54
|
+
eager_load: eager_load,
|
|
55
|
+
type: type,
|
|
56
|
+
nested: true, # Always treated as nested (accessed via parent field)
|
|
57
|
+
target_class_name: connection_class_name,
|
|
58
|
+
original_name: name,
|
|
59
|
+
default_arguments: default_arguments,
|
|
60
|
+
inverse_of: inverse_of
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# Validate inverse relationship if specified (validation is deferred to runtime)
|
|
64
|
+
validate_inverse_of!(name, connection_class_name, inverse_of) if inverse_of
|
|
65
|
+
|
|
66
|
+
# Define the connection method that returns a proxy
|
|
67
|
+
define_method name do |**options|
|
|
68
|
+
# Check if this connection was eager loaded
|
|
69
|
+
return @_connection_cache[name] if @_connection_cache&.key?(name)
|
|
70
|
+
|
|
71
|
+
config = self.class.connections[name]
|
|
72
|
+
if config[:type] == :singular
|
|
73
|
+
# Lazy load singular association
|
|
74
|
+
loader_class = config[:loader_class] || self.class.default_loader.class
|
|
75
|
+
target_class = config[:class_name].constantize
|
|
76
|
+
loader = loader_class.new(target_class)
|
|
77
|
+
|
|
78
|
+
# Load the record
|
|
79
|
+
records = loader.load_connection_records(config[:query_name], options, self, config)
|
|
80
|
+
|
|
81
|
+
# Populate inverse cache if inverse_of is specified
|
|
82
|
+
populate_inverse_cache_for_connection(records, config, self)
|
|
83
|
+
|
|
84
|
+
# Cache it
|
|
85
|
+
@_connection_cache ||= {}
|
|
86
|
+
@_connection_cache[name] = records
|
|
87
|
+
records
|
|
88
|
+
elsif options.empty?
|
|
89
|
+
# If no runtime options are provided, reuse existing proxy if it exists
|
|
90
|
+
@_connection_proxies ||= {}
|
|
91
|
+
@_connection_proxies[name] ||= ActiveShopifyGraphQL::Connections::ConnectionProxy.new(
|
|
92
|
+
parent: self,
|
|
93
|
+
connection_name: name,
|
|
94
|
+
connection_config: self.class.connections[name],
|
|
95
|
+
options: options
|
|
96
|
+
)
|
|
97
|
+
else
|
|
98
|
+
# Create a new proxy for custom options (don't cache these)
|
|
99
|
+
Connections::ConnectionProxy.new(
|
|
100
|
+
parent: self,
|
|
101
|
+
connection_name: name,
|
|
102
|
+
connection_config: self.class.connections[name],
|
|
103
|
+
options: options
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Define setter method for testing/caching
|
|
109
|
+
define_method "#{name}=" do |value|
|
|
110
|
+
@_connection_cache ||= {}
|
|
111
|
+
@_connection_cache[name] = value
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def validate_inverse_of!(_name, _target_class_name, _inverse_name)
|
|
118
|
+
# Validation is deferred until runtime when connections are actually used
|
|
119
|
+
# This allows class definitions to be in any order
|
|
120
|
+
# The validation logic will be checked when inverse cache is populated
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def validate_includes_connections!(connection_names)
|
|
125
|
+
connection_names.each do |name|
|
|
126
|
+
if name.is_a?(Hash)
|
|
127
|
+
name.each do |key, value|
|
|
128
|
+
raise ArgumentError, "Invalid connection for #{self.name}: #{key}. Available connections: #{connections.keys.join(', ')}" unless connections.key?(key.to_sym)
|
|
129
|
+
|
|
130
|
+
# Recursively validate nested connections
|
|
131
|
+
target_class = connections[key.to_sym][:class_name].constantize
|
|
132
|
+
if target_class.respond_to?(:validate_includes_connections!, true)
|
|
133
|
+
nested_names = value.is_a?(Array) ? value : [value]
|
|
134
|
+
target_class.send(:validate_includes_connections!, nested_names)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
else
|
|
138
|
+
raise ArgumentError, "Invalid connection for #{self.name}: #{name}. Available connections: #{connections.keys.join(', ')}" unless connections.key?(name.to_sym)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Instance method to populate inverse cache for lazy-loaded connections
|
|
145
|
+
|
|
146
|
+
def populate_inverse_cache_for_connection(records, connection_config, parent)
|
|
147
|
+
return unless connection_config[:inverse_of]
|
|
148
|
+
return if records.nil? || (records.is_a?(Array) && records.empty?)
|
|
149
|
+
|
|
150
|
+
inverse_name = connection_config[:inverse_of]
|
|
151
|
+
target_class = connection_config[:class_name].constantize
|
|
152
|
+
|
|
153
|
+
# Ensure target class has the inverse connection defined
|
|
154
|
+
return unless target_class.respond_to?(:connections) && target_class.connections[inverse_name]
|
|
155
|
+
|
|
156
|
+
inverse_type = target_class.connections[inverse_name][:type]
|
|
157
|
+
records_array = records.is_a?(Array) ? records : [records]
|
|
158
|
+
|
|
159
|
+
records_array.each do |record|
|
|
160
|
+
next unless record
|
|
161
|
+
|
|
162
|
+
record.instance_variable_set(:@_connection_cache, {}) unless record.instance_variable_get(:@_connection_cache)
|
|
163
|
+
cache = record.instance_variable_get(:@_connection_cache)
|
|
164
|
+
|
|
165
|
+
cache[inverse_name] =
|
|
166
|
+
if inverse_type == :singular
|
|
167
|
+
parent
|
|
168
|
+
else
|
|
169
|
+
# For collection inverses, wrap parent in an array
|
|
170
|
+
[parent]
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL::Model::FinderMethods
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
6
|
+
class_methods do
|
|
7
|
+
# Returns a Relation for the model that can be chained
|
|
8
|
+
# @return [Relation] A new relation for this model
|
|
9
|
+
def all
|
|
10
|
+
ActiveShopifyGraphQL::Query::Relation.new(self)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Find a single record by ID
|
|
14
|
+
# @param id [String, Integer] The record ID (will be converted to GID automatically)
|
|
15
|
+
# @param loader [ActiveShopifyGraphQL::Loader] The loader to use for fetching data (deprecated, use Relation chain)
|
|
16
|
+
# @return [Object] The model instance
|
|
17
|
+
# @raise [ActiveShopifyGraphQL::ObjectNotFoundError] If the record is not found
|
|
18
|
+
def find(id)
|
|
19
|
+
all.find(id)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Returns the default loader for this model's queries
|
|
23
|
+
# @return [ActiveGraphQL::Loader] The default loader instance
|
|
24
|
+
def default_loader
|
|
25
|
+
if respond_to?(:default_loader_instance)
|
|
26
|
+
default_loader_instance
|
|
27
|
+
else
|
|
28
|
+
@default_loader ||= begin
|
|
29
|
+
# Collect connections with eager_load: true
|
|
30
|
+
eagerly_loaded_connections = connections.select { |_name, config| config[:eager_load] }.keys
|
|
31
|
+
|
|
32
|
+
default_loader_class.new(
|
|
33
|
+
self,
|
|
34
|
+
included_connections: eagerly_loaded_connections
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Allows setting a custom default loader (useful for testing)
|
|
41
|
+
# @param loader [ActiveGraphQL::Loader] The loader to set as default
|
|
42
|
+
def default_loader=(loader)
|
|
43
|
+
@default_loader = loader
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Find a single record by attribute conditions
|
|
47
|
+
# @param conditions [Hash] The conditions to query
|
|
48
|
+
# @return [Object, nil] The first matching model instance or nil if not found
|
|
49
|
+
#
|
|
50
|
+
# @example
|
|
51
|
+
# Customer.find_by(email: "john@example.com")
|
|
52
|
+
# Customer.find_by(first_name: "John", country: "Canada")
|
|
53
|
+
# Customer.find_by(orders_count: { gte: 5 })
|
|
54
|
+
def find_by(conditions = {}, **options)
|
|
55
|
+
all.find_by(conditions.empty? ? options : conditions)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Query for multiple records using attribute conditions
|
|
59
|
+
# Returns a Relation that supports chaining .limit(), .includes(), .find_by() and .in_pages()
|
|
60
|
+
#
|
|
61
|
+
# Supports three query styles:
|
|
62
|
+
# 1. Hash-based (safe, with automatic sanitization) - burden on library
|
|
63
|
+
# 2. String-based (raw query, no sanitization) - burden on developer
|
|
64
|
+
# 3. String with parameter binding (safe, with sanitization) - burden on library
|
|
65
|
+
#
|
|
66
|
+
# @param conditions_or_first_condition [Hash, String] The conditions to query
|
|
67
|
+
# @param args [Array] Additional positional arguments for parameter binding
|
|
68
|
+
# @param options [Hash] Named parameters for parameter binding
|
|
69
|
+
# @return [Relation] A chainable relation
|
|
70
|
+
#
|
|
71
|
+
# @example Hash-based query (safe, escaped)
|
|
72
|
+
# Customer.where(email: "john@example.com").to_a
|
|
73
|
+
# # => produces: query:"email:'john@example.com'"
|
|
74
|
+
#
|
|
75
|
+
# @example String-based query (raw, allows wildcards)
|
|
76
|
+
# ProductVariant.where("sku:*").to_a
|
|
77
|
+
# # => produces: query:"sku:*" (wildcard matching enabled)
|
|
78
|
+
#
|
|
79
|
+
# @example String with positional parameter binding (safe)
|
|
80
|
+
# ProductVariant.where("sku:? product_id:?", "Good ol' value", 123).to_a
|
|
81
|
+
# # => produces: query:"sku:'Good ol\\' value' product_id:123"
|
|
82
|
+
#
|
|
83
|
+
# @example String with named parameter binding (safe)
|
|
84
|
+
# ProductVariant.where("sku::sku product_id::id", { sku: "A-SKU", id: 123 }).to_a
|
|
85
|
+
# ProductVariant.where("sku::sku", sku: "A-SKU").to_a
|
|
86
|
+
# # => produces: query:"sku:'A-SKU' product_id:123"
|
|
87
|
+
#
|
|
88
|
+
# @example With limit
|
|
89
|
+
# Customer.where(first_name: "John").limit(100).to_a
|
|
90
|
+
#
|
|
91
|
+
# @example With pagination block
|
|
92
|
+
# Customer.where(orders_count: { gte: 5 }).in_pages(of: 50) do |page|
|
|
93
|
+
# page.each { |customer| process(customer) }
|
|
94
|
+
# end
|
|
95
|
+
def where(conditions_or_first_condition = {}, *args, **options)
|
|
96
|
+
all.where(conditions_or_first_condition, *args, **options)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Select specific attributes to optimize GraphQL queries
|
|
100
|
+
# @param attributes [Symbol] The attributes to select
|
|
101
|
+
# @return [Relation] A relation with selected attributes
|
|
102
|
+
#
|
|
103
|
+
# @example
|
|
104
|
+
# Customer.select(:id, :email).find(123)
|
|
105
|
+
# Customer.select(:id, :email).where(first_name: "John")
|
|
106
|
+
def select(*attributes)
|
|
107
|
+
all.select(*attributes)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Include connections for eager loading
|
|
111
|
+
# @param connection_names [Array<Symbol>] Connection names to include
|
|
112
|
+
# @return [Relation] A relation with connections included
|
|
113
|
+
#
|
|
114
|
+
# @example
|
|
115
|
+
# Customer.includes(:orders).find(123)
|
|
116
|
+
# Customer.includes(:orders, :addresses).where(country: "Canada")
|
|
117
|
+
def includes(*connection_names)
|
|
118
|
+
all.includes(*connection_names)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# Validates that selected attributes exist in the model
|
|
124
|
+
# @param attributes [Array<Symbol>] The attributes to validate
|
|
125
|
+
# @raise [ArgumentError] If any attribute is invalid
|
|
126
|
+
def validate_select_attributes!(attributes)
|
|
127
|
+
return if attributes.empty?
|
|
128
|
+
|
|
129
|
+
available_attrs = available_select_attributes
|
|
130
|
+
invalid_attrs = attributes - available_attrs
|
|
131
|
+
|
|
132
|
+
return unless invalid_attrs.any?
|
|
133
|
+
|
|
134
|
+
raise ArgumentError, "Invalid attributes for #{name}: #{invalid_attrs.join(', ')}. " \
|
|
135
|
+
"Available attributes are: #{available_attrs.join(', ')}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Gets all available attributes for selection
|
|
139
|
+
# @return [Array<Symbol>] Available attribute names
|
|
140
|
+
def available_select_attributes
|
|
141
|
+
attrs = []
|
|
142
|
+
|
|
143
|
+
# Get attributes from the model class
|
|
144
|
+
loader_class = default_loader.class
|
|
145
|
+
model_attrs = attributes_for_loader(loader_class)
|
|
146
|
+
attrs.concat(model_attrs.keys)
|
|
147
|
+
|
|
148
|
+
# Get attributes from the loader class
|
|
149
|
+
loader_attrs = default_loader.class.defined_attributes
|
|
150
|
+
attrs.concat(loader_attrs.keys)
|
|
151
|
+
|
|
152
|
+
attrs.map(&:to_sym).uniq.sort
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL::Model::GraphqlTypeResolver
|
|
4
|
+
# Centralizes GraphQL type resolution logic.
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
class_methods do
|
|
8
|
+
# Set or get the base GraphQL type for this model.
|
|
9
|
+
#
|
|
10
|
+
# @param type [String, nil] The GraphQL type name to set, or nil to get
|
|
11
|
+
# @return [String] The GraphQL type name
|
|
12
|
+
# @raise [NotImplementedError] If no type is defined
|
|
13
|
+
def graphql_type(type = nil)
|
|
14
|
+
if type
|
|
15
|
+
if @current_loader_context
|
|
16
|
+
@loader_graphql_types ||= {}
|
|
17
|
+
@loader_graphql_types[@current_loader_context] = type
|
|
18
|
+
else
|
|
19
|
+
@base_graphql_type = type
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
@base_graphql_type || raise(NotImplementedError, "#{self} must define graphql_type")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Get the GraphQL type for a specific loader class.
|
|
27
|
+
# Resolution order:
|
|
28
|
+
# 1. Loader-specific type defined via `for_loader`
|
|
29
|
+
# 2. Base graphql_type defined on the model
|
|
30
|
+
# 3. Type defined on the loader class itself
|
|
31
|
+
# 4. Inferred from model class name
|
|
32
|
+
#
|
|
33
|
+
# @param loader_class [Class] The loader class to resolve type for
|
|
34
|
+
# @return [String] The resolved GraphQL type
|
|
35
|
+
# @raise [NotImplementedError] If no type can be resolved
|
|
36
|
+
def graphql_type_for_loader(loader_class)
|
|
37
|
+
# 1. Check loader-specific override
|
|
38
|
+
return @loader_graphql_types[loader_class] if @loader_graphql_types&.key?(loader_class)
|
|
39
|
+
|
|
40
|
+
# 2. Check base graphql_type
|
|
41
|
+
return @base_graphql_type if @base_graphql_type
|
|
42
|
+
|
|
43
|
+
# 3. Check loader class itself
|
|
44
|
+
loader_type = loader_class.instance_variable_get(:@graphql_type)
|
|
45
|
+
return loader_type if loader_type
|
|
46
|
+
|
|
47
|
+
# 4. Infer from model name
|
|
48
|
+
return name.demodulize if respond_to?(:name) && name
|
|
49
|
+
|
|
50
|
+
raise NotImplementedError,
|
|
51
|
+
"#{self} must define graphql_type or #{loader_class} must define graphql_type"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Resolve the GraphQL type from any source (model class, loader, or value).
|
|
55
|
+
# Useful for external callers that need to resolve type from various inputs.
|
|
56
|
+
#
|
|
57
|
+
# @param model_class [Class, nil] The model class
|
|
58
|
+
# @param loader_class [Class, nil] The loader class
|
|
59
|
+
# @return [String] The resolved GraphQL type
|
|
60
|
+
def resolve_graphql_type(model_class: nil, loader_class: nil)
|
|
61
|
+
if model_class.respond_to?(:graphql_type_for_loader) && loader_class
|
|
62
|
+
model_class.graphql_type_for_loader(loader_class)
|
|
63
|
+
elsif model_class.respond_to?(:graphql_type)
|
|
64
|
+
model_class.graphql_type
|
|
65
|
+
elsif loader_class.respond_to?(:graphql_type)
|
|
66
|
+
loader_class.graphql_type
|
|
67
|
+
elsif model_class.respond_to?(:name) && model_class.name
|
|
68
|
+
model_class.name.demodulize
|
|
69
|
+
else
|
|
70
|
+
raise ArgumentError, "Cannot resolve graphql_type from provided arguments"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Module-level resolver for convenience
|
|
76
|
+
def self.resolve(model_class: nil, loader_class: nil)
|
|
77
|
+
if model_class.respond_to?(:graphql_type_for_loader) && loader_class
|
|
78
|
+
model_class.graphql_type_for_loader(loader_class)
|
|
79
|
+
elsif model_class.respond_to?(:graphql_type)
|
|
80
|
+
model_class.graphql_type
|
|
81
|
+
elsif loader_class.respond_to?(:graphql_type)
|
|
82
|
+
loader_class.graphql_type
|
|
83
|
+
elsif model_class.respond_to?(:name) && model_class.name
|
|
84
|
+
model_class.name.demodulize
|
|
85
|
+
else
|
|
86
|
+
raise ArgumentError, "Cannot resolve graphql_type"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL::Model::LoaderSwitchable
|
|
4
|
+
# Provides capability to switch between different loaders within the same model
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
# Generic method to execute with a specific loader
|
|
8
|
+
# @param loader_class [Class] The loader class to use
|
|
9
|
+
# @yield [Object] Block to execute with the loader
|
|
10
|
+
# @return [Object] Result of the block
|
|
11
|
+
def with_loader(loader_class, &_block)
|
|
12
|
+
old_loader = Thread.current[:active_shopify_graphql_loader]
|
|
13
|
+
Thread.current[:active_shopify_graphql_loader] = loader_class.new(self.class)
|
|
14
|
+
|
|
15
|
+
if block_given?
|
|
16
|
+
yield(self)
|
|
17
|
+
else
|
|
18
|
+
self
|
|
19
|
+
end
|
|
20
|
+
ensure
|
|
21
|
+
Thread.current[:active_shopify_graphql_loader] = old_loader
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Executes with the admin API loader
|
|
25
|
+
# @return [self]
|
|
26
|
+
def with_admin_api(&block)
|
|
27
|
+
with_loader(ActiveShopifyGraphQL::Loaders::AdminApiLoader, &block)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Executes with the customer account API loader
|
|
31
|
+
# @return [self]
|
|
32
|
+
def with_customer_account_api(&block)
|
|
33
|
+
with_loader(ActiveShopifyGraphQL::Loaders::CustomerAccountApiLoader, &block)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class_methods do
|
|
37
|
+
# @!method use_loader(loader_class)
|
|
38
|
+
# Sets the default loader class for this model.
|
|
39
|
+
#
|
|
40
|
+
# @param loader_class [Class] The loader class to use as default
|
|
41
|
+
# @example
|
|
42
|
+
# class Customer < ActiveRecord::Base
|
|
43
|
+
# use_loader ActiveShopifyGraphQL::Loaders::CustomerAccountApiLoader
|
|
44
|
+
# end
|
|
45
|
+
def use_loader(loader_class)
|
|
46
|
+
@default_loader_class = loader_class
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Define loader-specific attribute and graphql_type overrides
|
|
50
|
+
# @param loader_class [Class] The loader class to override attributes for
|
|
51
|
+
def for_loader(loader_class, &block)
|
|
52
|
+
@current_loader_context = loader_class
|
|
53
|
+
@loader_contexts ||= {}
|
|
54
|
+
@loader_contexts[loader_class] ||= {}
|
|
55
|
+
instance_eval(&block) if block_given?
|
|
56
|
+
@current_loader_context = nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Class-level method to execute with admin API loader
|
|
60
|
+
# @return [LoaderProxy] Proxy object with find method
|
|
61
|
+
def with_admin_api
|
|
62
|
+
ActiveShopifyGraphQL::LoaderProxy.new(self, ActiveShopifyGraphQL::Loaders::AdminApiLoader.new(self))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Class-level method to execute with customer account API loader
|
|
66
|
+
# @return [LoaderProxy] Proxy object with find method
|
|
67
|
+
def with_customer_account_api(token = nil)
|
|
68
|
+
ActiveShopifyGraphQL::LoaderProxy.new(self, ActiveShopifyGraphQL::Loaders::CustomerAccountApiLoader.new(self, token))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Returns the default loader class (either set via DSL or inferred)
|
|
74
|
+
# @return [Class] The default loader class
|
|
75
|
+
def default_loader_class
|
|
76
|
+
@default_loader_class ||= ActiveShopifyGraphQL::Loaders::AdminApiLoader
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL::Model::MetafieldAttributes
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
6
|
+
class_methods do
|
|
7
|
+
# Define a metafield attribute for this model.
|
|
8
|
+
#
|
|
9
|
+
# @param name [Symbol] The Ruby attribute name
|
|
10
|
+
# @param namespace [String] The metafield namespace
|
|
11
|
+
# @param key [String] The metafield key
|
|
12
|
+
# @param type [Symbol] The type for coercion (:string, :integer, :float, :boolean, :datetime, :json)
|
|
13
|
+
# @param null [Boolean] Whether the attribute can be null (default: true)
|
|
14
|
+
# @param default [Object] Default value when GraphQL response is nil
|
|
15
|
+
# @param transform [Proc] Custom transform block for the value
|
|
16
|
+
def metafield_attribute(name, namespace:, key:, type: :string, null: true, default: nil, transform: nil)
|
|
17
|
+
@metafields ||= {}
|
|
18
|
+
@metafields[name] = { namespace: namespace, key: key, type: type }
|
|
19
|
+
|
|
20
|
+
# Build metafield config
|
|
21
|
+
alias_name = "#{infer_path(name)}Metafield"
|
|
22
|
+
value_field = type == :json ? 'jsonValue' : 'value'
|
|
23
|
+
path = "#{alias_name}.#{value_field}"
|
|
24
|
+
|
|
25
|
+
config = {
|
|
26
|
+
path: path,
|
|
27
|
+
type: type,
|
|
28
|
+
null: null,
|
|
29
|
+
default: default,
|
|
30
|
+
transform: transform,
|
|
31
|
+
is_metafield: true,
|
|
32
|
+
metafield_alias: alias_name,
|
|
33
|
+
metafield_namespace: namespace,
|
|
34
|
+
metafield_key: key
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if @current_loader_context
|
|
38
|
+
@loader_contexts[@current_loader_context][name] = config
|
|
39
|
+
else
|
|
40
|
+
@base_attributes ||= {}
|
|
41
|
+
@base_attributes[name] = config
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
attr_accessor name unless method_defined?(name) || method_defined?("#{name}=")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Get metafields defined for this model
|
|
48
|
+
def metafields
|
|
49
|
+
@metafields || {}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Infer GraphQL path from Ruby attribute name (delegates to Attributes if available)
|
|
55
|
+
def infer_path(name)
|
|
56
|
+
name.to_s.gsub(/_([a-z])/) { ::Regexp.last_match(1).upcase }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -1,25 +1,39 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ActiveShopifyGraphQL
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
# Base class for all GraphQL-backed models.
|
|
5
|
+
#
|
|
6
|
+
# Models should inherit from this class (typically via an ApplicationShopifyGqlRecord
|
|
7
|
+
# intermediate class) to gain ActiveRecord-like functionality for Shopify GraphQL APIs.
|
|
8
|
+
#
|
|
9
|
+
# @example Creating an ApplicationShopifyGqlRecord base class
|
|
10
|
+
# class ApplicationShopifyGqlRecord < ActiveShopifyGraphQL::Model
|
|
11
|
+
# attribute :id, transform: ->(id) { id.split("/").last }
|
|
12
|
+
# attribute :gid, path: "id"
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# @example Defining a model
|
|
16
|
+
# class Customer < ApplicationShopifyGqlRecord
|
|
17
|
+
# graphql_type "Customer"
|
|
18
|
+
#
|
|
19
|
+
# attribute :first_name
|
|
20
|
+
# attribute :email, path: "defaultEmailAddress.emailAddress"
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
class Model
|
|
24
|
+
include ActiveModel::AttributeAssignment
|
|
25
|
+
include ActiveModel::Validations
|
|
26
|
+
extend ActiveModel::Naming
|
|
6
27
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
include ActiveShopifyGraphQL::Connections
|
|
15
|
-
include ActiveShopifyGraphQL::Attributes
|
|
16
|
-
include ActiveShopifyGraphQL::MetafieldAttributes
|
|
17
|
-
include ActiveShopifyGraphQL::LoaderSwitchable
|
|
18
|
-
end
|
|
28
|
+
include GraphqlTypeResolver
|
|
29
|
+
include FinderMethods
|
|
30
|
+
include Associations
|
|
31
|
+
include Connections
|
|
32
|
+
include Attributes
|
|
33
|
+
include MetafieldAttributes
|
|
34
|
+
include LoaderSwitchable
|
|
19
35
|
|
|
20
36
|
def initialize(attributes = {})
|
|
21
|
-
super()
|
|
22
|
-
|
|
23
37
|
# Extract connection cache if present and populate inverse caches
|
|
24
38
|
if attributes.key?(:_connection_cache)
|
|
25
39
|
@_connection_cache = attributes.delete(:_connection_cache)
|