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.
Files changed (32) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/lint.yml +35 -0
  3. data/.github/workflows/test.yml +35 -0
  4. data/.rubocop.yml +50 -0
  5. data/AGENTS.md +53 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +544 -0
  8. data/Rakefile +8 -0
  9. data/lib/active_shopify_graphql/associations.rb +90 -0
  10. data/lib/active_shopify_graphql/attributes.rb +49 -0
  11. data/lib/active_shopify_graphql/base.rb +29 -0
  12. data/lib/active_shopify_graphql/configuration.rb +29 -0
  13. data/lib/active_shopify_graphql/connection_loader.rb +96 -0
  14. data/lib/active_shopify_graphql/connections/connection_proxy.rb +112 -0
  15. data/lib/active_shopify_graphql/connections.rb +170 -0
  16. data/lib/active_shopify_graphql/finder_methods.rb +154 -0
  17. data/lib/active_shopify_graphql/fragment_builder.rb +195 -0
  18. data/lib/active_shopify_graphql/gid_helper.rb +54 -0
  19. data/lib/active_shopify_graphql/graphql_type_resolver.rb +91 -0
  20. data/lib/active_shopify_graphql/loader.rb +183 -0
  21. data/lib/active_shopify_graphql/loader_context.rb +88 -0
  22. data/lib/active_shopify_graphql/loader_switchable.rb +121 -0
  23. data/lib/active_shopify_graphql/loaders/admin_api_loader.rb +32 -0
  24. data/lib/active_shopify_graphql/loaders/customer_account_api_loader.rb +71 -0
  25. data/lib/active_shopify_graphql/metafield_attributes.rb +61 -0
  26. data/lib/active_shopify_graphql/query_node.rb +160 -0
  27. data/lib/active_shopify_graphql/query_tree.rb +204 -0
  28. data/lib/active_shopify_graphql/response_mapper.rb +202 -0
  29. data/lib/active_shopify_graphql/search_query.rb +71 -0
  30. data/lib/active_shopify_graphql/version.rb +5 -0
  31. data/lib/active_shopify_graphql.rb +34 -0
  32. 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