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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveShopifyGraphQL
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -1,35 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_support'
4
- require 'active_support/inflector'
5
- require 'active_support/concern'
6
- require 'active_support/core_ext/object/blank'
7
- require 'active_model'
8
- require 'globalid'
3
+ require "active_support"
4
+ require "active_support/concern"
5
+ require "active_model"
6
+ require "active_model/attribute_assignment"
7
+ require "active_model/validations"
8
+ require "active_model/naming"
9
+ require "globalid"
9
10
 
10
- require_relative "active_shopify_graphql/version"
11
- require_relative "active_shopify_graphql/configuration"
12
- require_relative "active_shopify_graphql/gid_helper"
13
- require_relative "active_shopify_graphql/graphql_type_resolver"
14
- require_relative "active_shopify_graphql/loader_context"
15
- require_relative "active_shopify_graphql/query_node"
16
- require_relative "active_shopify_graphql/fragment_builder"
17
- require_relative "active_shopify_graphql/query_tree"
18
- require_relative "active_shopify_graphql/response_mapper"
19
- require_relative "active_shopify_graphql/connection_loader"
20
- require_relative "active_shopify_graphql/loader"
21
- require_relative "active_shopify_graphql/loaders/admin_api_loader"
22
- require_relative "active_shopify_graphql/loaders/customer_account_api_loader"
23
- require_relative "active_shopify_graphql/loader_switchable"
24
- require_relative "active_shopify_graphql/finder_methods"
25
- require_relative "active_shopify_graphql/associations"
26
- require_relative "active_shopify_graphql/includes_scope"
27
- require_relative "active_shopify_graphql/connections"
28
- require_relative "active_shopify_graphql/attributes"
29
- require_relative "active_shopify_graphql/metafield_attributes"
30
- require_relative "active_shopify_graphql/search_query"
31
- require_relative "active_shopify_graphql/base"
11
+ require "zeitwerk"
12
+ loader = Zeitwerk::Loader.for_gem
13
+ loader.inflector.inflect(
14
+ "active_shopify_graphql" => "ActiveShopifyGraphQL",
15
+ "graphql_associations" => "GraphQLAssociations"
16
+ )
17
+ loader.setup
32
18
 
33
19
  module ActiveShopifyGraphQL
34
20
  class Error < StandardError; end
21
+ class ObjectNotFoundError < Error; end
22
+
23
+ class << self
24
+ def configuration
25
+ @configuration ||= Configuration.new
26
+ end
27
+
28
+ def configure
29
+ yield(configuration)
30
+ end
31
+
32
+ # Reset configuration (useful for testing)
33
+ def reset_configuration!
34
+ @configuration = Configuration.new
35
+ end
36
+ end
35
37
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_shopify_graphql
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nicolò Rebughini
@@ -52,6 +52,20 @@ dependencies:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
54
  version: '1.3'
55
+ - !ruby/object:Gem::Dependency
56
+ name: zeitwerk
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '2.6'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '2.6'
55
69
  description: ActiveShopifyGraphQL provides an ActiveRecord-like interface for interacting
56
70
  with Shopify's GraphQL APIs, supporting both Admin API and Customer Account API
57
71
  with automatic query building and response mapping.
@@ -69,28 +83,46 @@ files:
69
83
  - README.md
70
84
  - Rakefile
71
85
  - lib/active_shopify_graphql.rb
72
- - lib/active_shopify_graphql/associations.rb
73
- - lib/active_shopify_graphql/attributes.rb
74
- - lib/active_shopify_graphql/base.rb
75
86
  - lib/active_shopify_graphql/configuration.rb
76
- - lib/active_shopify_graphql/connection_loader.rb
77
- - lib/active_shopify_graphql/connections.rb
87
+ - lib/active_shopify_graphql/connections/connection_loader.rb
78
88
  - lib/active_shopify_graphql/connections/connection_proxy.rb
79
- - lib/active_shopify_graphql/finder_methods.rb
80
- - lib/active_shopify_graphql/fragment_builder.rb
81
89
  - lib/active_shopify_graphql/gid_helper.rb
82
- - lib/active_shopify_graphql/graphql_type_resolver.rb
83
- - lib/active_shopify_graphql/includes_scope.rb
90
+ - lib/active_shopify_graphql/graphql_associations.rb
84
91
  - lib/active_shopify_graphql/loader.rb
85
92
  - lib/active_shopify_graphql/loader_context.rb
86
- - lib/active_shopify_graphql/loader_switchable.rb
93
+ - lib/active_shopify_graphql/loader_proxy.rb
87
94
  - lib/active_shopify_graphql/loaders/admin_api_loader.rb
88
95
  - lib/active_shopify_graphql/loaders/customer_account_api_loader.rb
89
- - lib/active_shopify_graphql/metafield_attributes.rb
90
- - lib/active_shopify_graphql/query_node.rb
91
- - lib/active_shopify_graphql/query_tree.rb
92
- - lib/active_shopify_graphql/response_mapper.rb
96
+ - lib/active_shopify_graphql/model.rb
97
+ - lib/active_shopify_graphql/model/associations.rb
98
+ - lib/active_shopify_graphql/model/attributes.rb
99
+ - lib/active_shopify_graphql/model/connections.rb
100
+ - lib/active_shopify_graphql/model/finder_methods.rb
101
+ - lib/active_shopify_graphql/model/graphql_type_resolver.rb
102
+ - lib/active_shopify_graphql/model/loader_switchable.rb
103
+ - lib/active_shopify_graphql/model/metafield_attributes.rb
104
+ - lib/active_shopify_graphql/model_builder.rb
105
+ - lib/active_shopify_graphql/query/node.rb
106
+ - lib/active_shopify_graphql/query/node/collection.rb
107
+ - lib/active_shopify_graphql/query/node/connection.rb
108
+ - lib/active_shopify_graphql/query/node/current_customer.rb
109
+ - lib/active_shopify_graphql/query/node/field.rb
110
+ - lib/active_shopify_graphql/query/node/fragment.rb
111
+ - lib/active_shopify_graphql/query/node/nested_connection.rb
112
+ - lib/active_shopify_graphql/query/node/raw.rb
113
+ - lib/active_shopify_graphql/query/node/root_connection.rb
114
+ - lib/active_shopify_graphql/query/node/single_record.rb
115
+ - lib/active_shopify_graphql/query/node/singular.rb
116
+ - lib/active_shopify_graphql/query/query_builder.rb
117
+ - lib/active_shopify_graphql/query/relation.rb
118
+ - lib/active_shopify_graphql/query/scope.rb
119
+ - lib/active_shopify_graphql/response/page_info.rb
120
+ - lib/active_shopify_graphql/response/paginated_result.rb
121
+ - lib/active_shopify_graphql/response/response_mapper.rb
93
122
  - lib/active_shopify_graphql/search_query.rb
123
+ - lib/active_shopify_graphql/search_query/hash_condition_formatter.rb
124
+ - lib/active_shopify_graphql/search_query/parameter_binder.rb
125
+ - lib/active_shopify_graphql/search_query/value_sanitizer.rb
94
126
  - lib/active_shopify_graphql/version.rb
95
127
  homepage: https://github.com/nebulab/active_shopify_graphql
96
128
  licenses:
@@ -1,90 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActiveShopifyGraphQL
4
- # Handles associations between ActiveShopifyGraphQL objects and ActiveRecord objects
5
- module Associations
6
- extend ActiveSupport::Concern
7
-
8
- included do
9
- class << self
10
- attr_accessor :associations
11
- end
12
-
13
- self.associations = {}
14
- end
15
-
16
- class_methods do
17
- def has_many(name, class_name: nil, foreign_key: nil, primary_key: nil)
18
- association_class_name = class_name || name.to_s.classify
19
- association_foreign_key = foreign_key || "shopify_#{model_name.element.downcase}_id"
20
- association_primary_key = primary_key || :id
21
-
22
- # Store association metadata
23
- associations[name] = {
24
- type: :has_many,
25
- class_name: association_class_name,
26
- foreign_key: association_foreign_key,
27
- primary_key: association_primary_key
28
- }
29
-
30
- # Define the association method
31
- define_method name do
32
- return @_association_cache[name] if @_association_cache&.key?(name)
33
-
34
- @_association_cache ||= {}
35
-
36
- primary_key_value = send(association_primary_key)
37
- return @_association_cache[name] = [] if primary_key_value.blank?
38
-
39
- # Extract numeric ID from Shopify GID if needed
40
- primary_key_value = primary_key_value.to_plain_id if primary_key_value.gid?
41
-
42
- association_class = association_class_name.constantize
43
- @_association_cache[name] = association_class.where(association_foreign_key => primary_key_value)
44
- end
45
-
46
- # Define the association setter method for testing/mocking
47
- define_method "#{name}=" do |value|
48
- @_association_cache ||= {}
49
- @_association_cache[name] = value
50
- end
51
- end
52
-
53
- def has_one(name, class_name: nil, foreign_key: nil, primary_key: nil)
54
- association_class_name = class_name || name.to_s.classify
55
- association_foreign_key = foreign_key || "shopify_#{model_name.element.downcase}_id"
56
- association_primary_key = primary_key || :id
57
-
58
- # Store association metadata
59
- associations[name] = {
60
- type: :has_one,
61
- class_name: association_class_name,
62
- foreign_key: association_foreign_key,
63
- primary_key: association_primary_key
64
- }
65
-
66
- # Define the association method
67
- define_method name do
68
- return @_association_cache[name] if @_association_cache&.key?(name)
69
-
70
- @_association_cache ||= {}
71
-
72
- primary_key_value = send(association_primary_key)
73
- return @_association_cache[name] = nil if primary_key_value.blank?
74
-
75
- # Extract numeric ID from Shopify GID if needed
76
- primary_key_value = primary_key_value.to_plain_id if primary_key_value.gid?
77
-
78
- association_class = association_class_name.constantize
79
- @_association_cache[name] = association_class.find_by(association_foreign_key => primary_key_value)
80
- end
81
-
82
- # Define the association setter method for testing/mocking
83
- define_method "#{name}=" do |value|
84
- @_association_cache ||= {}
85
- @_association_cache[name] = value
86
- end
87
- end
88
- end
89
- end
90
- end
@@ -1,50 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActiveShopifyGraphQL
4
- module Attributes
5
- extend ActiveSupport::Concern
6
-
7
- class_methods do
8
- # Define an attribute with automatic GraphQL path inference and type coercion.
9
- #
10
- # @param name [Symbol] The Ruby attribute name
11
- # @param path [String] The GraphQL field path (auto-inferred if not provided)
12
- # @param type [Symbol] The type for coercion (:string, :integer, :float, :boolean, :datetime)
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
- # @param raw_graphql [String] Raw GraphQL string to inject directly (escape hatch for unsupported features)
17
- def attribute(name, path: nil, type: :string, null: true, default: nil, transform: nil, raw_graphql: nil)
18
- path ||= infer_path(name)
19
- config = { path: path, type: type, null: null, default: default, transform: transform, raw_graphql: raw_graphql }
20
-
21
- if @current_loader_context
22
- # Store in loader-specific context
23
- @loader_contexts[@current_loader_context][name] = config
24
- else
25
- # Store in base attributes
26
- @base_attributes ||= {}
27
- @base_attributes[name] = config
28
- end
29
-
30
- # Always create attr_accessor
31
- attr_accessor name unless method_defined?(name) || method_defined?("#{name}=")
32
- end
33
-
34
- # Get attributes for a specific loader class, merging base with loader-specific overrides.
35
- def attributes_for_loader(loader_class)
36
- base = @base_attributes || {}
37
- overrides = @loader_contexts&.dig(loader_class) || {}
38
-
39
- base.merge(overrides) { |_key, base_val, override_val| base_val.merge(override_val) }
40
- end
41
-
42
- private
43
-
44
- # Infer GraphQL path from Ruby attribute name (snake_case -> camelCase)
45
- def infer_path(name)
46
- name.to_s.gsub(/_([a-z])/) { ::Regexp.last_match(1).upcase }
47
- end
48
- end
49
- end
50
- end
@@ -1,96 +0,0 @@
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.camelize(:lower)
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
@@ -1,198 +0,0 @@
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, inverse_of: nil, **options)
21
- connection(name, type: :singular, inverse_of: inverse_of, **options)
22
- end
23
-
24
- # Define a plural connection (returns a collection via edges)
25
- # @see #connection
26
- def has_many_connected(name, inverse_of: nil, **options)
27
- connection(name, type: :connection, inverse_of: inverse_of, **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
- # @param inverse_of [Symbol] The name of the inverse connection on the target model (optional)
40
- 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)
41
- # Infer defaults
42
- connection_class_name = class_name || name.to_s.classify
43
-
44
- # Set query_name - default to camelCase for nested fields
45
- connection_query_name = query_name || name.to_s.camelize(:lower)
46
-
47
- connection_loader_class = loader_class
48
-
49
- # Store connection metadata
50
- connections[name] = {
51
- class_name: connection_class_name,
52
- query_name: connection_query_name,
53
- foreign_key: foreign_key,
54
- loader_class: connection_loader_class,
55
- eager_load: eager_load,
56
- type: type,
57
- nested: true, # Always treated as nested (accessed via parent field)
58
- target_class_name: connection_class_name,
59
- original_name: name,
60
- default_arguments: default_arguments,
61
- inverse_of: inverse_of
62
- }
63
-
64
- # Validate inverse relationship if specified (validation is deferred to runtime)
65
- validate_inverse_of!(name, connection_class_name, inverse_of) if inverse_of
66
-
67
- # Define the connection method that returns a proxy
68
- define_method name do |**options|
69
- # Check if this connection was eager loaded
70
- return @_connection_cache[name] if @_connection_cache&.key?(name)
71
-
72
- config = self.class.connections[name]
73
- if config[:type] == :singular
74
- # Lazy load singular association
75
- loader_class = config[:loader_class] || self.class.default_loader.class
76
- target_class = config[:class_name].constantize
77
- loader = loader_class.new(target_class)
78
-
79
- # Load the record
80
- records = loader.load_connection_records(config[:query_name], options, self, config)
81
-
82
- # Populate inverse cache if inverse_of is specified
83
- populate_inverse_cache_for_connection(records, config, self)
84
-
85
- # Cache it
86
- @_connection_cache ||= {}
87
- @_connection_cache[name] = records
88
- records
89
- elsif options.empty?
90
- # If no runtime options are provided, reuse existing proxy if it exists
91
- @_connection_proxies ||= {}
92
- @_connection_proxies[name] ||= ConnectionProxy.new(
93
- parent: self,
94
- connection_name: name,
95
- connection_config: self.class.connections[name],
96
- options: options
97
- )
98
- else
99
- # Create a new proxy for custom options (don't cache these)
100
- ConnectionProxy.new(
101
- parent: self,
102
- connection_name: name,
103
- connection_config: self.class.connections[name],
104
- options: options
105
- )
106
- end
107
- end
108
-
109
- # Define setter method for testing/caching
110
- define_method "#{name}=" do |value|
111
- @_connection_cache ||= {}
112
- @_connection_cache[name] = value
113
- end
114
- end
115
-
116
- # Load records with eager-loaded connections
117
- # @param *connection_names [Symbol, Hash] The connection names to eager load
118
- # @return [Class] A modified class for method chaining
119
- #
120
- # @example
121
- # Customer.includes(:orders).find(123)
122
- # Customer.includes(:orders, :addresses).where(email: "john@example.com")
123
- # Order.includes(line_items: :variant)
124
- def includes(*connection_names)
125
- # Validate connections exist
126
- validate_includes_connections!(connection_names)
127
-
128
- # Collect connections with eager_load: true
129
- auto_included_connections = connections.select { |_name, config| config[:eager_load] }.keys
130
-
131
- # Merge manual and automatic connections
132
- all_included_connections = (connection_names + auto_included_connections).uniq
133
-
134
- # Create a scope object that holds the included connections
135
- IncludesScope.new(self, all_included_connections)
136
- end
137
-
138
- private
139
-
140
- def validate_inverse_of!(_name, _target_class_name, _inverse_name)
141
- # Validation is deferred until runtime when connections are actually used
142
- # This allows class definitions to be in any order
143
- # The validation logic will be checked when inverse cache is populated
144
- nil
145
- end
146
-
147
- def validate_includes_connections!(connection_names)
148
- connection_names.each do |name|
149
- if name.is_a?(Hash)
150
- name.each do |key, value|
151
- raise ArgumentError, "Invalid connection for #{self.name}: #{key}. Available connections: #{connections.keys.join(', ')}" unless connections.key?(key.to_sym)
152
-
153
- # Recursively validate nested connections
154
- target_class = connections[key.to_sym][:class_name].constantize
155
- if target_class.respond_to?(:validate_includes_connections!, true)
156
- nested_names = value.is_a?(Array) ? value : [value]
157
- target_class.send(:validate_includes_connections!, nested_names)
158
- end
159
- end
160
- else
161
- raise ArgumentError, "Invalid connection for #{self.name}: #{name}. Available connections: #{connections.keys.join(', ')}" unless connections.key?(name.to_sym)
162
- end
163
- end
164
- end
165
- end
166
-
167
- # Instance method to populate inverse cache for lazy-loaded connections
168
-
169
- def populate_inverse_cache_for_connection(records, connection_config, parent)
170
- return unless connection_config[:inverse_of]
171
- return if records.nil? || (records.is_a?(Array) && records.empty?)
172
-
173
- inverse_name = connection_config[:inverse_of]
174
- target_class = connection_config[:class_name].constantize
175
-
176
- # Ensure target class has the inverse connection defined
177
- return unless target_class.respond_to?(:connections) && target_class.connections[inverse_name]
178
-
179
- inverse_type = target_class.connections[inverse_name][:type]
180
- records_array = records.is_a?(Array) ? records : [records]
181
-
182
- records_array.each do |record|
183
- next unless record
184
-
185
- record.instance_variable_set(:@_connection_cache, {}) unless record.instance_variable_get(:@_connection_cache)
186
- cache = record.instance_variable_get(:@_connection_cache)
187
-
188
- cache[inverse_name] =
189
- if inverse_type == :singular
190
- parent
191
- else
192
- # For collection inverses, wrap parent in an array
193
- [parent]
194
- end
195
- end
196
- end
197
- end
198
- end