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,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
# Simple proxy class to handle loader delegation when using a specific API
|
|
5
|
+
# This provides a consistent interface with Relation while using a custom loader
|
|
6
|
+
class LoaderProxy
|
|
7
|
+
def initialize(model_class, loader)
|
|
8
|
+
@model_class = model_class
|
|
9
|
+
@loader = loader
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Create a Relation with this loader's configuration
|
|
13
|
+
# @return [Relation] A relation configured with this loader
|
|
14
|
+
def all
|
|
15
|
+
build_relation
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Delegate chainable methods to Relation
|
|
19
|
+
def includes(*connection_names)
|
|
20
|
+
build_relation.includes(*connection_names)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def select(*attribute_names)
|
|
24
|
+
build_relation.select(*attribute_names)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def where(*args, **options)
|
|
28
|
+
build_relation.where(*args, **options)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def find_by(conditions = {}, **options)
|
|
32
|
+
build_relation.find_by(conditions, **options)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def find(id = nil)
|
|
36
|
+
# For Customer Account API, if no ID is provided, load the current customer
|
|
37
|
+
if id.nil? && @loader.is_a?(ActiveShopifyGraphQL::Loaders::CustomerAccountApiLoader)
|
|
38
|
+
attributes = @loader.load_attributes
|
|
39
|
+
return nil if attributes.nil?
|
|
40
|
+
|
|
41
|
+
return @model_class.new(attributes)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# For other cases, require ID and use standard flow
|
|
45
|
+
return nil if id.nil?
|
|
46
|
+
|
|
47
|
+
build_relation.find(id)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
attr_reader :loader
|
|
51
|
+
|
|
52
|
+
def inspect
|
|
53
|
+
"#{@model_class.name}(with_#{@loader.class.name.demodulize})"
|
|
54
|
+
end
|
|
55
|
+
alias to_s inspect
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def build_relation
|
|
60
|
+
Query::Relation.new(
|
|
61
|
+
@model_class,
|
|
62
|
+
loader_class: @loader.class,
|
|
63
|
+
loader_extra_args: @loader.initialization_args
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -3,30 +3,14 @@
|
|
|
3
3
|
module ActiveShopifyGraphQL
|
|
4
4
|
module Loaders
|
|
5
5
|
class AdminApiLoader < Loader
|
|
6
|
-
def initialize(model_class = nil, selected_attributes: nil, included_connections: nil)
|
|
7
|
-
super
|
|
8
|
-
end
|
|
9
|
-
|
|
10
6
|
def perform_graphql_query(query, **variables)
|
|
11
|
-
log_query(query, variables)
|
|
7
|
+
log_query("Admin API", query, variables)
|
|
12
8
|
|
|
13
9
|
client = ActiveShopifyGraphQL.configuration.admin_api_client
|
|
14
10
|
raise Error, "Admin API client not configured. Please configure it using ActiveShopifyGraphQL.configure" unless client
|
|
15
11
|
|
|
16
12
|
client.execute(query, **variables)
|
|
17
13
|
end
|
|
18
|
-
|
|
19
|
-
private
|
|
20
|
-
|
|
21
|
-
def should_log?
|
|
22
|
-
ActiveShopifyGraphQL.configuration.log_queries && ActiveShopifyGraphQL.configuration.logger
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def log_query(query, variables)
|
|
26
|
-
logger = ActiveShopifyGraphQL.configuration.logger
|
|
27
|
-
logger.info("ActiveShopifyGraphQL Query (Admin API):\n#{query}")
|
|
28
|
-
logger.info("ActiveShopifyGraphQL Variables:\n#{variables}")
|
|
29
|
-
end
|
|
30
14
|
end
|
|
31
15
|
end
|
|
32
16
|
end
|
|
@@ -8,23 +8,15 @@ module ActiveShopifyGraphQL
|
|
|
8
8
|
@token = token
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
# Override to handle Customer queries that don't need an ID
|
|
12
|
-
def graphql_query(model_type = nil)
|
|
13
|
-
type = model_type || graphql_type
|
|
14
|
-
if type == 'Customer'
|
|
15
|
-
QueryTree.build_current_customer_query(context)
|
|
16
|
-
else
|
|
17
|
-
super(type)
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
|
|
21
11
|
# Override load_attributes to handle the Customer case
|
|
22
12
|
def load_attributes(id = nil)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
13
|
+
query = if context.graphql_type == 'Customer'
|
|
14
|
+
Query::QueryBuilder.build_current_customer_query(context)
|
|
15
|
+
else
|
|
16
|
+
Query::QueryBuilder.build_single_record_query(context)
|
|
17
|
+
end
|
|
18
|
+
variables = context.graphql_type == 'Customer' ? {} : { id: id }
|
|
19
|
+
response_data = execute_query(query, **variables)
|
|
28
20
|
|
|
29
21
|
return nil if response_data.nil?
|
|
30
22
|
|
|
@@ -39,20 +31,12 @@ module ActiveShopifyGraphQL
|
|
|
39
31
|
end
|
|
40
32
|
|
|
41
33
|
def perform_graphql_query(query, **variables)
|
|
42
|
-
log_query(query, variables)
|
|
34
|
+
log_query("Customer Account API", query, variables)
|
|
43
35
|
client.query(query, variables)
|
|
44
36
|
end
|
|
45
37
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def should_log?
|
|
49
|
-
ActiveShopifyGraphQL.configuration.log_queries && ActiveShopifyGraphQL.configuration.logger
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def log_query(query, variables)
|
|
53
|
-
logger = ActiveShopifyGraphQL.configuration.logger
|
|
54
|
-
logger.info("ActiveShopifyGraphQL Query (Customer Account API):\n#{query}")
|
|
55
|
-
logger.info("ActiveShopifyGraphQL Variables:\n#{variables}")
|
|
38
|
+
def initialization_args
|
|
39
|
+
[@token]
|
|
56
40
|
end
|
|
57
41
|
end
|
|
58
42
|
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL::Model::Associations
|
|
4
|
+
# Handles associations between ActiveShopifyGraphQL objects and ActiveRecord objects
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
class << self
|
|
9
|
+
def associations
|
|
10
|
+
@associations ||= {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
attr_writer :associations
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class_methods do
|
|
18
|
+
def has_many(name, class_name: nil, foreign_key: nil, primary_key: nil)
|
|
19
|
+
association_class_name = class_name || name.to_s.classify
|
|
20
|
+
association_foreign_key = foreign_key || "shopify_#{model_name.element.downcase}_id"
|
|
21
|
+
association_primary_key = primary_key || :id
|
|
22
|
+
|
|
23
|
+
# Store association metadata
|
|
24
|
+
associations[name] = {
|
|
25
|
+
type: :has_many,
|
|
26
|
+
class_name: association_class_name,
|
|
27
|
+
foreign_key: association_foreign_key,
|
|
28
|
+
primary_key: association_primary_key
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Define the association method
|
|
32
|
+
define_method name do
|
|
33
|
+
return @_association_cache[name] if @_association_cache&.key?(name)
|
|
34
|
+
|
|
35
|
+
@_association_cache ||= {}
|
|
36
|
+
|
|
37
|
+
primary_key_value = send(association_primary_key)
|
|
38
|
+
return @_association_cache[name] = [] if primary_key_value.blank?
|
|
39
|
+
|
|
40
|
+
association_class = association_class_name.constantize
|
|
41
|
+
@_association_cache[name] = association_class.where(association_foreign_key => primary_key_value)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Define the association setter method for testing/mocking
|
|
45
|
+
define_method "#{name}=" do |value|
|
|
46
|
+
@_association_cache ||= {}
|
|
47
|
+
@_association_cache[name] = value
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def has_one(name, class_name: nil, foreign_key: nil, primary_key: nil)
|
|
52
|
+
association_class_name = class_name || name.to_s.classify
|
|
53
|
+
association_foreign_key = foreign_key || "shopify_#{model_name.element.downcase}_id"
|
|
54
|
+
association_primary_key = primary_key || :id
|
|
55
|
+
|
|
56
|
+
# Store association metadata
|
|
57
|
+
associations[name] = {
|
|
58
|
+
type: :has_one,
|
|
59
|
+
class_name: association_class_name,
|
|
60
|
+
foreign_key: association_foreign_key,
|
|
61
|
+
primary_key: association_primary_key
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Define the association method
|
|
65
|
+
define_method name do
|
|
66
|
+
return @_association_cache[name] if @_association_cache&.key?(name)
|
|
67
|
+
|
|
68
|
+
@_association_cache ||= {}
|
|
69
|
+
|
|
70
|
+
primary_key_value = send(association_primary_key)
|
|
71
|
+
return @_association_cache[name] = nil if primary_key_value.blank?
|
|
72
|
+
|
|
73
|
+
# Extract numeric ID from Shopify GID if needed
|
|
74
|
+
if primary_key_value.is_a?(String)
|
|
75
|
+
begin
|
|
76
|
+
parsed_gid = URI::GID.parse(primary_key_value)
|
|
77
|
+
primary_key_value = parsed_gid.model_id
|
|
78
|
+
rescue URI::InvalidURIError, URI::BadURIError, ArgumentError
|
|
79
|
+
# Not a GID, use value as-is
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
association_class = association_class_name.constantize
|
|
84
|
+
@_association_cache[name] = association_class.find_by(association_foreign_key => primary_key_value)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Define the association setter method for testing/mocking
|
|
88
|
+
define_method "#{name}=" do |value|
|
|
89
|
+
@_association_cache ||= {}
|
|
90
|
+
@_association_cache[name] = value
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -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
|