ActiveShopifyGraphQL

Bringing Read Only (for now) ActiveRecord-like domain modeling to Shopify GraphQL APIs

Gem Version Spec Lint

Support for both Admin and Customer Account APIs with automatic query building, response mapping, and N+1-free connections.

🚀 Quick Start

gem install active_shopify_graphql
# Configure in pure Ruby
ActiveShopifyGraphQL.configure do |config|
  config.admin_api_executor = lambda do |query, **variables|
    client = ShopifyAPI::Clients::Graphql::Admin.new(session: ShopifyAPI::Context.active_session)
    response = client.query(query:, variables:)
    response.body if response
  end
end

# Or define a Rails initializer
Rails.configuration.to_prepare do
  ActiveShopifyGraphQL.configure do |config|
    config.admin_api_executor = lambda do |query, **variables|
      client = ShopifyAPI::Clients::Graphql::Admin.new(session: ShopifyAPI::Context.active_session)
      response = client.query(query:, variables:)
      response.body if response
    end
  end
end

# Define your model
class Customer < ActiveShopifyGraphQL::Model
  graphql_type "Customer" # Optional as it's auto inferred

  attribute :id, type: :string
  attribute :name, path: "displayName", type: :string
  attribute :email, path: "defaultEmailAddress.emailAddress", type: :string
  attribute :created_at, type: :datetime

  has_many_connected :orders, default_arguments: { first: 10 }
end

# Use it like ActiveRecord
customer = Customer.find(123456789)
customer.name                         # => "John Doe"
customer.orders.to_a                  # => [#<Order:0x...>, ...]

Customer.where(email: "@example.com")
Customer.includes(:orders).find(id)

✨ Why?

The Problem

GraphQL is powerful, but dealing with raw responses is painful:

# Before: The struggle
response = shopify_client.execute(query)
customer = response["data"]["customer"]
email = customer["defaultEmailAddress"]["emailAddress"]
created_at = Time.parse(customer["createdAt"])
orders = customer["orders"]["nodes"].map { |o| parse_order(o) }
# Different API? Different field names. Good luck!

Problems:

  • ❌ Different schemas for Admin API vs any other API
  • ❌ Inconsistent data shapes across queries
  • ❌ Manual type conversions everywhere
  • ❌ N+1 query problems with connections
  • ❌ No validation or business logic layer

The Solution

# After: Peace of mind
customer = Customer.includes(:orders).find(123456789)
customer.email                # => "[email protected]"
customer.created_at           # => #<DateTime>
customer.orders               # Eagerly loaded as a single query

Benefits:

  • Single source of truth — Models, not hashes
  • Type-safe attributes — Automatic coercion
  • Unified across APIs — Same model, different loaders
  • Optional eager loading — Save points by default, eager load when needed
  • ActiveRecord-like — Familiar, idiomatic Ruby and Rails

📚 Table of Contents


Installation

Add to your Gemfile:

gem "active_shopify_graphql"

Or install globally:

gem install active_shopify_graphql

Configuration

Configure your Shopify GraphQL clients:

# config/initializers/active_shopify_graphql.rb
Rails.configuration.to_prepare do
  ActiveShopifyGraphQL.configure do |config|
    # Admin API (receives query, **variables as arguments)
    config.admin_api_executor = lambda do |query, **variables|
      client = ShopifyAPI::Clients::Graphql::Admin.new(session: ShopifyAPI::Context.active_session)
      response = client.query(query:, variables:)
      response.body if response
    end

    # Optional Customer Account API (receives query, customer_token, **variables as arguments)
    config. = lambda do |query, customer_token, **variables|
      # This is a custom client example
      client = Shopify::Account::Client.from_config(customer_token)
      response = client.query(query, **variables)
    end
  end
end

Core Concepts

Models

Models are the heart of ActiveShopifyGraphQL. They define:

  • GraphQL type → Which Shopify schema type they map to
  • Attributes → Fields to fetch and their types
  • Associations → Relationships to other models
  • Connections → GraphQL connections for related data
  • Business logic → Validations, methods, transformations

Attributes

Attributes auto-generate GraphQL fragments and handle response mapping:

class Customer < ActiveShopifyGraphQL::Model
  graphql_type "Customer"

  # Renaming an attribute
  attribute :name, path: "displayName", type: :string

  # Custom path with dot notation
  attribute :email, path: "defaultEmailAddress.emailAddress", type: :string

  # Custom transformation
  attribute :plain_id, path: "id", transform: ->(gid) { gid.split("/").last }
end

Connections

Connections to related Shopify data with lazy/eager loading:

class Customer < ActiveShopifyGraphQL::Model
  # Lazy by default — loaded on first access
  has_many_connected :orders, default_arguments: { first: 10 }

  # Always eager load — no N+1 queries
  has_many_connected :addresses, eager_load: true, default_arguments: { first: 5 }

  # Scoped connection with custom arguments
  has_many_connected :recent_orders,
    query_name: "orders",
    default_arguments: { first: 5, reverse: true, sort_key: "CREATED_AT" }
end

Features

🏗️ Attribute Definition

Define attributes with automatic GraphQL generation:

class Product < ActiveShopifyGraphQL::Model
  graphql_type "Product"

  # Simple attribute (path auto-inferred as "title")
  attribute :title, type: :string

  # Custom path
  attribute :price, path: "priceRange.minVariantPrice.amount", type: :float

  # With default
  attribute :description, type: :string, default: "No description"

  # Custom transformation
  attribute :slug, path: "handle", transform: ->(handle) { handle.parameterize }

  # Nullable validation
  attribute :vendor, type: :string, null: false
end

Metafields

Easy access to Shopify metafields:

class Product < ActiveShopifyGraphQL::Model
  graphql_type "Product"

  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
end

Raw GraphQL

For advanced features like union types:

class Product < ActiveShopifyGraphQL::Model
  graphql_type "Product"

  # Raw GraphQL injection for union types
  attribute :provider_id,
    path: "provider_id.reference.id", # first part must match the attribute name as the field is aliased to that
    type: :string,
    raw_graphql: 'metafield(namespace: "custom", key: "provider") { reference { ... on Metaobject { id } } }'
end

API-Specific Attributes

Different fields per API:

class Customer < ActiveShopifyGraphQL::Model
  graphql_type "Customer"

  attribute :id, type: :string
  attribute :name, path: "displayName", type: :string

  # Admin API specific
  for_loader ActiveShopifyGraphQL::Loaders::AdminApiLoader do
    attribute :email, path: "defaultEmailAddress.emailAddress", type: :string
  end

  # Customer Account API specific
  for_loader ActiveShopifyGraphQL::Loaders::CustomerAccountApiLoader do
    attribute :email, path: "emailAddress.emailAddress", type: :string
  end
end

Querying

Finding Records

# By GID or numeric ID
customer = Customer.find("gid://shopify/Customer/123456789")
customer = Customer.find(123456789)

# With specific API
Customer.(token).find
Customer.with_admin_api.find(123456789)

Filtering

# Hash queries (auto-escaped)
Customer.where(email: "[email protected]")

# Range queries
Customer.where(created_at: { gte: "2024-01-01", lt: "2024-02-01" })
Customer.where(orders_count: { gte: 5 })

# Wildcards are supported in string queries only
# Use with caution as interpolation can lead to security issues
Customer.where("email:*@example.com")

# Parameter binding (safe)
Customer.where("email::email", email: "[email protected]")

# With limits
Customer.where(email: "@gmail.com").limit(100)

Sorting

# Sort by created_at ascending (default)
Customer.where(email: "@example.com").order(sort_key: "CREATED_AT")

# Sort by updated_at descending
Customer.order(sort_key: "UPDATED_AT", reverse: true)

# Combine with other chainable methods
Customer.where(country: "Canada").order(sort_key: "CREATED_AT").limit(25)

Note: Sort keys are Shopify GraphQL enum values (e.g., "CREATED_AT", "UPDATED_AT", "RELEVANCE"). Available values depend on the specific Shopify API endpoint.

Query Optimization

# Select only needed fields
Customer.select(:id, :name).find(123)

# Combine with includes (N+1-free)
Customer.includes(:orders).select(:id, :name).where(first_name: "Andrea")

Pagination

Automatic cursor-based pagination:

# Automatic pagination with limit
# Query for non-empty SKUs
ProductVariant.where("-sku:''").limit(100).to_a

# Manual pagination
page = ProductVariant.where("sku:FRZ*").in_pages(of: 50)
page.has_next_page?    # => true
next_page = page.next_page

# Batch processing
ProductVariant.where("sku:FRZ*").in_pages(of: 10) do |page|
  page.each { |variant| process(variant) }
end

# Lazy enumeration
scope = Customer.where(email: "@example.com")
scope.each { |c| puts c.name }  # Executes query
scope.first                      # Fetches just first

Connections

Lazy Loading

customer = Customer.find(123)

# Not loaded yet
customer.orders.loaded?    # => false

# Loads on access (separate query)
orders = customer.orders.to_a
customer.orders.loaded?    # => true

# Enumerable
customer.orders.each { |order| puts order.name }
customer.orders.size
customer.orders.first

Eager Loading

# Load in single query (no N+1!)
customer = Customer.includes(:orders, :addresses).find(123)

# Already loaded
orders = customer.orders      # No additional query
addresses = customer.addresses

Automatic Eager Loading

class Customer < ActiveShopifyGraphQL::Model
  # Always loaded without explicit includes
  has_many_connected :orders, eager_load: true
end

customer = Customer.find(123)
orders = customer.orders      # Already loaded

Runtime Parameters

customer = Customer.find(123)

# Override defaults
customer.orders(first: 25, sort_key: 'UPDATED_AT', reverse: true).to_a

Inverse Relationships

class Product < ActiveShopifyGraphQL::Model
  has_many_connected :variants, inverse_of: :product
end

class ProductVariant < ActiveShopifyGraphQL::Model
  has_one_connected :product, inverse_of: :variants
end

# Bidirectional caching — no redundant queries
product = Product.includes(:variants).find(123)
product.variants.each do |variant|
  variant.product  # Uses cached parent, no query runs
end

ActiveRecord Associations

Bridge between your ActiveRecord models and Shopify GraphQL:

class Reward < ApplicationRecord
  include ActiveShopifyGraphQL::GraphQLAssociations

  belongs_to_graphql :customer
  has_one_graphql :primary_address, class_name: "Address"
  has_many_graphql :variants, class_name: "ProductVariant"
end

reward = Reward.find(1)
reward.customer        # Loads Customer from shopify_customer_id
reward.variants        # Queries ProductVariant.where({})

Logging

Track GraphQL query performance and costs in your Rails logs:

# config/initializers/active_shopify_graphql.rb
require "benchmark"

Rails.configuration.to_prepare do
  ActiveShopifyGraphQL.configure do |config|
    config.admin_api_executor = lambda do |query, **variables|
      client = ShopifyAPI::Clients::Graphql::Admin.new(session: ShopifyAPI::Context.active_session)
      response = nil
      duration_ms = Benchmark.realtime { response = client.query(query:, variables:) } * 1000
      cost = response.body.dig("extensions", "cost")

      # Log query with timing and cost
      ActiveShopifyGraphQL::Logging::GraphqlLogger.log(
        query: query,
        duration_ms: duration_ms,
        cost: cost,
        variables: variables
      )

      errors = response.body["errors"]
      raise errors.inspect if errors.present?
      response.body if response
    end
  end
end

Controller Runtime (Optional)

Add GraphQL timing to your Rails request logs (Completed 200 OK ... GraphQL: 45.2ms, 38 cost):

# config/initializers/graphql_controller_runtime.rb
ActiveSupport.on_load(:action_controller) do
  include ActiveShopifyGraphQL::Logging::GraphqlControllerRuntime
end

This will append GraphQL timing and cost summaries to your controller logs:

Completed 200 OK in 250ms (Views: 12.3ms | ActiveRecord: 5.2ms | GraphQL: 45.2ms, 38 cost)

API Reference

Attribute Options

attribute :name,
  path: "displayName",                    # GraphQL path (auto-inferred if omitted)
  type: :string,                          # Type coercion
  null: false,                            # Can be null? (default: true)
  default: "value",                       # Default value (default: nil)
  transform: ->(v) { v.upcase }           # Custom transform

Supported Types: :string, :integer, :float, :boolean, :datetime

Connection Options

has_many_connected :orders,
  class_name: "Order",                    # Target class (default: connection name)
  query_name: "orders",                   # GraphQL field (default: pluralized)
  default_arguments: {                    # Default query args
    first: 10,
    sort_key: 'CREATED_AT',
    reverse: false
  },
  eager_load: true,                       # Auto eager load? (default: false)
  inverse_of: :customer                   # Inverse connection (optional)

Association Options

has_many :rewards,
  foreign_key: :shopify_customer_id       # ActiveRecord column
  primary_key: :id                        # Model attribute (default: :id)

has_one :billing_address,
  class_name: "Address"

Advanced Topics

Application Base Class

Create a base class for shared behavior:

# app/models/application_shopify_gql_record.rb
class ApplicationShopifyRecord < ActiveShopifyGraphQL::Model
  attribute :id, transform: ->(gid) { gid.split("/").last }
  attribute :gid, path: "id"
end

# Then inherit
class Customer < ApplicationShopifyRecord
  graphql_type "Customer"
  attribute :name, path: "displayName"
end

Custom Loaders

Create your own loaders for specialized behavior:

class MyCustomLoader < ActiveShopifyGraphQL::Loader
  def fragment
    # Return GraphQL fragment string
  end

  def map_response_to_attributes(response)
    # Map response to attribute hash
  end
end

# Use it
Customer.with_loader(MyCustomLoader).find(123)

Testing

The gem ships with a built-in testing module that lets you register test data using Ruby-level attributes. find, where, find_by, includes, and connections all work transparently without real API calls.

Setup

# spec/spec_helper.rb (or test_helper.rb)
require "active_shopify_graphql/testing"

RSpec.configure do |config|
  config.before(:each) { ActiveShopifyGraphQL::Testing.enable! }
  config.after(:each)  { ActiveShopifyGraphQL::Testing.reset! }
end

Registering Test Data

# Register records using Ruby attribute names — IDs are auto-normalized to GIDs
ActiveShopifyGraphQL::Testing.register(Customer, [
  { id: 1, email: "[email protected]", display_name: "John" },
  { id: 2, email: "[email protected]", display_name: "Jane" }
])

# With inline connections
ActiveShopifyGraphQL::Testing.register(Customer, [
  {
    id: 1,
    email: "[email protected]",
    orders: [{ id: "gid://shopify/Order/100", name: "#1001" }]
  }
])

# With extra search fields (not model attributes, used only for where matching)
ActiveShopifyGraphQL::Testing.register(ProductVariant, [
  { id: 1, sku: "ABC", product_id: 10 }
])

Using in Tests

All query patterns work the same as in production:

Customer.find(1)                                    # => Customer instance
Customer.where(email: "[email protected]").to_a      # => [Customer]
Customer.find_by(email: "[email protected]")         # => Customer or nil
Customer.includes(:orders).find(1)                  # => Customer with orders eager-loaded
customer.orders                                     # => lazy-loads from store
ProductVariant.where(product_id: 10).to_a           # => matches on search field

Manual Mocking

For simpler cases, you can also mock data directly:

customer = Customer.new(id: "gid://shopify/Customer/123")
customer.orders = [Order.new(id: "gid://shopify/Order/1")]
expect(customer.orders.size).to eq(1)

Development

# Install dependencies
bin/setup

# Run tests
bundle exec rake spec

# Run console
bin/console

# Lint
bundle exec rubocop

Roadmap

  • [x] Attribute-based model definition
  • [x] Metafield attributes
  • [x] Query optimization with select
  • [x] GraphQL connections with lazy/eager loading
  • [x] Cursor-based pagination
  • [x] Builtin instrumentation to track query costs
  • [ ] Metaobjects as models
  • [ ] Advanced error handling and retry mechanisms
  • [ ] Caching layer
  • [ ] Chained .where with .not support
  • [ ] Basic mutation support

Contributing

Bug reports and pull requests are welcome on GitHub at nebulab/active_shopify_graphql.


License

The gem is available as open source under the MIT License.


Made by Nebulab