active_shopify_graphql 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/workflows/lint.yml +35 -0
- data/.github/workflows/test.yml +35 -0
- data/.rubocop.yml +50 -0
- data/AGENTS.md +53 -0
- data/LICENSE.txt +21 -0
- data/README.md +544 -0
- data/Rakefile +8 -0
- data/lib/active_shopify_graphql/associations.rb +90 -0
- data/lib/active_shopify_graphql/attributes.rb +49 -0
- data/lib/active_shopify_graphql/base.rb +29 -0
- data/lib/active_shopify_graphql/configuration.rb +29 -0
- data/lib/active_shopify_graphql/connection_loader.rb +96 -0
- data/lib/active_shopify_graphql/connections/connection_proxy.rb +112 -0
- data/lib/active_shopify_graphql/connections.rb +170 -0
- data/lib/active_shopify_graphql/finder_methods.rb +154 -0
- data/lib/active_shopify_graphql/fragment_builder.rb +195 -0
- data/lib/active_shopify_graphql/gid_helper.rb +54 -0
- data/lib/active_shopify_graphql/graphql_type_resolver.rb +91 -0
- data/lib/active_shopify_graphql/loader.rb +183 -0
- data/lib/active_shopify_graphql/loader_context.rb +88 -0
- data/lib/active_shopify_graphql/loader_switchable.rb +121 -0
- data/lib/active_shopify_graphql/loaders/admin_api_loader.rb +32 -0
- data/lib/active_shopify_graphql/loaders/customer_account_api_loader.rb +71 -0
- data/lib/active_shopify_graphql/metafield_attributes.rb +61 -0
- data/lib/active_shopify_graphql/query_node.rb +160 -0
- data/lib/active_shopify_graphql/query_tree.rb +204 -0
- data/lib/active_shopify_graphql/response_mapper.rb +202 -0
- data/lib/active_shopify_graphql/search_query.rb +71 -0
- data/lib/active_shopify_graphql/version.rb +5 -0
- data/lib/active_shopify_graphql.rb +34 -0
- metadata +147 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
module Base
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
include ActiveModel::AttributeAssignment
|
|
9
|
+
include ActiveModel::Validations
|
|
10
|
+
extend ActiveModel::Naming
|
|
11
|
+
include ActiveShopifyGraphQL::GraphqlTypeResolver
|
|
12
|
+
include ActiveShopifyGraphQL::FinderMethods
|
|
13
|
+
include ActiveShopifyGraphQL::Associations
|
|
14
|
+
include ActiveShopifyGraphQL::Connections
|
|
15
|
+
include ActiveShopifyGraphQL::Attributes
|
|
16
|
+
include ActiveShopifyGraphQL::MetafieldAttributes
|
|
17
|
+
include ActiveShopifyGraphQL::LoaderSwitchable
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(attributes = {})
|
|
21
|
+
super()
|
|
22
|
+
|
|
23
|
+
# Extract connection cache if present
|
|
24
|
+
@_connection_cache = attributes.delete(:_connection_cache) if attributes.key?(:_connection_cache)
|
|
25
|
+
|
|
26
|
+
assign_attributes(attributes)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
# Configuration class for setting up external dependencies
|
|
5
|
+
class Configuration
|
|
6
|
+
attr_accessor :admin_api_client, :customer_account_client_class, :logger, :log_queries, :compact_queries
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@admin_api_client = nil
|
|
10
|
+
@customer_account_client_class = nil
|
|
11
|
+
@logger = nil
|
|
12
|
+
@log_queries = false
|
|
13
|
+
@compact_queries = false
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.configuration
|
|
18
|
+
@configuration ||= Configuration.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.configure
|
|
22
|
+
yield(configuration)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Reset configuration (useful for testing)
|
|
26
|
+
def self.reset_configuration!
|
|
27
|
+
@configuration = Configuration.new
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'global_id'
|
|
4
|
+
|
|
5
|
+
module ActiveShopifyGraphQL
|
|
6
|
+
# Handles loading records for GraphQL connections.
|
|
7
|
+
# Refactored to use LoaderContext for cleaner parameter passing.
|
|
8
|
+
class ConnectionLoader
|
|
9
|
+
attr_reader :context
|
|
10
|
+
|
|
11
|
+
def initialize(context, loader_instance:)
|
|
12
|
+
@context = context
|
|
13
|
+
@loader_instance = loader_instance
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Load records for a connection query
|
|
17
|
+
# @param query_name [String] The connection field name (e.g., 'orders', 'addresses')
|
|
18
|
+
# @param variables [Hash] The GraphQL variables (first, sort_key, reverse, query)
|
|
19
|
+
# @param parent [Object] The parent object that owns this connection
|
|
20
|
+
# @param connection_config [Hash] The connection configuration
|
|
21
|
+
# @return [Array<Object>] Array of model instances
|
|
22
|
+
def load_records(query_name, variables, parent = nil, connection_config = nil)
|
|
23
|
+
is_nested = connection_config&.dig(:nested) || parent.respond_to?(:id)
|
|
24
|
+
|
|
25
|
+
if is_nested && parent
|
|
26
|
+
load_nested_connection(query_name, variables, parent, connection_config)
|
|
27
|
+
else
|
|
28
|
+
load_root_connection(query_name, variables, connection_config)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def load_nested_connection(query_name, variables, parent, connection_config)
|
|
35
|
+
parent_type = parent.class.graphql_type_for_loader(@context.loader_class)
|
|
36
|
+
parent_query_name = parent_type.downcase
|
|
37
|
+
connection_type = connection_config&.dig(:type) || :connection
|
|
38
|
+
|
|
39
|
+
query = QueryTree.build_connection_query(
|
|
40
|
+
@context,
|
|
41
|
+
query_name: query_name,
|
|
42
|
+
variables: variables,
|
|
43
|
+
parent_query: "#{parent_query_name}(id: $id)",
|
|
44
|
+
connection_type: connection_type
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
parent_id = extract_gid(parent)
|
|
48
|
+
response_data = @loader_instance.perform_graphql_query(query, id: parent_id)
|
|
49
|
+
|
|
50
|
+
return [] if response_data.nil?
|
|
51
|
+
|
|
52
|
+
mapper = ResponseMapper.new(@context)
|
|
53
|
+
mapper.map_nested_connection_response(response_data, query_name, parent, connection_config)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def load_root_connection(query_name, variables, connection_config)
|
|
57
|
+
connection_type = connection_config&.dig(:type) || :connection
|
|
58
|
+
|
|
59
|
+
query = QueryTree.build_connection_query(
|
|
60
|
+
@context,
|
|
61
|
+
query_name: query_name,
|
|
62
|
+
variables: variables,
|
|
63
|
+
parent_query: nil,
|
|
64
|
+
connection_type: connection_type
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
response_data = @loader_instance.perform_graphql_query(query)
|
|
68
|
+
|
|
69
|
+
return [] if response_data.nil?
|
|
70
|
+
|
|
71
|
+
mapper = ResponseMapper.new(@context)
|
|
72
|
+
mapper.map_connection_response(response_data, query_name, connection_config)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def extract_gid(parent)
|
|
76
|
+
return parent.gid if parent.respond_to?(:gid) && !parent.gid.nil?
|
|
77
|
+
|
|
78
|
+
id_value = parent.id
|
|
79
|
+
parent_type = resolve_parent_type(parent)
|
|
80
|
+
|
|
81
|
+
GidHelper.normalize_gid(id_value, parent_type)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def resolve_parent_type(parent)
|
|
85
|
+
klass = parent.class
|
|
86
|
+
|
|
87
|
+
if klass.respond_to?(:graphql_type_for_loader)
|
|
88
|
+
klass.graphql_type_for_loader(@context.loader_class)
|
|
89
|
+
elsif klass.respond_to?(:graphql_type)
|
|
90
|
+
klass.graphql_type
|
|
91
|
+
else
|
|
92
|
+
klass.name
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
module Connections
|
|
5
|
+
# Lazy-loading proxy for GraphQL connections.
|
|
6
|
+
# Implements Enumerable and delegates to the loaded records array.
|
|
7
|
+
class ConnectionProxy
|
|
8
|
+
include Enumerable
|
|
9
|
+
|
|
10
|
+
def initialize(parent:, connection_name:, connection_config:, options:)
|
|
11
|
+
@parent = parent
|
|
12
|
+
@connection_name = connection_name
|
|
13
|
+
@connection_config = connection_config
|
|
14
|
+
@options = options
|
|
15
|
+
@loaded = false
|
|
16
|
+
@records = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Core Enumerable method - all others derive from this
|
|
20
|
+
def each(&block)
|
|
21
|
+
ensure_loaded
|
|
22
|
+
@records.each(&block)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Array coercion (returns a copy to prevent mutation)
|
|
26
|
+
def to_a
|
|
27
|
+
ensure_loaded
|
|
28
|
+
@records.dup
|
|
29
|
+
end
|
|
30
|
+
alias to_ary to_a
|
|
31
|
+
|
|
32
|
+
def loaded?
|
|
33
|
+
@loaded
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def load
|
|
37
|
+
ensure_loaded
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Override for efficiency - avoids full iteration
|
|
42
|
+
def size
|
|
43
|
+
ensure_loaded
|
|
44
|
+
@records.size
|
|
45
|
+
end
|
|
46
|
+
alias length size
|
|
47
|
+
alias count size
|
|
48
|
+
|
|
49
|
+
# Override for efficiency
|
|
50
|
+
def empty?
|
|
51
|
+
ensure_loaded
|
|
52
|
+
@records.empty?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Override first/last for efficiency (avoid iterating entire collection)
|
|
56
|
+
def first(n = nil)
|
|
57
|
+
ensure_loaded
|
|
58
|
+
n ? @records.first(n) : @records.first
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def last(n = nil)
|
|
62
|
+
ensure_loaded
|
|
63
|
+
n ? @records.last(n) : @records.last
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def [](index)
|
|
67
|
+
ensure_loaded
|
|
68
|
+
@records[index]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def reload
|
|
72
|
+
@loaded = false
|
|
73
|
+
@records = nil
|
|
74
|
+
self
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def inspect
|
|
78
|
+
ensure_loaded
|
|
79
|
+
@records.inspect
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def pretty_print(q)
|
|
83
|
+
ensure_loaded
|
|
84
|
+
@records.pretty_print(q)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def ensure_loaded
|
|
90
|
+
return if @loaded
|
|
91
|
+
|
|
92
|
+
loader_class = @connection_config[:loader_class] || @parent.class.default_loader.class
|
|
93
|
+
target_class = @connection_config[:class_name].constantize
|
|
94
|
+
loader = loader_class.new(target_class)
|
|
95
|
+
|
|
96
|
+
@records = loader.load_connection_records(
|
|
97
|
+
@connection_config[:query_name],
|
|
98
|
+
build_variables,
|
|
99
|
+
@parent,
|
|
100
|
+
@connection_config
|
|
101
|
+
) || []
|
|
102
|
+
|
|
103
|
+
@loaded = true
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def build_variables
|
|
107
|
+
default_args = @connection_config[:default_arguments] || {}
|
|
108
|
+
default_args.merge(@options).compact
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "connections/connection_proxy"
|
|
4
|
+
|
|
5
|
+
module ActiveShopifyGraphQL
|
|
6
|
+
module Connections
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
class << self
|
|
11
|
+
attr_accessor :connections
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
self.connections = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class_methods do
|
|
18
|
+
# Define a singular connection (returns a single object)
|
|
19
|
+
# @see #connection
|
|
20
|
+
def has_one_connected(name, **options)
|
|
21
|
+
connection(name, type: :singular, **options)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Define a plural connection (returns a collection via edges)
|
|
25
|
+
# @see #connection
|
|
26
|
+
def has_many_connected(name, **options)
|
|
27
|
+
connection(name, type: :connection, **options)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Define a GraphQL connection to another ActiveShopifyGraphQL model
|
|
31
|
+
# @param name [Symbol] The connection name (e.g., :orders)
|
|
32
|
+
# @param class_name [String] The target model class name (defaults to name.to_s.classify)
|
|
33
|
+
# @param query_name [String] The GraphQL query field name (auto-determined based on nested/root-level)
|
|
34
|
+
# @param foreign_key [String] The field to filter by (auto-determined for root-level queries)
|
|
35
|
+
# @param loader_class [Class] Custom loader class to use (defaults to model's default loader)
|
|
36
|
+
# @param eager_load [Boolean] Whether to automatically eager load this connection (default: false)
|
|
37
|
+
# @param type [Symbol] The type of connection (:connection, :singular). Default is :connection.
|
|
38
|
+
# @param default_arguments [Hash] Default arguments to pass to the GraphQL query (e.g. first: 10)
|
|
39
|
+
def connection(name, class_name: nil, query_name: nil, foreign_key: nil, loader_class: nil, eager_load: false, type: :connection, default_arguments: {})
|
|
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
|
+
}
|
|
61
|
+
|
|
62
|
+
# Define the connection method that returns a proxy
|
|
63
|
+
define_method name do |**options|
|
|
64
|
+
# Check if this connection was eager loaded
|
|
65
|
+
return @_connection_cache[name] if @_connection_cache&.key?(name)
|
|
66
|
+
|
|
67
|
+
config = self.class.connections[name]
|
|
68
|
+
if config[:type] == :singular
|
|
69
|
+
# Lazy load singular association
|
|
70
|
+
loader_class = config[:loader_class] || self.class.default_loader.class
|
|
71
|
+
target_class = config[:class_name].constantize
|
|
72
|
+
loader = loader_class.new(target_class)
|
|
73
|
+
|
|
74
|
+
# Load the record
|
|
75
|
+
records = loader.load_connection_records(config[:query_name], options, self, config)
|
|
76
|
+
|
|
77
|
+
# Cache it
|
|
78
|
+
@_connection_cache ||= {}
|
|
79
|
+
@_connection_cache[name] = records
|
|
80
|
+
records
|
|
81
|
+
elsif options.empty?
|
|
82
|
+
# If no runtime options are provided, reuse existing proxy if it exists
|
|
83
|
+
@_connection_proxies ||= {}
|
|
84
|
+
@_connection_proxies[name] ||= ConnectionProxy.new(
|
|
85
|
+
parent: self,
|
|
86
|
+
connection_name: name,
|
|
87
|
+
connection_config: self.class.connections[name],
|
|
88
|
+
options: options
|
|
89
|
+
)
|
|
90
|
+
else
|
|
91
|
+
# Create a new proxy for custom options (don't cache these)
|
|
92
|
+
ConnectionProxy.new(
|
|
93
|
+
parent: self,
|
|
94
|
+
connection_name: name,
|
|
95
|
+
connection_config: self.class.connections[name],
|
|
96
|
+
options: options
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Define setter method for testing/caching
|
|
102
|
+
define_method "#{name}=" do |value|
|
|
103
|
+
@_connection_cache ||= {}
|
|
104
|
+
@_connection_cache[name] = value
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Load records with eager-loaded connections
|
|
109
|
+
# @param *connection_names [Symbol, Hash] The connection names to eager load
|
|
110
|
+
# @return [Class] A modified class for method chaining
|
|
111
|
+
#
|
|
112
|
+
# @example
|
|
113
|
+
# Customer.includes(:orders).find(123)
|
|
114
|
+
# Customer.includes(:orders, :addresses).where(email: "john@example.com")
|
|
115
|
+
# Order.includes(line_items: :variant)
|
|
116
|
+
def includes(*connection_names)
|
|
117
|
+
# Validate connections exist
|
|
118
|
+
validate_includes_connections!(connection_names)
|
|
119
|
+
|
|
120
|
+
# Collect connections with eager_load: true
|
|
121
|
+
auto_included_connections = connections.select { |_name, config| config[:eager_load] }.keys
|
|
122
|
+
|
|
123
|
+
# Merge manual and automatic connections
|
|
124
|
+
all_included_connections = (connection_names + auto_included_connections).uniq
|
|
125
|
+
|
|
126
|
+
# Create a new class that inherits from self with eager loading enabled
|
|
127
|
+
included_class = Class.new(self)
|
|
128
|
+
|
|
129
|
+
# Store the connections to include
|
|
130
|
+
included_class.instance_variable_set(:@included_connections, all_included_connections)
|
|
131
|
+
|
|
132
|
+
# Override methods to use eager loading
|
|
133
|
+
included_class.define_singleton_method(:default_loader) do
|
|
134
|
+
@default_loader ||= superclass.default_loader.class.new(
|
|
135
|
+
superclass,
|
|
136
|
+
included_connections: @included_connections
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Preserve the original class name and model name for GraphQL operations
|
|
141
|
+
included_class.define_singleton_method(:name) { superclass.name }
|
|
142
|
+
included_class.define_singleton_method(:model_name) { superclass.model_name }
|
|
143
|
+
included_class.define_singleton_method(:connections) { superclass.connections }
|
|
144
|
+
|
|
145
|
+
included_class
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def validate_includes_connections!(connection_names)
|
|
151
|
+
connection_names.each do |name|
|
|
152
|
+
if name.is_a?(Hash)
|
|
153
|
+
name.each do |key, value|
|
|
154
|
+
raise ArgumentError, "Invalid connection for #{self.name}: #{key}. Available connections: #{connections.keys.join(', ')}" unless connections.key?(key.to_sym)
|
|
155
|
+
|
|
156
|
+
# Recursively validate nested connections
|
|
157
|
+
target_class = connections[key.to_sym][:class_name].constantize
|
|
158
|
+
if target_class.respond_to?(:validate_includes_connections!, true)
|
|
159
|
+
nested_names = value.is_a?(Array) ? value : [value]
|
|
160
|
+
target_class.send(:validate_includes_connections!, nested_names)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
else
|
|
164
|
+
raise ArgumentError, "Invalid connection for #{self.name}: #{name}. Available connections: #{connections.keys.join(', ')}" unless connections.key?(name.to_sym)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
module FinderMethods
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
class_methods do
|
|
8
|
+
# Find a single record by ID using the provided loader
|
|
9
|
+
# @param id [String, Integer] The record ID (will be converted to GID automatically)
|
|
10
|
+
# @param loader [ActiveShopifyGraphQL::Loader] The loader to use for fetching data
|
|
11
|
+
# @return [Object, nil] The model instance or nil if not found
|
|
12
|
+
def find(id, loader: default_loader)
|
|
13
|
+
gid = GidHelper.normalize_gid(id, model_name.name.demodulize)
|
|
14
|
+
attributes = loader.load_attributes(gid)
|
|
15
|
+
|
|
16
|
+
return nil if attributes.nil?
|
|
17
|
+
|
|
18
|
+
new(attributes)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns the default loader for this model's queries
|
|
22
|
+
# @return [ActiveGraphQL::Loader] The default loader instance
|
|
23
|
+
def default_loader
|
|
24
|
+
if respond_to?(:default_loader_instance)
|
|
25
|
+
default_loader_instance
|
|
26
|
+
else
|
|
27
|
+
@default_loader ||= begin
|
|
28
|
+
# Collect connections with eager_load: true
|
|
29
|
+
eagerly_loaded_connections = connections.select { |_name, config| config[:eager_load] }.keys
|
|
30
|
+
|
|
31
|
+
default_loader_class.new(
|
|
32
|
+
self,
|
|
33
|
+
included_connections: eagerly_loaded_connections
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Allows setting a custom default loader (useful for testing)
|
|
40
|
+
# @param loader [ActiveGraphQL::Loader] The loader to set as default
|
|
41
|
+
def default_loader=(loader)
|
|
42
|
+
@default_loader = loader
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Select specific attributes to optimize GraphQL queries
|
|
46
|
+
# @param *attributes [Symbol] The attributes to select
|
|
47
|
+
# @return [Class] A class with modified default loader for method chaining
|
|
48
|
+
#
|
|
49
|
+
# @example
|
|
50
|
+
# Customer.select(:id, :email).find(123)
|
|
51
|
+
# Customer.select(:id, :email).where(first_name: "John")
|
|
52
|
+
def select(*attributes)
|
|
53
|
+
# Validate attributes exist
|
|
54
|
+
attrs = Array(attributes).flatten.map(&:to_sym)
|
|
55
|
+
validate_select_attributes!(attrs)
|
|
56
|
+
|
|
57
|
+
# Create a new class that inherits from self with a modified default loader
|
|
58
|
+
selected_class = Class.new(self)
|
|
59
|
+
|
|
60
|
+
# Override the default_loader method to return a loader with selected attributes
|
|
61
|
+
selected_class.define_singleton_method(:default_loader) do
|
|
62
|
+
@default_loader ||= superclass.default_loader.class.new(
|
|
63
|
+
superclass,
|
|
64
|
+
selected_attributes: attrs
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Preserve the original class name and model name for GraphQL operations
|
|
69
|
+
selected_class.define_singleton_method(:name) { superclass.name }
|
|
70
|
+
selected_class.define_singleton_method(:model_name) { superclass.model_name }
|
|
71
|
+
|
|
72
|
+
selected_class
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Query for multiple records using attribute conditions
|
|
76
|
+
# @param conditions [Hash] The conditions to query (e.g., { email: "example@test.com", first_name: "John" })
|
|
77
|
+
# @param options [Hash] Options hash containing loader and limit (when first arg is a Hash)
|
|
78
|
+
# @option options [ActiveShopifyGraphQL::Loader] :loader The loader to use for fetching data
|
|
79
|
+
# @option options [Integer] :limit The maximum number of records to return (default: 250, max: 250)
|
|
80
|
+
# @return [Array<Object>] Array of model instances
|
|
81
|
+
# @raise [ArgumentError] If any attribute is not valid for querying
|
|
82
|
+
#
|
|
83
|
+
# @example
|
|
84
|
+
# # Keyword argument style (recommended)
|
|
85
|
+
# Customer.where(email: "john@example.com")
|
|
86
|
+
# Customer.where(first_name: "John", country: "Canada")
|
|
87
|
+
# Customer.where(orders_count: { gte: 5 })
|
|
88
|
+
# Customer.where(created_at: { gte: "2024-01-01", lt: "2024-02-01" })
|
|
89
|
+
#
|
|
90
|
+
# # Hash style with options
|
|
91
|
+
# Customer.where({ email: "john@example.com" }, loader: custom_loader, limit: 100)
|
|
92
|
+
def where(conditions_or_first_condition = {}, *args, **options)
|
|
93
|
+
# Handle both syntaxes:
|
|
94
|
+
# where(email: "john@example.com") - keyword args become options
|
|
95
|
+
# where({ email: "john@example.com" }, loader: custom_loader) - explicit hash + options
|
|
96
|
+
if conditions_or_first_condition.is_a?(Hash) && !conditions_or_first_condition.empty?
|
|
97
|
+
# Explicit hash provided as first argument
|
|
98
|
+
conditions = conditions_or_first_condition
|
|
99
|
+
# Any additional options passed as keyword args or second hash argument
|
|
100
|
+
final_options = args.first.is_a?(Hash) ? options.merge(args.first) : options
|
|
101
|
+
else
|
|
102
|
+
# Keyword arguments style - conditions come from options, excluding known option keys
|
|
103
|
+
known_option_keys = %i[loader limit]
|
|
104
|
+
conditions = options.except(*known_option_keys)
|
|
105
|
+
final_options = options.slice(*known_option_keys)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
loader = final_options[:loader] || default_loader
|
|
109
|
+
limit = final_options[:limit] || 250
|
|
110
|
+
|
|
111
|
+
# Ensure loader has model class set - needed for graphql_type inference
|
|
112
|
+
loader.instance_variable_set(:@model_class, self) if loader.instance_variable_get(:@model_class).nil?
|
|
113
|
+
|
|
114
|
+
attributes_array = loader.load_collection(conditions, limit: limit)
|
|
115
|
+
|
|
116
|
+
attributes_array.map { |attributes| new(attributes) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# Validates that selected attributes exist in the model
|
|
122
|
+
# @param attributes [Array<Symbol>] The attributes to validate
|
|
123
|
+
# @raise [ArgumentError] If any attribute is invalid
|
|
124
|
+
def validate_select_attributes!(attributes)
|
|
125
|
+
return if attributes.empty?
|
|
126
|
+
|
|
127
|
+
available_attrs = available_select_attributes
|
|
128
|
+
invalid_attrs = attributes - available_attrs
|
|
129
|
+
|
|
130
|
+
return unless invalid_attrs.any?
|
|
131
|
+
|
|
132
|
+
raise ArgumentError, "Invalid attributes for #{name}: #{invalid_attrs.join(', ')}. " \
|
|
133
|
+
"Available attributes are: #{available_attrs.join(', ')}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Gets all available attributes for selection
|
|
137
|
+
# @return [Array<Symbol>] Available attribute names
|
|
138
|
+
def available_select_attributes
|
|
139
|
+
attrs = []
|
|
140
|
+
|
|
141
|
+
# Get attributes from the model class
|
|
142
|
+
loader_class = default_loader.class
|
|
143
|
+
model_attrs = attributes_for_loader(loader_class)
|
|
144
|
+
attrs.concat(model_attrs.keys)
|
|
145
|
+
|
|
146
|
+
# Get attributes from the loader class
|
|
147
|
+
loader_attrs = default_loader.class.defined_attributes
|
|
148
|
+
attrs.concat(loader_attrs.keys)
|
|
149
|
+
|
|
150
|
+
attrs.map(&:to_sym).uniq.sort
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|