Bringing Read Only (for now) ActiveRecord-like domain modeling to Shopify GraphQL APIs
gem install active_shopify_graphql# Configure in pure Ruby
ActiveShopifyGraphQL.configure do |config|
config.admin_api_client = ShopifyGraphQL::Client
config.customer_account_client_class = Shopify::Account::Client
end
# Or define a Rails initializer
Rails.configuration.to_prepare do
ActiveShopifyGraphQL.configure do |config|
config.admin_api_client = ShopifyGraphQL::Client
config.customer_account_client_class = Shopify::Account::Client
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)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
# After: Peace of mind
customer = Customer.includes(:orders).find(123456789)
customer.email # => "john@example.com"
customer.created_at # => #<DateTime>
customer.orders.to_a # Lazily loaded as a single queryBenefits:
- ✅ 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
Add to your Gemfile:
gem "active_shopify_graphql"Or install globally:
gem install active_shopify_graphqlConfigure your Shopify GraphQL clients:
# config/initializers/active_shopify_graphql.rb
Rails.configuration.to_prepare do
ActiveShopifyGraphQL.configure do |config|
# Admin API (must respond to #execute(query, **variables))
config.admin_api_client = ShopifyGraphQL::Client
# Customer Account API (must have .from_config(token) and #execute)
config.customer_account_client_class = Shopify::Account::Client
end
endModels 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 auto-generate GraphQL fragments and handle response mapping:
class Customer < ActiveShopifyGraphQL::Model
graphql_type "Customer"
# Auto-inferred path: displayName
attribute :name, 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 }
endConnections to related Shopify data with lazy/eager loading:
class Customer < ActiveShopifyGraphQL::Model
# Lazy by default — loaded on first access
has_many_connected :orders
# 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" }
endDefine 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
endEasy 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
endFor 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 } } }'
endDifferent 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# By GID or numeric ID
customer = Customer.find("gid://shopify/Customer/123456789")
customer = Customer.find(123456789)
# With specific API
Customer.with_customer_account_api(token).find
Customer.with_admin_api.find(123456789)# Hash queries (auto-escaped)
Customer.where(email: "john@example.com")
# Range queries
Customer.where(created_at: { gte: "2024-01-01", lt: "2024-02-01" })
Customer.where(orders_count: { gte: 5 })
# Wildcards (string query)
Customer.where("email:*@example.com")
# Parameter binding (safe)
Customer.where("email::email", email: "john@example.com")
# With limits
Customer.where(email: "@gmail.com").limit(100)# 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")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 firstcustomer = 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# Load in single query (no N+1!)
customer = Customer.includes(:orders, :addresses).find(123)
# Already loaded
orders = customer.orders # No additional query
addresses = customer.addressesclass Customer < ActiveShopifyGraphQL::Model
# Always loaded without explicit includes
has_many_connected :orders, eager_load: true
end
customer = Customer.find(123)
orders = customer.orders # Already loadedcustomer = Customer.find(123)
# Override defaults
customer.orders(first: 25, sort_key: 'UPDATED_AT', reverse: true).to_aclass 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
endBridge 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({})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 transformSupported Types: :string, :integer, :float, :boolean, :datetime
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)has_many :rewards,
foreign_key: :shopify_customer_id # ActiveRecord column
primary_key: :id # Model attribute (default: :id)
has_one :billing_address,
class_name: "Address"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"
endCreate 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)Mock data for tests:
# Mock associations
customer = Customer.new(id: 'gid://shopify/Customer/123')
customer.orders = [Order.new(id: 'gid://shopify/Order/1')]
# Mock connections
customer.orders = mock_orders
expect(customer.orders.size).to eq(1)# Install dependencies
bin/setup
# Run tests
bundle exec rake spec
# Run console
bin/console
# Lint
bundle exec rubocop- Attribute-based model definition
- Metafield attributes
- Query optimization with
select - GraphQL connections with lazy/eager loading
- Cursor-based pagination
- Metaobjects as models
- Builtin instrumentation to track query costs
- Advanced error handling and retry mechanisms
- Caching layer
- Chained
.wherewith.notsupport - Basic mutation support
Bug reports and pull requests are welcome on GitHub at nebulab/active_shopify_graphql.
The gem is available as open source under the MIT License.
Made by Nebulab