active_shopify_graphql 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -0
  3. data/README.md +187 -56
  4. data/lib/active_shopify_graphql/configuration.rb +2 -15
  5. data/lib/active_shopify_graphql/connections/connection_loader.rb +141 -0
  6. data/lib/active_shopify_graphql/gid_helper.rb +2 -0
  7. data/lib/active_shopify_graphql/graphql_associations.rb +245 -0
  8. data/lib/active_shopify_graphql/loader.rb +147 -126
  9. data/lib/active_shopify_graphql/loader_context.rb +2 -47
  10. data/lib/active_shopify_graphql/loader_proxy.rb +67 -0
  11. data/lib/active_shopify_graphql/loaders/admin_api_loader.rb +1 -17
  12. data/lib/active_shopify_graphql/loaders/customer_account_api_loader.rb +10 -26
  13. data/lib/active_shopify_graphql/model/associations.rb +94 -0
  14. data/lib/active_shopify_graphql/model/attributes.rb +48 -0
  15. data/lib/active_shopify_graphql/model/connections.rb +174 -0
  16. data/lib/active_shopify_graphql/model/finder_methods.rb +155 -0
  17. data/lib/active_shopify_graphql/model/graphql_type_resolver.rb +89 -0
  18. data/lib/active_shopify_graphql/model/loader_switchable.rb +79 -0
  19. data/lib/active_shopify_graphql/model/metafield_attributes.rb +59 -0
  20. data/lib/active_shopify_graphql/{base.rb → model.rb} +30 -16
  21. data/lib/active_shopify_graphql/model_builder.rb +53 -0
  22. data/lib/active_shopify_graphql/query/node/collection.rb +78 -0
  23. data/lib/active_shopify_graphql/query/node/connection.rb +16 -0
  24. data/lib/active_shopify_graphql/query/node/current_customer.rb +37 -0
  25. data/lib/active_shopify_graphql/query/node/field.rb +23 -0
  26. data/lib/active_shopify_graphql/query/node/fragment.rb +17 -0
  27. data/lib/active_shopify_graphql/query/node/nested_connection.rb +79 -0
  28. data/lib/active_shopify_graphql/query/node/raw.rb +15 -0
  29. data/lib/active_shopify_graphql/query/node/root_connection.rb +76 -0
  30. data/lib/active_shopify_graphql/query/node/single_record.rb +36 -0
  31. data/lib/active_shopify_graphql/query/node/singular.rb +16 -0
  32. data/lib/active_shopify_graphql/query/node.rb +95 -0
  33. data/lib/active_shopify_graphql/query/query_builder.rb +290 -0
  34. data/lib/active_shopify_graphql/query/relation.rb +424 -0
  35. data/lib/active_shopify_graphql/query/scope.rb +219 -0
  36. data/lib/active_shopify_graphql/response/page_info.rb +40 -0
  37. data/lib/active_shopify_graphql/response/paginated_result.rb +114 -0
  38. data/lib/active_shopify_graphql/response/response_mapper.rb +242 -0
  39. data/lib/active_shopify_graphql/search_query/hash_condition_formatter.rb +107 -0
  40. data/lib/active_shopify_graphql/search_query/parameter_binder.rb +68 -0
  41. data/lib/active_shopify_graphql/search_query/value_sanitizer.rb +24 -0
  42. data/lib/active_shopify_graphql/search_query.rb +34 -84
  43. data/lib/active_shopify_graphql/version.rb +1 -1
  44. data/lib/active_shopify_graphql.rb +30 -28
  45. metadata +47 -15
  46. data/lib/active_shopify_graphql/associations.rb +0 -90
  47. data/lib/active_shopify_graphql/attributes.rb +0 -50
  48. data/lib/active_shopify_graphql/connection_loader.rb +0 -96
  49. data/lib/active_shopify_graphql/connections.rb +0 -198
  50. data/lib/active_shopify_graphql/finder_methods.rb +0 -159
  51. data/lib/active_shopify_graphql/fragment_builder.rb +0 -228
  52. data/lib/active_shopify_graphql/graphql_type_resolver.rb +0 -91
  53. data/lib/active_shopify_graphql/includes_scope.rb +0 -48
  54. data/lib/active_shopify_graphql/loader_switchable.rb +0 -180
  55. data/lib/active_shopify_graphql/metafield_attributes.rb +0 -61
  56. data/lib/active_shopify_graphql/query_node.rb +0 -173
  57. data/lib/active_shopify_graphql/query_tree.rb +0 -225
  58. data/lib/active_shopify_graphql/response_mapper.rb +0 -249
@@ -0,0 +1,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) if should_log?
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
- type = graphql_type
24
- query = graphql_query(type)
25
-
26
- variables = type == 'Customer' ? {} : { id: id }
27
- response_data = perform_graphql_query(query, **variables)
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) if should_log?
34
+ log_query("Customer Account API", query, variables)
43
35
  client.query(query, variables)
44
36
  end
45
37
 
46
- private
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