diff --git a/.rubocop.yml b/.rubocop.yml index 90316ad..9af1424 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -3,3 +3,39 @@ Style/StringLiterals: Metrics/BlockLength: Enabled: false + +Style/Documentation: + Enabled: false + +Metrics/CyclomaticComplexity: + Enabled: false + +Metrics/AbcSize: + Enabled: false + +Metrics/MethodLength: + Enabled: false + +Metrics/ModuleLength: + Enabled: false + +Layout/LineLength: + Enabled: false + +Lint/MissingSuper: + Enabled: false + +Metrics/PerceivedComplexity: + Enabled: false + +Naming/PredicatePrefix: + Enabled: false + +Style/TrailingCommaInHashLiteral: + Enabled: false + +Style/TrailingCommaInArrayLiteral: + Enabled: false + +Metrics/ParameterLists: + Enabled: false diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..466eff7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,37 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- Core code lives in `lib/active_shopify_graphql/`, with entrypoint `lib/active_shopify_graphql.rb` and loaders/associations split into focused files. +- Tests sit in `spec/` using RSpec; shared config is in `spec/spec_helper.rb`. +- Executables and setup utilities are in `bin/` (`bin/setup`, `bin/console`); Rake tasks are defined in `Rakefile`. + +## Build, Test, and Development Commands +- `bundle install` — install dependencies (required before any other command). +- `bin/setup` — project bootstrap (bundler + any initial prep). +- `bundle exec rake spec` or just `bundle exec rspec` — run the test suite (default Rake task). +- `bundle exec rubocop` — lint/format check using `.rubocop.yml`. +- `bin/console` — interactive console with the gem loaded for quick experiments. + +## Coding Style & Naming Conventions +- Ruby 3.2+; prefer idiomatic Ruby with 2-space indentation and frozen string literals already enabled. +- Follow existing module/loader patterns under `ActiveShopifyGraphQL` (e.g., `AdminApiLoader`, `CustomerAccountApiLoader`). +- Keep loaders small: expose `fragment` and `map_response_to_attributes` for clarity. +- Use RuboCop defaults unless explicitly relaxed in `.rubocop.yml`; respect existing disables instead of re-enabling. + +## Testing Guidelines +- Framework: RSpec with documentation formatter (`.rspec`). +- Place specs under `spec/` and name files `*_spec.rb` matching the class/module under test. +- Do not use `let` or `before` blocks in specs; each test case should tell a complete story. +- Use verifying doubles instead of normal doubles. Prefer `{instance|class}_{double|spy}` to `double` or `spy` +- Prefer explicit model/loader fixtures; stub external Shopify calls rather than hitting the network. +- Aim to cover happy path and schema edge cases (missing attributes, nil fragments). Add regression specs with minimal fixtures. + +## Commit & Pull Request Guidelines +- Use short, imperative commit subjects (≈50 chars) with focused diffs; group related changes together. +- Reference issues in commit messages or PR bodies when relevant. +- PRs should include: what changed, why, and how to test (commands run, expected outcomes). Add screenshots only if user-facing behavior is affected. +- Ensure CI-critical commands (`bundle exec rake spec`, `bundle exec rubocop`) pass locally before opening a PR. + +## Security & Configuration Tips +- Verify Admin vs Customer Account API client selection when adding loaders; avoid leaking tokens between contexts. +- If introducing new configuration knobs, document defaults and required environment variables in `README.md` and add minimal validation in configuration objects. diff --git a/README.md b/README.md index 3a9bb2b..675a53d 100644 --- a/README.md +++ b/README.md @@ -52,13 +52,20 @@ end ### Basic Model Setup -Create a model that includes `ActiveShopifyGraphQL::Base`: +Create a model that includes `ActiveShopifyGraphQL::Base` and define attributes directly: ```ruby class Customer include ActiveShopifyGraphQL::Base - attr_accessor :id, :name, :email, :created_at + # Define the GraphQL type + graphql_type "Customer" + + # Define attributes with automatic GraphQL path inference and type coercion + attribute :id, type: :string + attribute :name, path: "displayName", type: :string + attribute :email, path: "defaultEmailAddress.emailAddress", type: :string + attribute :created_at, type: :datetime validates :id, presence: true @@ -68,38 +75,95 @@ class Customer end ``` -### Creating Loaders +### Defining Attributes + +Attributes are now defined directly in the model class using the `attribute` method. The GraphQL fragments and response mapping are automatically generated! -Create loader classes to define how to fetch and map data from Shopify's GraphQL APIs: +#### Basic Attribute Definition ```ruby -# For Admin API -module ActiveShopifyGraphQL::Loaders::AdminApi - class CustomerLoader < ActiveShopifyGraphQL::AdminApiLoader - def fragment - <<~GRAPHQL - fragment CustomerFragment on Customer { - id - displayName - defaultEmailAddress { - emailAddress - } - createdAt - } - GRAPHQL - end - - def map_response_to_attributes(response_data) - customer_data = response_data.dig("data", "customer") - return nil unless customer_data - - { - id: customer_data["id"], - name: customer_data["displayName"], - email: customer_data.dig("defaultEmailAddress", "emailAddress"), - created_at: customer_data["createdAt"] - } - end +class Customer + include ActiveShopifyGraphQL::Base + + graphql_type "Customer" + + # Define attributes with automatic GraphQL path inference and type coercion + attribute :id, type: :string + attribute :name, path: "displayName", type: :string + attribute :email, path: "defaultEmailAddress.emailAddress", type: :string + attribute :created_at, type: :datetime + + # Custom transform example + attribute :tags, type: :string, transform: ->(tags_array) { tags_array.join(", ") } +end +``` + +#### Attribute Definition Options + +The `attribute` method supports several options for flexibility: + +```ruby +attribute :name, + path: "displayName", # Custom GraphQL path (auto-inferred if omitted) + type: :string, # Type coercion (:string, :integer, :float, :boolean, :datetime) + null: false, # Whether the attribute can be null (default: true) + transform: ->(value) { value.upcase } # Custom transformation block +``` + +**Auto-inference:** When `path` is omitted, it's automatically inferred by converting snake_case to camelCase (e.g., `display_name` → `displayName`). + +**Nested paths:** Use dot notation for nested GraphQL fields (e.g., `"defaultEmailAddress.emailAddress"`). + +**Type coercion:** Automatic conversion using ActiveModel types ensures type safety. + +**Array handling:** Arrays are automatically preserved regardless of the specified type. + +#### Metafield Attributes + +Shopify metafields can be easily accessed using the `metafield_attribute` method: + +```ruby +class Product + include ActiveShopifyGraphQL::Base + + graphql_type "Product" + + # Regular attributes + attribute :id, type: :string + attribute :title, type: :string + + # Metafield attributes + metafield_attribute :boxes_available, namespace: 'custom', key: 'available_boxes', type: :integer + metafield_attribute :seo_description, namespace: 'seo', key: 'meta_description', type: :string + metafield_attribute :product_data, namespace: 'custom', key: 'data', type: :json + metafield_attribute :is_featured, namespace: 'custom', key: 'featured', type: :boolean, null: false +end +``` + +The metafield attributes automatically generate the correct GraphQL syntax and handle value extraction from either `value` or `jsonValue` fields based on the type. + +#### API-Specific Attributes + +For models that need different attributes depending on the API being used, you can define loader-specific overrides: + +```ruby +class Customer + include ActiveShopifyGraphQL::Base + + graphql_type "Customer" + + # Default attributes (used by Admin API) + attribute :id, type: :string + attribute :name, path: "displayName", type: :string + attribute :email, path: "defaultEmailAddress.emailAddress", type: :string + attribute :created_at, type: :datetime + + # Customer Account API uses different field names + for_loader ActiveShopifyGraphQL::CustomerAccountApiLoader do + attribute :name, path: "firstName", type: :string + attribute :last_name, path: "lastName", type: :string + attribute :email, path: "emailAddress.emailAddress", type: :string + attribute :phone, path: "phoneNumber.phoneNumber", type: :string, null: true end end ``` @@ -109,11 +173,10 @@ end Use the `find` method to retrieve records by ID: ```ruby -# Using default loader (Admin API) +# Using Admin API (default) customer = Customer.find("gid://shopify/Customer/123456789") - -# Using specific loader -customer = Customer.find("gid://shopify/Customer/123456789", loader: custom_loader) +# You can also use just the ID number +customer = Customer.find(123456789) # Using Customer Account API customer = Customer.with_customer_account_api(token).find @@ -134,6 +197,63 @@ customer = Customer.with_customer_account_api(token).find customer = Customer.with_admin_api.find(id) ``` +### Querying Records + +Use the `where` method to query multiple records using Shopify's search syntax: + +```ruby +# Simple conditions +customers = Customer.where(email: "john@example.com") + +# Range queries +customers = Customer.where(created_at: { gte: "2024-01-01", lt: "2024-02-01" }) +customers = Customer.where(orders_count: { gte: 5 }) + +# Multi-word values are automatically quoted +customers = Customer.where(first_name: "John Doe") + +# With limits +customers = Customer.where({ email: "john@example.com" }, limit: 100) +``` + +The `where` method automatically converts Ruby conditions into Shopify's GraphQL query syntax and validates that the query fields are supported by Shopify. + +### Optimizing Queries with Select + +Use the `select` method to only fetch specific attributes, reducing GraphQL query size and improving performance: + +```ruby +# Only fetch id, name, and email +customer = Customer.select(:id, :name, :email).find(123) + +# Works with where queries too +customers = Customer.select(:id, :name).where(country: "Canada") + +# Always includes id even if not specified +customer = Customer.select(:name).find(123) +# This will still include :id in the GraphQL query +``` + +The `select` method validates that the specified attributes exist and automatically includes the `id` field for proper object identification. + +### Optimizing Queries with Select + +Use the `select` method to only fetch specific attributes, reducing GraphQL query size and improving performance: + +```ruby +# Only fetch id, name, and email +customer = Customer.select(:id, :name, :email).find(123) + +# Works with where queries too +customers = Customer.select(:id, :name).where(country: "Canada") + +# Always includes id even if not specified +customer = Customer.select(:name).find(123) +# This will still include :id in the GraphQL query +``` + +The `select` method validates that the specified attributes exist and automatically includes the `id` field for proper object identification. + ## Associations ActiveShopifyGraphQL provides ActiveRecord-like associations to define relationships between the Shopify native models and your own custom ones. @@ -146,7 +266,12 @@ Use `has_many` to define one-to-many relationships: class Customer include ActiveShopifyGraphQL::Base - attr_accessor :id, :display_name, :email, :created_at + graphql_type "Customer" + + attribute :id, type: :string + attribute :display_name, type: :string + attribute :email, path: "defaultEmailAddress.emailAddress", type: :string + attribute :created_at, type: :datetime # Define an association to one of your own ActiveRecord models # foreign_key maps the id of the GraphQL powered model to the rewards.shopify_customer_id table @@ -154,20 +279,12 @@ class Customer validates :id, presence: true end - -class Order - include ActiveShopifyGraphQL::Base - - attr_accessor :id, :name, :shopify_customer_id, :created_at - - validates :id, presence: true -end ``` #### Using the Association ```ruby -customer = Customer.find("gid://shopify/Customer/123456789") +customer = Customer.find("gid://shopify/Customer/123456789") # or Customer.find(123456789) # Access associated orders (lazy loaded) customer.rewards @@ -191,8 +308,14 @@ The associations automatically handle Shopify GID format conversion, extracting ## Next steps -- [ ] Support `Model.where(param: value)` proxying params to the GraphQL query attribute +- [x] Support `Model.where(param: value)` proxying params to the GraphQL query attribute +- [x] Attribute-based model definition with automatic GraphQL fragment generation +- [x] Metafield attributes for easy access to Shopify metafields +- [x] Query optimization with `select` method - [ ] Eager loading of GraphQL connections via `Customer.includes(:orders).find(id)` in a single GraphQL query +- [ ] Better error handling and retry mechanisms for GraphQL API calls +- [ ] Caching layer for frequently accessed data +- [ ] Support for GraphQL subscriptions ## Development @@ -200,7 +323,7 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run ## Contributing -Bug reports and pull requests are welcome on GitHub at https://github.com/team-cometeer/active_shopify_graphql. +Bug reports and pull requests are welcome on GitHub at https://github.com/nebulab/active_shopify_graphql. ## License diff --git a/lib/active_shopify_graphql.rb b/lib/active_shopify_graphql.rb index ee71de4..8dcb0ae 100644 --- a/lib/active_shopify_graphql.rb +++ b/lib/active_shopify_graphql.rb @@ -1,5 +1,12 @@ # frozen_string_literal: true +require 'active_support' +require 'active_support/inflector' +require 'active_support/concern' +require 'active_support/core_ext/object/blank' +require 'active_model' +require 'globalid' + require_relative "active_shopify_graphql/version" require_relative "active_shopify_graphql/configuration" require_relative "active_shopify_graphql/base" diff --git a/lib/active_shopify_graphql/admin_api_loader.rb b/lib/active_shopify_graphql/admin_api_loader.rb index daccd6c..dd1232a 100644 --- a/lib/active_shopify_graphql/admin_api_loader.rb +++ b/lib/active_shopify_graphql/admin_api_loader.rb @@ -2,6 +2,10 @@ module ActiveShopifyGraphQL class AdminApiLoader < Loader + def initialize(model_class = nil, selected_attributes: nil) + super(model_class, selected_attributes: selected_attributes) + end + private def execute_graphql_query(query, **variables) diff --git a/lib/active_shopify_graphql/associations.rb b/lib/active_shopify_graphql/associations.rb index 5e5c72e..a54466b 100644 --- a/lib/active_shopify_graphql/associations.rb +++ b/lib/active_shopify_graphql/associations.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveShopifyGraphQL module Associations extend ActiveSupport::Concern @@ -17,7 +19,7 @@ def has_many(name, class_name: nil, foreign_key: nil, primary_key: nil) association_primary_key = primary_key || :id # Store association metadata - self.associations[name] = { + associations[name] = { type: :has_many, class_name: association_class_name, foreign_key: association_foreign_key, @@ -34,9 +36,7 @@ def has_many(name, class_name: nil, foreign_key: nil, primary_key: nil) return @_association_cache[name] = [] if primary_key_value.blank? # Extract numeric ID from Shopify GID if needed - if primary_key_value.gid? - primary_key_value = primary_key_value.to_plain_id - end + primary_key_value = primary_key_value.to_plain_id if primary_key_value.gid? association_class = association_class_name.constantize @_association_cache[name] = association_class.where(association_foreign_key => primary_key_value) @@ -55,7 +55,7 @@ def has_one(name, class_name: nil, foreign_key: nil, primary_key: nil) association_primary_key = primary_key || :id # Store association metadata - self.associations[name] = { + associations[name] = { type: :has_one, class_name: association_class_name, foreign_key: association_foreign_key, @@ -72,9 +72,7 @@ def has_one(name, class_name: nil, foreign_key: nil, primary_key: nil) return @_association_cache[name] = nil if primary_key_value.blank? # Extract numeric ID from Shopify GID if needed - if primary_key_value.gid? - primary_key_value = primary_key_value.to_plain_id - end + primary_key_value = primary_key_value.to_plain_id if primary_key_value.gid? association_class = association_class_name.constantize @_association_cache[name] = association_class.find_by(association_foreign_key => primary_key_value) diff --git a/lib/active_shopify_graphql/base.rb b/lib/active_shopify_graphql/base.rb index e3789db..c634896 100644 --- a/lib/active_shopify_graphql/base.rb +++ b/lib/active_shopify_graphql/base.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveShopifyGraphQL module Base extend ActiveSupport::Concern @@ -12,6 +14,199 @@ module Base include ActiveShopifyGraphQL::LoaderSwitchable end + class_methods do + # Set or get the GraphQL type for this model + def graphql_type(type = nil, for_loader: nil) + if type + if for_loader + @loader_graphql_types ||= {} + @loader_graphql_types[for_loader] = type + else + @base_graphql_type = type + end + end + + @base_graphql_type || raise(NotImplementedError, "#{self} must define graphql_type") + end + + # Get GraphQL type for a specific loader + def graphql_type_for_loader(loader_class) + @loader_graphql_types&.dig(loader_class) || @base_graphql_type || loader_class.instance_variable_get(:@graphql_type) || raise(NotImplementedError, "#{self} must define graphql_type or #{loader_class} must define graphql_type") + end + + # @param name [Symbol] The Ruby attribute name + # @param path [String] The GraphQL field path (auto-inferred if not provided) + # @param type [Symbol] The type for coercion (:string, :integer, :float, :boolean, :datetime). Arrays are preserved automatically. + # @param null [Boolean] Whether the attribute can be null (default: true) + # @param default [Object] Default value to use when the GraphQL response is nil + # @param transform [Proc] Custom transform block for the value + def attribute(name, path: nil, type: :string, null: true, default: nil, transform: nil) + @base_attributes ||= {} + + # Auto-infer GraphQL path for simple cases: display_name -> displayName + path ||= infer_path(name) + + @base_attributes[name] = { + path: path, + type: type, + null: null, + default: default, + transform: transform + } + + # Create attr_accessor for the attribute + attr_accessor name unless method_defined?(name) || method_defined?("#{name}=") + end + + # Define a metafield attribute for this model + # @param name [Symbol] The Ruby attribute name + # @param namespace [String] The metafield namespace + # @param key [String] The metafield key + # @param type [Symbol] The type for coercion (:string, :integer, :float, :boolean, :datetime, :json). Arrays are preserved automatically. + # @param null [Boolean] Whether the attribute can be null (default: true) + # @param default [Object] Default value to use when the GraphQL response is nil + # @param transform [Proc] Custom transform block for the value + def metafield_attribute(name, namespace:, key:, type: :string, null: true, default: nil, transform: nil) + @base_attributes ||= {} + @metafields ||= {} + + # Store metafield metadata for special handling + @metafields[name] = { + namespace: namespace, + key: key, + type: type + } + + # Generate alias and path for metafield - use camelCase for GraphQL + alias_name = "#{infer_path(name)}Metafield" + value_field = type == :json ? 'jsonValue' : 'value' + path = "#{alias_name}.#{value_field}" + + @base_attributes[name] = { + path: path, + type: type, + null: null, + default: default, + transform: transform, + is_metafield: true, + metafield_alias: alias_name, + metafield_namespace: namespace, + metafield_key: key + } + + # Create attr_accessor for the attribute + attr_accessor name unless method_defined?(name) || method_defined?("#{name}=") + end + + # Define loader-specific attribute and graphql_type overrides + # @param loader_class [Class] The loader class to override attributes for + def for_loader(loader_class, &block) + @current_loader_context = loader_class + @loader_contexts ||= {} + @loader_contexts[loader_class] ||= {} + instance_eval(&block) if block_given? + @current_loader_context = nil + end + + # Get attributes for a specific loader class + def attributes_for_loader(loader_class) + base_attrs = @base_attributes || {} + loader_attrs = @loader_contexts&.dig(loader_class) || {} + + # Merge loader-specific overrides with base attributes + merged = base_attrs.dup + loader_attrs.each do |name, overrides| + merged[name] = if merged[name] + merged[name].merge(overrides) + else + overrides + end + end + + merged + end + + # Get all base attributes (without loader-specific overrides) + def base_attributes + @base_attributes || {} + end + + # Get metafields defined for this model + def metafields + @metafields || {} + end + + private + + # Override attribute method to handle loader context + def attribute_with_context(name, path: nil, type: :string, null: true, default: nil, transform: nil) + if @current_loader_context + # Auto-infer path if not provided + path ||= infer_path(name) + @loader_contexts[@current_loader_context][name] = { path: path, type: type, null: null, default: default, transform: transform } + else + attribute_without_context(name, path: path, type: type, null: null, default: default, transform: transform) + end + + # Always create attr_accessor for the attribute on base model + attr_accessor name unless method_defined?(name) || method_defined?("#{name}=") + end + + # Override graphql_type method to handle loader context + def graphql_type_with_context(type = nil, for_loader: nil) + if type && @current_loader_context + @loader_graphql_types ||= {} + @loader_graphql_types[@current_loader_context] = type + else + graphql_type_without_context(type, for_loader: for_loader) + end + end + + # Override metafield_attribute method to handle loader context + def metafield_attribute_with_context(name, **options) + if @current_loader_context + # For loader-specific metafields, we need to generate the full config + namespace = options[:namespace] + key = options[:key] + type = options[:type] || :string + + alias_name = "#{infer_path(name)}Metafield" + value_field = type == :json ? 'jsonValue' : 'value' + path = "#{alias_name}.#{value_field}" + + @loader_contexts[@current_loader_context][name] = { + path: path, + type: type, + null: options[:null] || true, + default: options[:default], + transform: options[:transform], + is_metafield: true, + metafield_alias: alias_name, + metafield_namespace: namespace, + metafield_key: key + } + else + metafield_attribute_without_context(name, **options) + end + end + + # Alias methods to support context handling + alias_method :attribute_without_context, :attribute + alias_method :attribute, :attribute_with_context + + alias_method :metafield_attribute_without_context, :metafield_attribute + alias_method :metafield_attribute, :metafield_attribute_with_context + + alias_method :graphql_type_without_context, :graphql_type + alias_method :graphql_type, :graphql_type_with_context + + # Infer GraphQL path from Ruby attribute name + # Only handles simple snake_case to camelCase conversion + def infer_path(name) + name.to_s.gsub(/_([a-z])/) { ::Regexp.last_match(1).upcase } + end + end + def initialize(attributes = {}) super() assign_attributes(attributes) diff --git a/lib/active_shopify_graphql/connections.rb b/lib/active_shopify_graphql/connections.rb index 24fab14..61c3358 100644 --- a/lib/active_shopify_graphql/connections.rb +++ b/lib/active_shopify_graphql/connections.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveShopifyGraphQL module Connections extend ActiveSupport::Concern @@ -11,47 +13,11 @@ class << self end class_methods do - def metafield(attribute_name, graphql_field: "metafield", target_class: "Shopify::GraphQL::Metafield") - # Define the metafield accessor method - define_method attribute_name do |namespace:, key:| - cache_key = "#{attribute_name}_#{namespace}_#{key}" - return @_metafield_cache[cache_key] if @_metafield_cache&.key?(cache_key) - - @_metafield_cache ||= {} - - # Build the metafield query - metafield_query = self.class.send(:build_metafield_query, graphql_field, target_class) - - # Execute the query - id_for_query = id.to_gid(self.class.model_name.name.demodulize) - variables = { id: id_for_query, namespace: namespace, key: key } - - response = ActiveShopifyGraphQL.configuration.admin_api_client.execute(metafield_query, **variables) - - # Parse the response - metafield_data = response.dig("data", self.class.finder_query_name, graphql_field) - - if metafield_data - target_class_const = target_class.constantize - @_metafield_cache[cache_key] = target_class_const.new(metafield_data) - else - @_metafield_cache[cache_key] = nil - end - end - - # Define a setter method for testing/mocking - define_method "#{attribute_name}=" do |value| - @_metafield_cache ||= {} - # Use a generic cache key for setter (since we don't have namespace/key) - @_metafield_cache["#{attribute_name}_test"] = value - end - end - - def connection(name, target_class: nil, arguments: {}, &block) + def connection(name, target_class: nil, arguments: {}) target_class_name = target_class&.to_s || name.to_s.singularize.classify # Store connection metadata - self.defined_connections[name] = { + defined_connections[name] = { target_class: target_class_name, arguments: arguments, field_name: name.to_s.camelize(:lower) @@ -104,22 +70,6 @@ def connection(name, target_class: nil, arguments: {}, &block) private - def build_metafield_query(graphql_field, target_class) - target_class_const = target_class.constantize - target_fragment = target_class_const.fragment - - <<~GRAPHQL - #{target_fragment} - query #{model_name.singular}Metafield($id: ID!, $namespace: String!, $key: String!) { - #{@finder_query_name || model_name.singular}(id: $id) { - #{graphql_field}(namespace: $namespace, key: $key) { - ...shopify_#{target_class_const.model_name.element.downcase}Fragment - } - } - } - GRAPHQL - end - def build_connection_query(connection_name, arguments) connection_info = defined_connections[connection_name] target_class = connection_info[:target_class].constantize @@ -166,11 +116,9 @@ def build_arguments_string(arguments) def build_variables_string(arguments) return "" if arguments.empty? - variables = arguments.map do |key, _value| + arguments.map do |key, _value| ", $#{key}: #{graphql_type_for_argument(key)}" end.join("") - - variables end def graphql_type_for_argument(key) @@ -224,11 +172,11 @@ def last nodes.last end - def has_next_page? + def next_page? page_info["hasNextPage"] end - def has_previous_page? + def previous_page? page_info["hasPreviousPage"] end diff --git a/lib/active_shopify_graphql/customer_account_api_loader.rb b/lib/active_shopify_graphql/customer_account_api_loader.rb index f79c431..9a58be2 100644 --- a/lib/active_shopify_graphql/customer_account_api_loader.rb +++ b/lib/active_shopify_graphql/customer_account_api_loader.rb @@ -2,27 +2,44 @@ module ActiveShopifyGraphQL class CustomerAccountApiLoader < Loader - def initialize(token) + def initialize(model_class = nil, token = nil, selected_attributes: nil) + super(model_class, selected_attributes: selected_attributes) @token = token end # Override to handle Customer queries that don't need an ID - def graphql_query(model_type = 'Customer') - if model_type == 'Customer' + def graphql_query(model_type = nil) + type = model_type || self.class.graphql_type + if type == 'Customer' # Customer Account API doesn't need ID for customer queries - token identifies the customer - customer_only_query(model_type) + customer_only_query(type) else # For other types, use the standard query with ID - super(model_type) + super(type) end end # Override load_attributes to handle the Customer case - def load_attributes(id = nil, model_type = 'Customer') - query = graphql_query(model_type) + def load_attributes(model_type_or_id = nil, id = nil) + # Handle both old and new signatures like the parent class + if id.nil? && model_type_or_id.is_a?(String) && model_type_or_id != self.class.graphql_type + # Old signature: load_attributes(model_type) + type = model_type_or_id + actual_id = nil + elsif id.nil? + # New signature: load_attributes() or load_attributes(id) - but for Customer, we don't need ID + type = self.class.graphql_type + actual_id = model_type_or_id + else + # Old signature: load_attributes(model_type, id) + type = model_type_or_id + actual_id = id + end + + query = graphql_query(type) # For Customer queries, we don't need variables; for others, we need the ID - variables = model_type == 'Customer' ? {} : { id: id } + variables = type == 'Customer' ? {} : { id: actual_id } response_data = execute_graphql_query(query, **variables) @@ -46,9 +63,10 @@ def execute_graphql_query(query, **variables) end # Builds a customer-only query (no ID parameter needed) - def customer_only_query(model_type) - query_name_value = query_name(model_type) - fragment_name_value = fragment_name(model_type) + def customer_only_query(model_type = nil) + type = model_type || self.class.graphql_type + query_name_value = query_name(type) + fragment_name_value = fragment_name(type) <<~GRAPHQL #{fragment} diff --git a/lib/active_shopify_graphql/finder_methods.rb b/lib/active_shopify_graphql/finder_methods.rb index 2cca197..718e5b7 100644 --- a/lib/active_shopify_graphql/finder_methods.rb +++ b/lib/active_shopify_graphql/finder_methods.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActiveShopifyGraphQL module FinderMethods extend ActiveSupport::Concern @@ -10,7 +12,7 @@ module FinderMethods def find(id, loader: default_loader) gid = URI::GID.build(app: "shopify", model_name: model_name.name.demodulize, model_id: id) model_type = name.demodulize - attributes = loader.load_attributes(gid, model_type) + attributes = loader.load_attributes(model_type, gid) return nil if attributes.nil? @@ -23,7 +25,7 @@ def default_loader if respond_to?(:default_loader_instance) default_loader_instance else - @default_loader ||= default_loader_class.new + @default_loader ||= default_loader_class.new(self) end end @@ -33,18 +35,129 @@ def default_loader=(loader) @default_loader = loader end + # Select specific attributes to optimize GraphQL queries + # @param *attributes [Symbol] The attributes to select + # @return [Class] A class with modified default loader for method chaining + # + # @example + # Customer.select(:id, :email).find(123) + # Customer.select(:id, :email).where(first_name: "John") + def select(*attributes) + # Validate attributes exist + attrs = Array(attributes).flatten.map(&:to_sym) + validate_select_attributes!(attrs) + + # Create a new class that inherits from self with a modified default loader + selected_class = Class.new(self) + + # Override the default_loader method to return a loader with selected attributes + selected_class.define_singleton_method(:default_loader) do + @selective_loader ||= superclass.default_loader.class.new( + superclass, + selected_attributes: attrs + ) + end + + # Preserve the original class name and model name for GraphQL operations + selected_class.define_singleton_method(:name) { superclass.name } + selected_class.define_singleton_method(:model_name) { superclass.model_name } + + selected_class + end + + # Query for multiple records using attribute conditions + # @param conditions [Hash] The conditions to query (e.g., { email: "example@test.com", first_name: "John" }) + # @param options [Hash] Options hash containing loader and limit (when first arg is a Hash) + # @option options [ActiveShopifyGraphQL::Loader] :loader The loader to use for fetching data + # @option options [Integer] :limit The maximum number of records to return (default: 250, max: 250) + # @return [Array] Array of model instances + # @raise [ArgumentError] If any attribute is not valid for querying + # + # @example + # # Keyword argument style (recommended) + # Customer.where(email: "john@example.com") + # Customer.where(first_name: "John", country: "Canada") + # Customer.where(orders_count: { gte: 5 }) + # Customer.where(created_at: { gte: "2024-01-01", lt: "2024-02-01" }) + # + # # Hash style with options + # Customer.where({ email: "john@example.com" }, loader: custom_loader, limit: 100) + def where(conditions_or_first_condition = {}, *args, **options) + # Handle both syntaxes: + # where(email: "john@example.com") - keyword args become options + # where({ email: "john@example.com" }, loader: custom_loader) - explicit hash + options + if conditions_or_first_condition.is_a?(Hash) && !conditions_or_first_condition.empty? + # Explicit hash provided as first argument + conditions = conditions_or_first_condition + # Any additional options passed as keyword args or second hash argument + final_options = args.first.is_a?(Hash) ? options.merge(args.first) : options + else + # Keyword arguments style - conditions come from options, excluding known option keys + known_option_keys = %i[loader limit] + conditions = options.except(*known_option_keys) + final_options = options.slice(*known_option_keys) + end + + loader = final_options[:loader] || default_loader + limit = final_options[:limit] || 250 + + model_type = name.demodulize + attributes_array = loader.load_collection(model_type, conditions, limit: limit) + + attributes_array.map { |attributes| new(attributes) } + end + private + # Validates that selected attributes exist in the model + # @param attributes [Array] The attributes to validate + # @raise [ArgumentError] If any attribute is invalid + def validate_select_attributes!(attributes) + return if attributes.empty? + + available_attrs = available_select_attributes + invalid_attrs = attributes - available_attrs + + return unless invalid_attrs.any? + + raise ArgumentError, "Invalid attributes for #{name}: #{invalid_attrs.join(', ')}. " \ + "Available attributes are: #{available_attrs.join(', ')}" + end + + # Gets all available attributes for selection + # @return [Array] Available attribute names + def available_select_attributes + attrs = [] + + # Get attributes from the model class + if respond_to?(:attributes_for_loader) + loader_class = default_loader.class + model_attrs = attributes_for_loader(loader_class) + attrs.concat(model_attrs.keys) + end + + # Get attributes from the loader class + if default_loader.respond_to?(:defined_attributes) + loader_attrs = default_loader.class.defined_attributes + attrs.concat(loader_attrs.keys) + end + + attrs.map(&:to_sym).uniq.sort + end + # Infers the loader class name from the model name # e.g., Customer -> ActiveGraphQL::CustomerLoader # @return [Class] The loader class def default_loader_class loader_class_name = "#{name}Loader" loader_class_name.constantize - rescue NameError => e - raise NameError, "Default loader class '#{loader_class_name}' not found for model '#{name}'. " \ - "Please create the loader class or override the default_loader method. " \ - "Original error: #{e.message}" + rescue NameError + # Fall back to the LoaderSwitchable's default_loader_class if inference fails + if respond_to?(:default_loader_class, true) + super + else + ActiveShopifyGraphQL::AdminApiLoader + end end end end diff --git a/lib/active_shopify_graphql/loader.rb b/lib/active_shopify_graphql/loader.rb index e934b0a..ad2c0bb 100644 --- a/lib/active_shopify_graphql/loader.rb +++ b/lib/active_shopify_graphql/loader.rb @@ -1,35 +1,221 @@ # frozen_string_literal: true +require 'active_model/type' + module ActiveShopifyGraphQL - class Loader - # Override this to define special behavior at loader initialization - def initialize(**) - # no-op + class Loader # rubocop:disable Metrics/ClassLength + class << self + # Set or get the GraphQL type for this loader + def graphql_type(type = nil) + return @graphql_type = type if type + + # Try to get GraphQL type from associated model class first + return model_class.graphql_type_for_loader(self) if model_class.respond_to?(:graphql_type_for_loader) + + @graphql_type || raise(NotImplementedError, "#{self} must define graphql_type or have an associated model with graphql_type") + end + + # Set or get the client type for this loader (:admin_api or :customer_account_api) + def client_type(type = nil) + return @client_type = type if type + + @client_type || :admin_api # Default to admin API + end + + # Get the model class associated with this loader + def model_class + @model_class ||= infer_model_class + end + + # Set the model class associated with this loader + attr_writer :model_class + + # For backward compatibility - loaders can still define attributes directly + def attribute(name, path: nil, type: :string, null: true, default: nil, transform: nil) + @attributes ||= {} + + # Auto-infer GraphQL path for simple cases: display_name -> displayName + path ||= infer_path(name) + + @attributes[name] = { + path: path, + type: type, + null: null, + default: default, + transform: transform + } + end + + # For backward compatibility - loaders can still define metafield attributes directly + def metafield_attribute(name, namespace:, key:, type: :string, null: true, default: nil, transform: nil) + @attributes ||= {} + @metafields ||= {} + + # Store metafield metadata for special handling + @metafields[name] = { + namespace: namespace, + key: key, + type: type + } + + # Generate alias and path for metafield + alias_name = "#{name}Metafield" + value_field = type == :json ? 'jsonValue' : 'value' + path = "#{alias_name}.#{value_field}" + + @attributes[name] = { + path: path, + type: type, + null: null, + default: default, + transform: transform, + is_metafield: true, + metafield_alias: alias_name, + metafield_namespace: namespace, + metafield_key: key + } + end + + # Get all defined attributes (includes both direct and model attributes) + def attributes + defined_attributes + end + + # Get all defined metafields (includes both direct and model metafields) + def metafields + defined_metafields + end + + # Get attributes from the model class for this loader + def defined_attributes + return @attributes || {} unless model_class.respond_to?(:attributes_for_loader) + + # Get attributes defined in the model for this loader class + model_attrs = model_class.attributes_for_loader(self) + direct_attrs = @attributes || {} + + # Merge direct loader attributes with model attributes (model takes precedence) + direct_attrs.merge(model_attrs) + end + + # Get metafields from the model class + def defined_metafields + return @metafields || {} unless model_class.respond_to?(:metafields) + + model_class.metafields + end + + # Set or get the GraphQL fragment fields for this loader + # Example: + # fragment <<~GRAPHQL + # id + # displayName + # createdAt + # defaultEmailAddress { + # emailAddress + # } + # tags + # GRAPHQL + def fragment(fields = nil) + return @fragment_fields = fields if fields + + @fragment_fields || raise(NotImplementedError, "#{self} must define fragment") + end + + private + + # Infer GraphQL path from Ruby attribute name + # Only handles simple snake_case to camelCase conversion + def infer_path(name) + name.to_s.camelize(:lower) + end + + # Infer the model class from the GraphQL type + # e.g., graphql_type "Customer" -> Customer + def infer_model_class + type = @graphql_type + return nil unless type + + # Try to find the class based on GraphQL type + begin + Object.const_get(type) + rescue NameError + # If not found, return nil - the model class may not exist yet + nil + end + end + end + + # Initialize loader with optional model class and selected attributes + def initialize(model_class = nil, selected_attributes: nil, **) + @model_class = model_class || self.class.model_class + @selected_attributes = selected_attributes&.map(&:to_sym) + end + + # Get GraphQL type for this loader instance + def graphql_type + if @model_class.respond_to?(:graphql_type_for_loader) + @model_class.graphql_type_for_loader(self.class) + else + self.class.graphql_type + end end - # Override this to define the GraphQL fragment for the model + # Get defined attributes for this loader instance + def defined_attributes + attrs = if @model_class.respond_to?(:attributes_for_loader) + @model_class.attributes_for_loader(self.class) + else + self.class.defined_attributes + end + + # Filter by selected attributes if specified + if @selected_attributes + selected_attrs = {} + (@selected_attributes + [:id]).uniq.each do |attr| + selected_attrs[attr] = attrs[attr] if attrs.key?(attr) + end + selected_attrs + else + attrs + end + end + + # Get defined metafields for this loader instance + def defined_metafields + if @model_class.respond_to?(:metafields) + @model_class.metafields + else + self.class.defined_metafields + end + end + + # Returns the complete GraphQL fragment built from class-level fragment fields def fragment - raise NotImplementedError, "#{self.class} must implement fragment" + build_fragment_from_fields end # Override this to define the query name (can accept model_type for customization) - def query_name(model_type = 'customer') - model_type.downcase + def query_name(model_type = nil) + type = model_type || graphql_type + type.downcase end # Override this to define the fragment name (can accept model_type for customization) - def fragment_name(model_type = 'Customer') - "#{model_type}Fragment" + def fragment_name(model_type = nil) + type = model_type || graphql_type + "#{type}Fragment" end # Builds the complete GraphQL query using the fragment - def graphql_query(model_type = 'Customer') - query_name_value = query_name(model_type) - fragment_name_value = fragment_name(model_type) + def graphql_query(model_type = nil) + type = model_type || graphql_type + query_name_value = query_name(type) + fragment_name_value = fragment_name(type) <<~GRAPHQL #{fragment} - query get#{model_type}($id: ID!) { + query get#{type}($id: ID!) { #{query_name_value}(id: $id) { ...#{fragment_name_value} } @@ -39,14 +225,29 @@ def graphql_query(model_type = 'Customer') # Override this to map the GraphQL response to model attributes def map_response_to_attributes(response_data) - raise NotImplementedError, "#{self.class} must implement map_response_to_attributes" + # Use attributes-based mapping if attributes are defined, otherwise require manual implementation + attrs = defined_attributes + raise NotImplementedError, "#{self.class} must implement map_response_to_attributes" unless attrs.any? + + map_response_from_attributes(response_data) end # Executes the GraphQL query and returns the mapped attributes hash # The model instantiation is handled by the calling code - def load_attributes(id, model_type = 'Customer') - query = graphql_query(model_type) - variables = { id: id } + def load_attributes(model_type_or_id, id = nil) + # Support both old signature (model_type, id) and new signature (id) + if id.nil? + # New signature: load_attributes(id) + actual_id = model_type_or_id + type = graphql_type + else + # Old signature: load_attributes(model_type, id) + type = model_type_or_id + actual_id = id + end + + query = graphql_query(type) + variables = { id: actual_id } response_data = execute_graphql_query(query, **variables) @@ -55,10 +256,326 @@ def load_attributes(id, model_type = 'Customer') map_response_to_attributes(response_data) end + # Executes a collection query using Shopify's search syntax and returns an array of mapped attributes + # @param conditions_or_model_type [Hash|String] The conditions to query or model type (for backwards compatibility) + # @param conditions_or_limit [Hash|Integer] The conditions or limit (for backwards compatibility) + # @param limit [Integer] The maximum number of records to return (default: 250, max: 250) + # @return [Array] Array of attribute hashes or empty array if none found + def load_collection(conditions_or_model_type = {}, conditions_or_limit = {}, limit: 250) + # Handle different method signatures for backwards compatibility + if conditions_or_model_type.is_a?(String) + # Old signature: load_collection(model_type, conditions = {}, limit: 250) + type = conditions_or_model_type + conditions = conditions_or_limit + actual_limit = limit + else + # New signature: load_collection(conditions = {}, limit: 250) + type = self.class.graphql_type + conditions = conditions_or_model_type + actual_limit = conditions_or_limit.is_a?(Integer) ? conditions_or_limit : limit + end + + query_string = build_shopify_query_string(conditions) + query = collection_graphql_query(type) + variables = { query: query_string, first: actual_limit } + + # Use existing fragment and response mapping + response = execute_graphql_query(query, **variables) + + # Check for search warnings/errors in extensions + if response.dig("extensions", "search") + warnings = response["extensions"]["search"].flat_map { |search| search["warnings"] || [] } + unless warnings.empty? + warning_messages = warnings.map { |w| "#{w['field']}: #{w['message']}" } + raise ArgumentError, "Shopify query validation failed: #{warning_messages.join(', ')}" + end + end + + # Use the existing collection mapping method + map_collection_response_to_attributes(response, type) + end + + # Build Shopify query string from Ruby conditions + + # Builds the GraphQL query for collections + # @param model_type [String] The model type (optional, uses class graphql_type if not provided) + # @param _limit [Integer] The maximum number of records to return (unused, handled via variables) + # @return [String] The GraphQL query string + def collection_graphql_query(model_type = nil, _limit = 250) + type = model_type || self.class.graphql_type + query_name_value = query_name(type).pluralize + fragment_name_value = fragment_name(type) + + <<~GRAPHQL + #{fragment} + query get#{type.pluralize}($query: String, $first: Int!) { + #{query_name_value}(query: $query, first: $first) { + nodes { + ...#{fragment_name_value} + } + } + } + GRAPHQL + end + + # Override this to map collection GraphQL responses to model attributes + # @param response_data [Hash] The GraphQL response data + # @param model_type [String] The model type (optional, uses class graphql_type if not provided) + # @return [Array] Array of attribute hashes + def map_collection_response_to_attributes(response_data, model_type = nil) + type = model_type || self.class.graphql_type + query_name_value = query_name(type).pluralize + nodes = response_data.dig("data", query_name_value, "nodes") + + return [] unless nodes&.any? + + nodes.map do |node_data| + # Create a response structure similar to single record queries + single_response = { "data" => { query_name(type) => node_data } } + map_response_to_attributes(single_response) + end.compact + end + private - def execute_graphql_query - raise NotImplementedError, "#{self.class} must implement execute_graphql_query" + # Builds the complete fragment from class-level fragment fields or declared attributes + def build_fragment_from_fields + type = graphql_type + fragment_name_value = fragment_name(type) + + # Use attributes-based fragment if attributes are defined, otherwise fall back to manual fragment + fragment_fields = if defined_attributes.any? + build_fragment_from_attributes + else + self.class.fragment + end + + <<~GRAPHQL + fragment #{fragment_name_value} on #{type} { + #{fragment_fields.strip} + } + GRAPHQL + end + + # Build GraphQL fragment fields from declared attributes with path merging + def build_fragment_from_attributes + path_tree = {} + metafield_aliases = {} + + # Build a tree structure for nested paths + defined_attributes.each_value do |config| + if config[:is_metafield] + # Handle metafield attributes specially + alias_name = config[:metafield_alias] + namespace = config[:metafield_namespace] + key = config[:metafield_key] + value_field = config[:type] == :json ? 'jsonValue' : 'value' + + # Store metafield definition for later insertion + metafield_aliases[alias_name] = { + namespace: namespace, + key: key, + value_field: value_field + } + else + # Handle regular attributes + path_parts = config[:path].split('.') + current_level = path_tree + + path_parts.each_with_index do |part, index| + if index == path_parts.length - 1 + # Leaf node - store as string + current_level[part] = true + else + # Branch node - ensure it's a hash + current_level[part] ||= {} + current_level = current_level[part] + end + end + end + end + + # Build fragment from regular attributes + regular_fields = build_graphql_from_tree(path_tree, 0) + + # Build metafield fragments + metafield_fragments = metafield_aliases.map do |alias_name, config| + " #{alias_name}: metafield(namespace: \"#{config[:namespace]}\", key: \"#{config[:key]}\") {\n #{config[:value_field]}\n }" + end + + # Combine regular fields and metafield fragments + [regular_fields, metafield_fragments].flatten.compact.reject(&:empty?).join("\n") + end + + # Convert path tree to GraphQL syntax with proper indentation + def build_graphql_from_tree(tree, indent_level) + indent = " " * indent_level + + tree.map do |key, value| + if value == true + # Leaf node - simple field + "#{indent}#{key}" + else + # Branch node - nested selection + nested_fields = build_graphql_from_tree(value, indent_level + 1) + "#{indent}#{key} {\n#{nested_fields}\n#{indent}}" + end + end.join("\n") + end + + # Map GraphQL response to attributes using declared attribute metadata + def map_response_from_attributes(response_data) + type = graphql_type + query_name_value = query_name(type) + root_data = response_data.dig("data", query_name_value) + return {} unless root_data + + result = {} + defined_attributes.each do |attr_name, config| + path = config[:path] + path_parts = path.split('.') + + # Use dig to safely extract the value + value = root_data.dig(*path_parts) + + # Handle nil values with defaults or transforms + if value.nil? + # Use default value if provided (more efficient than transform for simple defaults) + if !config[:default].nil? + value = config[:default] + elsif config[:transform] + # Only call transform if no default is provided + value = config[:transform].call(value) + end + elsif config[:transform] + # Apply transform to non-nil values + value = config[:transform].call(value) + end + + # Validate null constraint after applying defaults/transforms + raise ArgumentError, "Attribute '#{attr_name}' (GraphQL path: '#{path}') cannot be null but received nil" if !config[:null] && value.nil? + + # Apply type coercion + result[attr_name] = value.nil? ? nil : coerce_value(value, config[:type], attr_name, path) + end + + result + end + + # Coerce a value to the specified type using ActiveSupport's type system + def coerce_value(value, type, attr_name, path) + # Automatically preserve arrays regardless of specified type + return value if value.is_a?(Array) + + type_caster = get_type_caster(type) + type_caster.cast(value) + rescue ArgumentError, TypeError => e + raise ArgumentError, "Type conversion failed for attribute '#{attr_name}' (GraphQL path: '#{path}') to #{type}: #{e.message}" + end + + # Get the appropriate ActiveModel::Type caster for the given type + def get_type_caster(type) + case type + when :string + ActiveModel::Type::String.new + when :integer + ActiveModel::Type::Integer.new + when :float + ActiveModel::Type::Float.new + when :boolean + ActiveModel::Type::Boolean.new + when :datetime + ActiveModel::Type::DateTime.new + + else + # For unknown types, use a pass-through type that returns the value as-is + ActiveModel::Type::Value.new + end + end + + def execute_graphql_query(query, **variables) + case self.class.client_type + when :admin_api + client = ActiveShopifyGraphQL.configuration.admin_api_client + raise Error, "Admin API client not configured. Please configure it using ActiveShopifyGraphQL.configure" unless client + + client.execute(query, **variables) + when :customer_account_api + # Customer Account API implementation would go here + # For now, raise an error since we'd need token handling + raise NotImplementedError, "Customer Account API support needs token handling implementation" + else + raise ArgumentError, "Unknown client type: #{self.class.client_type}" + end + end + + # Validates that all query attributes are supported by the model + # @param conditions [Hash] The query conditions + # @param model_type [String] The model type + # @raise [ArgumentError] If any attribute is not valid for querying + def validate_query_attributes!(conditions, model_type) + return if conditions.empty? + + valid_attrs = valid_query_attributes(model_type) + invalid_attrs = conditions.keys.map(&:to_s) - valid_attrs + + return unless invalid_attrs.any? + + raise ArgumentError, "Invalid query attributes for #{model_type}: #{invalid_attrs.join(', ')}. " \ + "Valid attributes are: #{valid_attrs.join(', ')}" + end + + # Builds a Shopify GraphQL query string from Ruby conditions + # @param conditions [Hash] The query conditions + # @return [String] The Shopify query string + def build_shopify_query_string(conditions) + return "" if conditions.empty? + + query_parts = conditions.map do |key, value| + format_query_condition(key.to_s, value) + end + + query_parts.join(" AND ") + end + + # Formats a single query condition into Shopify's query syntax + # @param key [String] The attribute name + # @param value [Object] The attribute value + # @return [String] The formatted query condition + def format_query_condition(key, value) + case value + when String + # Handle special string values and escape quotes + if value.include?(" ") && !value.start_with?('"') + # Multi-word values should be quoted + "#{key}:\"#{value.gsub('"', '\\"')}\"" + else + "#{key}:#{value}" + end + when Numeric + "#{key}:#{value}" + when true, false + "#{key}:#{value}" + when Hash + # Handle range conditions like { created_at: { gte: '2024-01-01' } } + range_parts = value.map do |operator, range_value| + case operator.to_sym + when :gt, :> + "#{key}:>#{range_value}" + when :gte, :>= + "#{key}:>=#{range_value}" + when :lt, :< + "#{key}:<#{range_value}" + when :lte, :<= + "#{key}:<=#{range_value}" + else + raise ArgumentError, "Unsupported range operator: #{operator}" + end + end + range_parts.join(" ") + else + "#{key}:#{value}" + end end end end diff --git a/lib/active_shopify_graphql/loader_switchable.rb b/lib/active_shopify_graphql/loader_switchable.rb index 937d8c2..3a4e397 100644 --- a/lib/active_shopify_graphql/loader_switchable.rb +++ b/lib/active_shopify_graphql/loader_switchable.rb @@ -1,108 +1,115 @@ +# frozen_string_literal: true + module ActiveShopifyGraphQL + # Provides capability to switch between different loaders within the same model module LoaderSwitchable extend ActiveSupport::Concern - class_methods do - # DSL method to set the default loader class - # @param loader_class [Class] The loader class to use as default - def uses_loader(loader_class) - @default_loader_class = loader_class + # Generic method to execute with a specific loader + # @param loader_class [Class] The loader class to use + # @yield [Object] Block to execute with the loader + # @return [Object] Result of the block + def with_loader(loader_class, &_block) + old_loader = Thread.current[:active_shopify_graphql_loader] + Thread.current[:active_shopify_graphql_loader] = loader_class.new(self.class) + + if block_given? + yield(self) + else + self end + ensure + Thread.current[:active_shopify_graphql_loader] = old_loader + end - # Returns an instance of the default loader - # @return [Shopify::Loader] The default loader instance - def default_loader_instance - @default_loader_instance ||= default_loader_class.new - end + # Executes with the admin API loader + # @return [self] + def with_admin_api(&block) + with_loader(ActiveShopifyGraphQL::AdminApiLoader, &block) + end - # Returns the default loader class (either set via DSL or inferred) - # @return [Class] The default loader class - def default_loader_class - @default_loader_class ||= infer_loader_class('AdminApi') - end + # Executes with the customer account API loader + # @return [self] + def with_customer_account_api(&block) + with_loader(ActiveShopifyGraphQL::CustomerAccountApiLoader, &block) + end - # Use Admin API loader - returns a finder proxy that uses the admin API - # @return [Object] An object with find method using Admin API - def with_admin_api - LoaderProxy.new(self, admin_api_loader_class.new) + class_methods do + # @!method use_loader(loader_class) + # Sets the default loader class for this model. + # + # @param loader_class [Class] The loader class to use as default + # @example + # class Customer < ActiveRecord::Base + # use_loader ActiveShopifyGraphQL::CustomerAccountApiLoader + # end + def use_loader(loader_class) + @default_loader_class = loader_class end - # Use Customer Account API loader with provided token - # @param token [String] The customer access token - # @return [Object] An object with find method using Customer Account API - def with_customer_account_api(token) - LoaderProxy.new(self, customer_account_api_loader_class.new(token)) + # Class-level method to execute with admin API loader + # @return [LoaderProxy] Proxy object with find method + def with_admin_api + LoaderProxy.new(self, ActiveShopifyGraphQL::AdminApiLoader.new(self)) end - # Override this method to customize the Admin API loader class - # @return [Class] The Admin API loader class - def admin_api_loader_class - @admin_api_loader_class ||= infer_loader_class('AdminApi') + # Class-level method to execute with customer account API loader + # @return [LoaderProxy] Proxy object with find method + def with_customer_account_api(token = nil) + LoaderProxy.new(self, ActiveShopifyGraphQL::CustomerAccountApiLoader.new(self, token)) end - # Override this method to customize the Customer Account API loader class - # @return [Class] The Customer Account API loader class - def customer_account_api_loader_class - @customer_account_api_loader_class ||= infer_loader_class('CustomerAccountApi') - end + private - # Allows setting a custom Admin API loader class - # @param klass [Class] The loader class to use for Admin API - def admin_api_loader_class=(klass) - @admin_api_loader_class = klass + # Returns the default loader class (either set via DSL or inferred) + # @return [Class] The default loader class + def default_loader_class + @default_loader_class ||= ActiveShopifyGraphQL::AdminApiLoader end + end - # Allows setting a custom Customer Account API loader class - # @param klass [Class] The loader class to use for Customer Account API - def customer_account_api_loader_class=(klass) - @customer_account_api_loader_class = klass + # Simple proxy class to handle loader delegation + class LoaderProxy + def initialize(model_class, loader) + @model_class = model_class + @loader = loader end - private - - # Infers loader class from model name using naming conventions - # e.g., Shopify::Customer + 'AdminApi' -> ActiveShopifyGraphQL::Loaders::AdminApi::CustomerLoader - # @param api_type [String] The API type ('AdminApi' or 'CustomerAccountApi') - # @return [Class] The inferred loader class - def infer_loader_class(api_type) - model_name = name.demodulize # e.g., 'Customer' from 'Shopify::Customer' - - loader_class_name = "ActiveShopifyGraphQL::Loaders::#{api_type}::#{model_name}Loader" - loader_class_name.constantize - rescue NameError => e - raise NameError, "Could not find loader class '#{loader_class_name}' for model '#{name}'. " \ - "Please create the loader class or override the #{api_type.underscore}_loader_class method. " \ - "Original error: #{e.message}" - end + def find(id = nil) + # For Customer Account API, if no ID is provided, load the current customer + if id.nil? && @loader.is_a?(ActiveShopifyGraphQL::CustomerAccountApiLoader) + attributes = @loader.load_attributes + return nil if attributes.nil? - # Simple proxy class to handle loader delegation - class LoaderProxy - def initialize(model_class, loader) - @model_class = model_class - @loader = loader + return @model_class.new(attributes) end - def find(id = nil) - model_type = @model_class.name.demodulize + # For other cases, require ID and use standard flow + return nil if id.nil? - # Convert to GID only if ID is provided - gid = id ? @model_class.send(:convert_to_gid, id) : nil - attributes = @loader.load_attributes(gid, model_type) + gid = if id.is_a?(String) && id.include?('gid://') + id + else + URI::GID.build(app: "shopify", model_name: @model_class.model_name.name.demodulize, model_id: id) + end - return nil if attributes.nil? + attributes = @loader.load_attributes(gid) + return nil if attributes.nil? - @model_class.new(attributes) - end + @model_class.new(attributes) + end - def loader - @loader - end + # Delegate where to the model class with the specific loader + def where(*args, **options) + @model_class.where(*args, **options.merge(loader: @loader)) + end - def inspect - "#{@model_class.name}(with_#{@loader.class.name.demodulize})" - end - alias_method :to_s, :inspect + attr_reader :loader + + def inspect + "#{@model_class.name}(with_#{@loader.class.name.demodulize})" end + alias to_s inspect end end end diff --git a/sig/active_shopify_graphql.rbs b/sig/active_shopify_graphql.rbs deleted file mode 100644 index 35aa794..0000000 --- a/sig/active_shopify_graphql.rbs +++ /dev/null @@ -1,4 +0,0 @@ -module ActiveShopifyGraphql - VERSION: String - # See the writing guide of rbs: https://github.com/ruby/rbs#guides -end diff --git a/spec/active_shopify_graphql/array_type_spec.rb b/spec/active_shopify_graphql/array_type_spec.rb new file mode 100644 index 0000000..cdcbc35 --- /dev/null +++ b/spec/active_shopify_graphql/array_type_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Automatic array support' do + let(:loader_class) do + Class.new(ActiveShopifyGraphQL::Loader) do + graphql_type 'TestType' + + attribute :tags # No type specified - arrays preserved automatically + attribute :single_tag, type: :string # String type, but arrays still preserved + attribute :nullable_tags, null: true # No type specified + attribute :non_nullable_tags, null: false # No type specified + attribute :numeric_values, type: :integer # Integer type, but arrays still preserved + end + end + + let(:loader) { loader_class.new } + + describe 'automatic array preservation' do + it 'preserves arrays regardless of specified type' do + response_data = { + 'data' => { + 'testtype' => { # NOTE: query_name returns lowercase + 'tags' => %w[tag1 tag2 tag3], + 'singleTag' => %w[single], + 'nullableTags' => nil, + 'nonNullableTags' => %w[required tag], + 'numericValues' => [1, 2, 3] + } + } + } + + attributes = loader.map_response_to_attributes(response_data) + + expect(attributes[:tags]).to eq(%w[tag1 tag2 tag3]) + expect(attributes[:single_tag]).to eq(%w[single]) # Array preserved even with type: :string + expect(attributes[:nullable_tags]).to be_nil + expect(attributes[:non_nullable_tags]).to eq(%w[required tag]) + expect(attributes[:numeric_values]).to eq([1, 2, 3]) # Array preserved even with type: :integer + end + + it 'raises error for null values when null is not allowed' do + response_data = { + 'data' => { + 'testtype' => { # NOTE: query_name returns lowercase + 'tags' => ['tag1'], + 'singleTag' => %w[single], + 'nullableTags' => nil, + 'nonNullableTags' => nil, # This should cause an error + 'numericValues' => [1] + } + } + } + + expect do + loader.map_response_to_attributes(response_data) + end.to raise_error(ArgumentError, /cannot be null/) + end + + it 'handles empty arrays correctly' do + response_data = { + 'data' => { + 'testtype' => { # NOTE: query_name returns lowercase + 'tags' => [], + 'singleTag' => [], + 'nullableTags' => [], + 'nonNullableTags' => [], + 'numericValues' => [] + } + } + } + + attributes = loader.map_response_to_attributes(response_data) + + expect(attributes[:tags]).to eq([]) + expect(attributes[:single_tag]).to eq([]) + expect(attributes[:nullable_tags]).to eq([]) + expect(attributes[:non_nullable_tags]).to eq([]) + expect(attributes[:numeric_values]).to eq([]) + end + + it 'works with transform blocks' do + loader_class.class_eval do + attribute :transformed_tags, type: :array, transform: ->(value) { value.map(&:upcase) } + end + + response_data = { + 'data' => { + 'testtype' => { # NOTE: query_name returns lowercase + 'tags' => %w[tag1 tag2], + 'singleTag' => %w[single], + 'nullableTags' => nil, + 'nonNullableTags' => ['required'], + 'transformedTags' => %w[lower case], + 'numericValues' => [1, 2] + } + } + } + + attributes = loader.map_response_to_attributes(response_data) + + expect(attributes[:transformed_tags]).to eq(%w[LOWER CASE]) + end + end + + describe 'type coercion with arrays' do + it 'preserves arrays even when type coercion is specified' do + # Test string type coercer with array input + expect(loader.send(:coerce_value, %w[a b c], :string, :test, 'test')).to eq(%w[a b c]) + + # Test integer type coercer with array input + expect(loader.send(:coerce_value, [1, 2, 3], :integer, :test, 'test')).to eq([1, 2, 3]) + + # Test boolean type coercer with array input + expect(loader.send(:coerce_value, [true, false], :boolean, :test, 'test')).to eq([true, false]) + end + + it 'still performs type coercion for non-array values' do + expect(loader.send(:coerce_value, '42', :integer, :test, 'test')).to eq(42) + expect(loader.send(:coerce_value, 'true', :boolean, :test, 'test')).to eq(true) + expect(loader.send(:coerce_value, 42, :string, :test, 'test')).to eq('42') + end + + it 'handles nil values correctly' do + expect(loader.send(:coerce_value, nil, :string, :test, 'test')).to be_nil + expect(loader.send(:coerce_value, nil, :integer, :test, 'test')).to be_nil + end + + it 'handles empty arrays' do + expect(loader.send(:coerce_value, [], :string, :test, 'test')).to eq([]) + expect(loader.send(:coerce_value, [], :integer, :test, 'test')).to eq([]) + end + end +end diff --git a/spec/active_shopify_graphql/loader_spec.rb b/spec/active_shopify_graphql/loader_spec.rb new file mode 100644 index 0000000..4719208 --- /dev/null +++ b/spec/active_shopify_graphql/loader_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ActiveShopifyGraphQL::Loader do + describe '.graphql_type and .fragment' do + let(:test_loader_class) do + Class.new(described_class) do + graphql_type "TestModel" + fragment <<~GRAPHQL + id + name + GRAPHQL + + def map_response_to_attributes(response_data) + { id: response_data.dig("data", "testmodel", "id") } + end + + private + + def execute_graphql_query(_query, **variables) + { "data" => { "testmodel" => { "id" => variables[:id] } } } + end + end + end + + it 'allows setting graphql_type at class level' do + expect(test_loader_class.graphql_type).to eq("TestModel") + end + + it 'raises error when graphql_type is not set' do + loader_without_type = Class.new(described_class) + expect { loader_without_type.graphql_type }.to raise_error(NotImplementedError) + end + + it 'generates correct query_name from graphql_type' do + loader = test_loader_class.new + expect(loader.query_name).to eq("testmodel") + end + + it 'generates correct fragment_name from graphql_type' do + loader = test_loader_class.new + expect(loader.fragment_name).to eq("TestModelFragment") + end + + it 'generates correct GraphQL query using graphql_type' do + loader = test_loader_class.new + query = loader.graphql_query + + expect(query).to include("query getTestModel($id: ID!)") + expect(query).to include("testmodel(id: $id)") + expect(query).to include("...TestModelFragment") + end + + it 'builds fragment automatically from class-level fragment definition' do + loader = test_loader_class.new + fragment = loader.fragment + + expect(fragment).to include("fragment TestModelFragment on TestModel {") + expect(fragment).to include("id") + expect(fragment).to include("name") + expect(fragment).to include("}") + end + + it 'allows getting fragment fields at class level' do + expect(test_loader_class.fragment).to include("id") + expect(test_loader_class.fragment).to include("name") + end + + it 'raises error when fragment is not defined' do + loader_without_fragment = Class.new(described_class) do + graphql_type "NoFragment" + end + + expect { loader_without_fragment.fragment }.to raise_error(NotImplementedError) + end + + it 'loads attributes using graphql_type' do + loader = test_loader_class.new + result = loader.load_attributes("test-id") + + expect(result).to eq({ id: "test-id" }) + end + + context 'backwards compatibility' do + it 'still accepts model_type parameter in load_attributes' do + loader = test_loader_class.new + result = loader.load_attributes("CustomType", "test-id") + + expect(result).to eq({ id: "test-id" }) + end + + it 'still accepts model_type parameter in other methods' do + loader = test_loader_class.new + + expect(loader.query_name("CustomType")).to eq("customtype") + expect(loader.fragment_name("CustomType")).to eq("CustomTypeFragment") + end + end + end +end diff --git a/spec/active_shopify_graphql/metafield_attributes_spec.rb b/spec/active_shopify_graphql/metafield_attributes_spec.rb new file mode 100644 index 0000000..530af6d --- /dev/null +++ b/spec/active_shopify_graphql/metafield_attributes_spec.rb @@ -0,0 +1,325 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' + +RSpec.describe "Metafield attribute functionality" do + let(:test_loader_class) do + Class.new(ActiveShopifyGraphQL::Loader) do + graphql_type "Product" + + # Regular attributes + attribute :id, type: :string + attribute :title, type: :string + + # Metafield attributes with different types + metafield_attribute :boxes_available, namespace: 'custom', key: 'available_boxes', type: :integer + metafield_attribute :boxes_sent, namespace: 'custom', key: 'sent_boxes', type: :json + metafield_attribute :description, namespace: 'seo', key: 'meta_description', type: :string + metafield_attribute :is_featured, namespace: 'custom', key: 'featured', type: :boolean, null: false + + def execute_graphql_query(_query, **_variables) + { + "data" => { + "product" => { + "id" => "gid://shopify/Product/123", + "title" => "Test Product", + "boxes_availableMetafield" => { "value" => "10" }, + "boxes_sentMetafield" => { "jsonValue" => { "count" => 5, "dates" => ["2024-01-01"] } }, + "descriptionMetafield" => { "value" => "SEO description" }, + "is_featuredMetafield" => { "value" => "true" } + } + } + } + end + end + end + + describe ".metafield_attribute" do + it "adds metafield to attributes collection" do + expect(test_loader_class.attributes).to include(:boxes_available) + expect(test_loader_class.attributes).to include(:boxes_sent) + end + + it "stores metafield metadata" do + expect(test_loader_class.metafields[:boxes_available]).to eq({ + namespace: 'custom', + key: 'available_boxes', + type: :integer + }) + end + + it "creates correct attribute configuration for integer metafields" do + config = test_loader_class.attributes[:boxes_available] + expect(config[:path]).to eq("boxes_availableMetafield.value") + expect(config[:type]).to eq(:integer) + expect(config[:is_metafield]).to be true + expect(config[:metafield_namespace]).to eq('custom') + expect(config[:metafield_key]).to eq('available_boxes') + end + + it "creates correct attribute configuration for json metafields" do + config = test_loader_class.attributes[:boxes_sent] + expect(config[:path]).to eq("boxes_sentMetafield.jsonValue") + expect(config[:type]).to eq(:json) + expect(config[:is_metafield]).to be true + end + end + + describe "#fragment generation" do + it "includes metafield GraphQL syntax in generated fragment" do + loader = test_loader_class.new + fragment = loader.fragment + + expect(fragment).to include("fragment ProductFragment on Product {") + expect(fragment).to include("id") + expect(fragment).to include("title") + expect(fragment).to include('boxes_availableMetafield: metafield(namespace: "custom", key: "available_boxes") {') + expect(fragment).to include("value") + expect(fragment).to include('boxes_sentMetafield: metafield(namespace: "custom", key: "sent_boxes") {') + expect(fragment).to include("jsonValue") + end + + it "uses value field for non-json types" do + loader = test_loader_class.new + fragment = loader.fragment + + expect(fragment).to include('boxes_availableMetafield: metafield(namespace: "custom", key: "available_boxes") {') + expect(fragment).to include('descriptionMetafield: metafield(namespace: "seo", key: "meta_description") {') + expect(fragment).to match(/boxes_availableMetafield[^}]*value[^}]*}/m) + expect(fragment).to match(/descriptionMetafield[^}]*value[^}]*}/m) + end + + it "uses jsonValue field for json type" do + loader = test_loader_class.new + fragment = loader.fragment + + expect(fragment).to include('boxes_sentMetafield: metafield(namespace: "custom", key: "sent_boxes") {') + expect(fragment).to match(/boxes_sentMetafield[^}]*jsonValue[^}]*}/m) + end + end + + describe "#map_response_to_attributes" do + it "correctly maps metafield responses to attributes" do + loader = test_loader_class.new + response = loader.send(:execute_graphql_query, "", id: "test") + + attributes = loader.map_response_to_attributes(response) + + expect(attributes[:id]).to eq("gid://shopify/Product/123") + expect(attributes[:title]).to eq("Test Product") + expect(attributes[:boxes_available]).to eq(10) # Coerced to integer + expect(attributes[:boxes_sent]).to eq({ "count" => 5, "dates" => ["2024-01-01"] }) # JSON string preserved + expect(attributes[:description]).to eq("SEO description") + expect(attributes[:is_featured]).to be true # Coerced to boolean + end + + it "handles null metafield values when null is allowed" do + null_response_loader = Class.new(ActiveShopifyGraphQL::Loader) do + graphql_type "Product" + + metafield_attribute :optional_field, namespace: 'test', key: 'optional', type: :string + + def execute_graphql_query(_query, **_variables) + { + "data" => { + "product" => { + "optional_fieldMetafield" => nil + } + } + } + end + end + + loader = null_response_loader.new + response = loader.send(:execute_graphql_query, "") + attributes = loader.map_response_to_attributes(response) + + expect(attributes[:optional_field]).to be_nil + end + + it "raises error for null values when null is not allowed" do + loader = test_loader_class.new + + # Mock a response where the required metafield is null + allow(loader).to receive(:execute_graphql_query).and_return({ + "data" => { + "product" => { + "is_featuredMetafield" => nil + } + } + }) + + response = loader.send(:execute_graphql_query, "") + + expect do + loader.map_response_to_attributes(response) + end.to raise_error(ArgumentError, /Attribute 'is_featured'.*cannot be null/) + end + end + + describe "#load_attributes integration" do + it "successfully loads and maps metafield attributes" do + loader = test_loader_class.new + attributes = loader.load_attributes("test-id") + + expect(attributes).to include( + id: "gid://shopify/Product/123", + title: "Test Product", + boxes_available: 10, + boxes_sent: { "count" => 5, "dates" => ["2024-01-01"] }, + description: "SEO description", + is_featured: true + ) + end + end + + describe "edge cases" do + it "handles metafields with transform blocks" do + transform_loader = Class.new(ActiveShopifyGraphQL::Loader) do + graphql_type "Product" + + metafield_attribute :tags, namespace: 'custom', key: 'tags', type: :json, + transform: ->(tags_array) { tags_array.map(&:upcase) } + + def execute_graphql_query(_query, **_variables) + { + "data" => { + "product" => { + "tagsMetafield" => { "jsonValue" => %w[tag1 tag2] } + } + } + } + end + end + + loader = transform_loader.new + attributes = loader.load_attributes("test-id") + + expect(attributes[:tags]).to eq(%w[TAG1 TAG2]) + end + + it "handles nil metafields with default values" do + default_loader = Class.new(ActiveShopifyGraphQL::Loader) do + graphql_type "Product" + + metafield_attribute :missing_string, namespace: 'custom', key: 'missing_str', type: :string, + default: "default_value" + + metafield_attribute :missing_json, namespace: 'custom', key: 'missing_json', type: :json, + default: { "default" => true } + + metafield_attribute :missing_integer, namespace: 'custom', key: 'missing_int', type: :integer, + default: 42 + + def execute_graphql_query(_query, **_variables) + { + "data" => { + "product" => { + "missing_stringMetafield" => nil, + "missing_jsonMetafield" => nil, + "missing_integerMetafield" => nil + } + } + } + end + end + + loader = default_loader.new + attributes = loader.load_attributes("test-id") + + expect(attributes[:missing_string]).to eq("default_value") + expect(attributes[:missing_json]).to eq({ "default" => true }) + expect(attributes[:missing_integer]).to eq(42) + end + + it "handles nil metafields with transform blocks providing defaults" do + transform_loader = Class.new(ActiveShopifyGraphQL::Loader) do + graphql_type "Product" + + metafield_attribute :missing_string, namespace: 'custom', key: 'missing', type: :string, + transform: ->(value) { value.nil? ? "transform_default" : value } + + metafield_attribute :missing_json, namespace: 'custom', key: 'json', type: :json, + transform: ->(value) { value.nil? ? { "transform" => true } : value } + + def execute_graphql_query(_query, **_variables) + { + "data" => { + "product" => { + "missing_stringMetafield" => nil, + "missing_jsonMetafield" => nil + } + } + } + end + end + + loader = transform_loader.new + attributes = loader.load_attributes("test-id") + + expect(attributes[:missing_string]).to eq("transform_default") + expect(attributes[:missing_json]).to eq({ "transform" => true }) + end + + it "prefers default over transform for nil values (optimization)" do + call_count = 0 + + mixed_loader = Class.new(ActiveShopifyGraphQL::Loader) do + graphql_type "Product" + + # This should use default and NOT call transform + metafield_attribute :with_default, namespace: 'custom', key: 'def', type: :string, + default: "default_used", + transform: lambda { |value| + call_count += 1 + "transform_should_not_be_called" + } + + # This should call transform since no default + metafield_attribute :with_transform, namespace: 'custom', key: 'trans', type: :string, + transform: lambda { |value| + call_count += 1 + "transform_called" + } + + def execute_graphql_query(_query, **_variables) + { + "data" => { + "product" => { + "with_defaultMetafield" => nil, + "with_transformMetafield" => nil + } + } + } + end + end + + loader = mixed_loader.new + attributes = loader.load_attributes("test-id") + + expect(attributes[:with_default]).to eq("default_used") + expect(attributes[:with_transform]).to eq("transform_called") + expect(call_count).to eq(1) # Only transform should be called once + end + + it "generates unique aliases for metafields with same namespace/key but different names" do + multi_loader = Class.new(ActiveShopifyGraphQL::Loader) do + graphql_type "Product" + + metafield_attribute :weight_kg, namespace: 'shipping', key: 'weight', type: :float + metafield_attribute :weight_display, namespace: 'shipping', key: 'weight', type: :string + + def fragment + build_fragment_from_fields + end + end + + loader = multi_loader.new + fragment = loader.fragment + + expect(fragment).to include('weight_kgMetafield: metafield(namespace: "shipping", key: "weight")') + expect(fragment).to include('weight_displayMetafield: metafield(namespace: "shipping", key: "weight")') + end + end +end diff --git a/spec/active_shopify_graphql/select_spec.rb b/spec/active_shopify_graphql/select_spec.rb new file mode 100644 index 0000000..5ecdba2 --- /dev/null +++ b/spec/active_shopify_graphql/select_spec.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe "Select functionality" do + let(:mock_client) do + instance_double("GraphQLClient") + end + + let(:customer_class) do + Class.new do + include ActiveShopifyGraphQL::Base + + graphql_type "Customer" + + attribute :id, path: "id", type: :string + attribute :name, path: "displayName", type: :string + attribute :email, path: "defaultEmailAddress.emailAddress", type: :string + attribute :first_name, path: "firstName", type: :string + attribute :created_at, path: "createdAt", type: :datetime + + def self.name + "Customer" + end + + def self.model_name + ActiveModel::Name.new(self, nil, "Customer") + end + + def self.default_loader_class + ActiveShopifyGraphQL::AdminApiLoader + end + + class << self + attr_accessor :default_loader_instance + end + end + end + + before do + ActiveShopifyGraphQL.configure do |config| + config.admin_api_client = mock_client + end + + customer_class.default_loader_instance = ActiveShopifyGraphQL::AdminApiLoader.new(customer_class) + end + + describe ".select" do + it "returns a modified class that can be used for method chaining" do + selected_class = customer_class.select(:id, :name) + expect(selected_class).to be_a(Class) + expect(selected_class).to respond_to(:find) + expect(selected_class).to respond_to(:where) + end + + it "creates a loader with selected attributes" do + selected_class = customer_class.select(:id, :name) + loader = selected_class.default_loader + + # Should only include selected attributes plus id (always included) + attrs = loader.defined_attributes + expect(attrs.keys).to contain_exactly(:id, :name) + expect(attrs[:id][:path]).to eq("id") + expect(attrs[:name][:path]).to eq("displayName") + end + + it "always includes id even if not explicitly selected" do + selected_class = customer_class.select(:name) + loader = selected_class.default_loader + attrs = loader.defined_attributes + + expect(attrs.keys).to include(:id) + expect(attrs.keys).to include(:name) + end + + it "generates GraphQL fragments with only selected attributes" do + selected_class = customer_class.select(:name, :email) + loader = selected_class.default_loader + fragment = loader.fragment + + expect(fragment).to include("id") # Always included + expect(fragment).to include("displayName") + expect(fragment).to include("defaultEmailAddress") + expect(fragment).to include("emailAddress") + expect(fragment).not_to include("firstName") + expect(fragment).not_to include("createdAt") + end + + it "validates that selected attributes exist" do + expect do + customer_class.select(:nonexistent_attribute) + end.to raise_error(ArgumentError, /Invalid attributes.*nonexistent_attribute/) + end + + it "provides helpful error message with available attributes" do + expect do + customer_class.select(:bad_attr) + end.to raise_error(ArgumentError) do |error| + expect(error.message).to include("Available attributes are:") + # The exact order may vary, but these should be included + expect(error.message).to match(/\bcreated_at\b/) + expect(error.message).to match(/\bemail\b/) + expect(error.message).to match(/\bfirst_name\b/) + expect(error.message).to match(/\bid\b/) + expect(error.message).to match(/\bname\b/) + end + end + + it "preserves the original class name and model name" do + selected_class = customer_class.select(:id, :name) + expect(selected_class.name).to eq("Customer") + expect(selected_class.model_name).to eq(customer_class.model_name) + end + end + + describe "integration with find and where" do + it "works with find method" do + expected_response = { + "data" => { + "customer" => { + "id" => "gid://shopify/Customer/123", + "displayName" => "John Doe" + } + } + } + + expect(mock_client).to receive(:execute) do |query, **variables| + # Verify the fragment only includes selected attributes and id + expect(query).to include("id") + expect(query).to include("displayName") + expect(query).not_to include("defaultEmailAddress") + expect(query).not_to include("firstName") + expect(variables[:id].to_s).to eq("gid://shopify/Customer/123") + expected_response + end + + customer = customer_class.select(:name).find(123) + expect(customer).to be_a(customer_class) + expect(customer.name).to eq("John Doe") + end + + it "works with where method" do + expected_response = { + "data" => { + "customers" => { + "nodes" => [ + { + "id" => "gid://shopify/Customer/123", + "displayName" => "John Doe" + } + ] + } + } + } + + expect(mock_client).to receive(:execute) do |query, **variables| + # Verify the fragment only includes selected attributes and id + expect(query).to include("id") + expect(query).to include("displayName") + expect(query).not_to include("defaultEmailAddress") + expect(query).not_to include("firstName") + expect(variables[:query]).to eq("first_name:John") + expected_response + end + + customers = customer_class.select(:name).where(first_name: "John") + expect(customers).to be_an(Array) + expect(customers.size).to eq(1) + expect(customers.first.name).to eq("John Doe") + end + end +end diff --git a/spec/active_shopify_graphql/where_spec.rb b/spec/active_shopify_graphql/where_spec.rb new file mode 100644 index 0000000..4f7630e --- /dev/null +++ b/spec/active_shopify_graphql/where_spec.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +RSpec.describe "Where functionality" do + let(:mock_client) do + double("GraphQLClient") + end + + let(:mock_loader) do + client = mock_client + Class.new(ActiveShopifyGraphQL::AdminApiLoader) do + define_method :initialize do + @client = client + end + + def fragment + <<~GRAPHQL + fragment CustomerFragment on Customer { + id + displayName + defaultEmailAddress { + emailAddress + } + createdAt + } + GRAPHQL + end + + def map_response_to_attributes(response_data) + customer_data = response_data.dig("data", "customer") + return nil unless customer_data + + { + id: customer_data["id"], + name: customer_data["displayName"], + email: customer_data.dig("defaultEmailAddress", "emailAddress"), + created_at: customer_data["createdAt"] + } + end + + def execute_graphql_query(query, **variables) + @client.execute(query, **variables) + end + end.new + end + + let(:customer_class) do + Class.new do + include ActiveShopifyGraphQL::Base + + attr_accessor :id, :name, :email, :created_at + + def self.name + "Customer" + end + + def self.model_name + ActiveModel::Name.new(self, nil, "Customer") + end + + class << self + attr_reader :default_loader + end + + class << self + attr_writer :default_loader + end + end + end + + before do + ActiveShopifyGraphQL.configure do |config| + config.admin_api_client = mock_client + end + + customer_class.default_loader = mock_loader + end + + describe ".where" do + it "builds correct Shopify query syntax for simple conditions" do + expected_query = <<~GRAPHQL + fragment CustomerFragment on Customer { + id + displayName + defaultEmailAddress { + emailAddress + } + createdAt + } + + query getCustomers($query: String, $first: Int!) { + customers(query: $query, first: $first) { + nodes { + ...CustomerFragment + } + } + } + GRAPHQL + + expected_variables = { + query: "email:john@example.com AND first_name:John", + first: 250 + } + + expect(mock_client).to receive(:execute) + .with(expected_query, **expected_variables) + .and_return({ + "data" => { + "customers" => { + "nodes" => [ + { + "id" => "gid://shopify/Customer/123", + "displayName" => "John Doe", + "defaultEmailAddress" => { "emailAddress" => "john@example.com" }, + "createdAt" => "2024-01-01T00:00:00Z" + } + ] + } + } + }) + + results = customer_class.where(email: "john@example.com", first_name: "John") + + expect(results).to have_attributes(size: 1) + expect(results.first).to have_attributes( + id: "gid://shopify/Customer/123", + name: "John Doe", + email: "john@example.com" + ) + end + + it "raises ArgumentError when Shopify returns field validation warnings" do + # Mock a response with search warnings + mock_response = { + "data" => { "customers" => { "edges" => [] } }, + "extensions" => { + "search" => [{ + "path" => ["customers"], + "query" => "invalid_field:test", + "warnings" => [{ + "field" => "invalid_field", + "message" => "Invalid search field for this query." + }] + }] + } + } + + allow(customer_class.default_loader).to receive(:execute_graphql_query).and_return(mock_response) + + expect do + customer_class.where(invalid_field: "test") + end.to raise_error(ArgumentError, /Shopify query validation failed: invalid_field: Invalid search field for this query/) + end + + it "handles range conditions correctly" do + expected_variables = { + query: "id:>=100 id:<200", + first: 250 + } + + expect(mock_client).to receive(:execute) + .with(anything, **expected_variables) + .and_return({ "data" => { "customers" => { "nodes" => [] } } }) + + customer_class.where(id: { gte: 100, lt: 200 }) + end + + it "handles quoted values for multi-word strings" do + expected_variables = { + query: "first_name:\"John Doe\"", + first: 250 + } + + expect(mock_client).to receive(:execute) + .with(anything, **expected_variables) + .and_return({ "data" => { "customers" => { "nodes" => [] } } }) + + customer_class.where(first_name: "John Doe") + end + + it "returns empty array when no results" do + expect(mock_client).to receive(:execute) + .and_return({ "data" => { "customers" => { "nodes" => [] } } }) + + results = customer_class.where(email: "nonexistent@example.com") + expect(results).to be_empty + end + end +end diff --git a/spec/active_shopify_graphql_spec.rb b/spec/active_shopify_graphql_spec.rb index 29a42a7..68e44f8 100644 --- a/spec/active_shopify_graphql_spec.rb +++ b/spec/active_shopify_graphql_spec.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true -RSpec.describe ActiveShopifyGraphql do +RSpec.describe ActiveShopifyGraphQL do it "has a version number" do - expect(ActiveShopifyGraphql::VERSION).not_to be nil + expect(ActiveShopifyGraphQL::VERSION).not_to be nil end - it "does something useful" do - expect(false).to eq(true) + it "works correctly" do + expect(ActiveShopifyGraphQL).to be_a(Module) end end