Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
37 changes: 37 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
217 changes: 170 additions & 47 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
```
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -146,28 +266,25 @@ 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
has_many :rewards, foreign_key: :shopify_customer_id

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
Expand All @@ -191,16 +308,22 @@ 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

After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.

## 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

Expand Down
7 changes: 7 additions & 0 deletions lib/active_shopify_graphql.rb
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
4 changes: 4 additions & 0 deletions lib/active_shopify_graphql/admin_api_loader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading