Skip to content

Commit 91ecb75

Browse files
authored
Merge pull request #1 from nebulab/nirebu/where-query
Add `Model.where` query methods
2 parents b244545 + 353244f commit 91ecb75

19 files changed

+2156
-236
lines changed

.rubocop.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,39 @@ Style/StringLiterals:
33

44
Metrics/BlockLength:
55
Enabled: false
6+
7+
Style/Documentation:
8+
Enabled: false
9+
10+
Metrics/CyclomaticComplexity:
11+
Enabled: false
12+
13+
Metrics/AbcSize:
14+
Enabled: false
15+
16+
Metrics/MethodLength:
17+
Enabled: false
18+
19+
Metrics/ModuleLength:
20+
Enabled: false
21+
22+
Layout/LineLength:
23+
Enabled: false
24+
25+
Lint/MissingSuper:
26+
Enabled: false
27+
28+
Metrics/PerceivedComplexity:
29+
Enabled: false
30+
31+
Naming/PredicatePrefix:
32+
Enabled: false
33+
34+
Style/TrailingCommaInHashLiteral:
35+
Enabled: false
36+
37+
Style/TrailingCommaInArrayLiteral:
38+
Enabled: false
39+
40+
Metrics/ParameterLists:
41+
Enabled: false

AGENTS.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Repository Guidelines
2+
3+
## Project Structure & Module Organization
4+
- Core code lives in `lib/active_shopify_graphql/`, with entrypoint `lib/active_shopify_graphql.rb` and loaders/associations split into focused files.
5+
- Tests sit in `spec/` using RSpec; shared config is in `spec/spec_helper.rb`.
6+
- Executables and setup utilities are in `bin/` (`bin/setup`, `bin/console`); Rake tasks are defined in `Rakefile`.
7+
8+
## Build, Test, and Development Commands
9+
- `bundle install` — install dependencies (required before any other command).
10+
- `bin/setup` — project bootstrap (bundler + any initial prep).
11+
- `bundle exec rake spec` or just `bundle exec rspec` — run the test suite (default Rake task).
12+
- `bundle exec rubocop` — lint/format check using `.rubocop.yml`.
13+
- `bin/console` — interactive console with the gem loaded for quick experiments.
14+
15+
## Coding Style & Naming Conventions
16+
- Ruby 3.2+; prefer idiomatic Ruby with 2-space indentation and frozen string literals already enabled.
17+
- Follow existing module/loader patterns under `ActiveShopifyGraphQL` (e.g., `AdminApiLoader`, `CustomerAccountApiLoader`).
18+
- Keep loaders small: expose `fragment` and `map_response_to_attributes` for clarity.
19+
- Use RuboCop defaults unless explicitly relaxed in `.rubocop.yml`; respect existing disables instead of re-enabling.
20+
21+
## Testing Guidelines
22+
- Framework: RSpec with documentation formatter (`.rspec`).
23+
- Place specs under `spec/` and name files `*_spec.rb` matching the class/module under test.
24+
- Do not use `let` or `before` blocks in specs; each test case should tell a complete story.
25+
- Use verifying doubles instead of normal doubles. Prefer `{instance|class}_{double|spy}` to `double` or `spy`
26+
- Prefer explicit model/loader fixtures; stub external Shopify calls rather than hitting the network.
27+
- Aim to cover happy path and schema edge cases (missing attributes, nil fragments). Add regression specs with minimal fixtures.
28+
29+
## Commit & Pull Request Guidelines
30+
- Use short, imperative commit subjects (≈50 chars) with focused diffs; group related changes together.
31+
- Reference issues in commit messages or PR bodies when relevant.
32+
- PRs should include: what changed, why, and how to test (commands run, expected outcomes). Add screenshots only if user-facing behavior is affected.
33+
- Ensure CI-critical commands (`bundle exec rake spec`, `bundle exec rubocop`) pass locally before opening a PR.
34+
35+
## Security & Configuration Tips
36+
- Verify Admin vs Customer Account API client selection when adding loaders; avoid leaking tokens between contexts.
37+
- If introducing new configuration knobs, document defaults and required environment variables in `README.md` and add minimal validation in configuration objects.

README.md

Lines changed: 170 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,20 @@ end
5252

5353
### Basic Model Setup
5454

55-
Create a model that includes `ActiveShopifyGraphQL::Base`:
55+
Create a model that includes `ActiveShopifyGraphQL::Base` and define attributes directly:
5656

5757
```ruby
5858
class Customer
5959
include ActiveShopifyGraphQL::Base
6060

61-
attr_accessor :id, :name, :email, :created_at
61+
# Define the GraphQL type
62+
graphql_type "Customer"
63+
64+
# Define attributes with automatic GraphQL path inference and type coercion
65+
attribute :id, type: :string
66+
attribute :name, path: "displayName", type: :string
67+
attribute :email, path: "defaultEmailAddress.emailAddress", type: :string
68+
attribute :created_at, type: :datetime
6269

6370
validates :id, presence: true
6471

@@ -68,38 +75,95 @@ class Customer
6875
end
6976
```
7077

71-
### Creating Loaders
78+
### Defining Attributes
79+
80+
Attributes are now defined directly in the model class using the `attribute` method. The GraphQL fragments and response mapping are automatically generated!
7281

73-
Create loader classes to define how to fetch and map data from Shopify's GraphQL APIs:
82+
#### Basic Attribute Definition
7483

7584
```ruby
76-
# For Admin API
77-
module ActiveShopifyGraphQL::Loaders::AdminApi
78-
class CustomerLoader < ActiveShopifyGraphQL::AdminApiLoader
79-
def fragment
80-
<<~GRAPHQL
81-
fragment CustomerFragment on Customer {
82-
id
83-
displayName
84-
defaultEmailAddress {
85-
emailAddress
86-
}
87-
createdAt
88-
}
89-
GRAPHQL
90-
end
91-
92-
def map_response_to_attributes(response_data)
93-
customer_data = response_data.dig("data", "customer")
94-
return nil unless customer_data
95-
96-
{
97-
id: customer_data["id"],
98-
name: customer_data["displayName"],
99-
email: customer_data.dig("defaultEmailAddress", "emailAddress"),
100-
created_at: customer_data["createdAt"]
101-
}
102-
end
85+
class Customer
86+
include ActiveShopifyGraphQL::Base
87+
88+
graphql_type "Customer"
89+
90+
# Define attributes with automatic GraphQL path inference and type coercion
91+
attribute :id, type: :string
92+
attribute :name, path: "displayName", type: :string
93+
attribute :email, path: "defaultEmailAddress.emailAddress", type: :string
94+
attribute :created_at, type: :datetime
95+
96+
# Custom transform example
97+
attribute :tags, type: :string, transform: ->(tags_array) { tags_array.join(", ") }
98+
end
99+
```
100+
101+
#### Attribute Definition Options
102+
103+
The `attribute` method supports several options for flexibility:
104+
105+
```ruby
106+
attribute :name,
107+
path: "displayName", # Custom GraphQL path (auto-inferred if omitted)
108+
type: :string, # Type coercion (:string, :integer, :float, :boolean, :datetime)
109+
null: false, # Whether the attribute can be null (default: true)
110+
transform: ->(value) { value.upcase } # Custom transformation block
111+
```
112+
113+
**Auto-inference:** When `path` is omitted, it's automatically inferred by converting snake_case to camelCase (e.g., `display_name``displayName`).
114+
115+
**Nested paths:** Use dot notation for nested GraphQL fields (e.g., `"defaultEmailAddress.emailAddress"`).
116+
117+
**Type coercion:** Automatic conversion using ActiveModel types ensures type safety.
118+
119+
**Array handling:** Arrays are automatically preserved regardless of the specified type.
120+
121+
#### Metafield Attributes
122+
123+
Shopify metafields can be easily accessed using the `metafield_attribute` method:
124+
125+
```ruby
126+
class Product
127+
include ActiveShopifyGraphQL::Base
128+
129+
graphql_type "Product"
130+
131+
# Regular attributes
132+
attribute :id, type: :string
133+
attribute :title, type: :string
134+
135+
# Metafield attributes
136+
metafield_attribute :boxes_available, namespace: 'custom', key: 'available_boxes', type: :integer
137+
metafield_attribute :seo_description, namespace: 'seo', key: 'meta_description', type: :string
138+
metafield_attribute :product_data, namespace: 'custom', key: 'data', type: :json
139+
metafield_attribute :is_featured, namespace: 'custom', key: 'featured', type: :boolean, null: false
140+
end
141+
```
142+
143+
The metafield attributes automatically generate the correct GraphQL syntax and handle value extraction from either `value` or `jsonValue` fields based on the type.
144+
145+
#### API-Specific Attributes
146+
147+
For models that need different attributes depending on the API being used, you can define loader-specific overrides:
148+
149+
```ruby
150+
class Customer
151+
include ActiveShopifyGraphQL::Base
152+
153+
graphql_type "Customer"
154+
155+
# Default attributes (used by Admin API)
156+
attribute :id, type: :string
157+
attribute :name, path: "displayName", type: :string
158+
attribute :email, path: "defaultEmailAddress.emailAddress", type: :string
159+
attribute :created_at, type: :datetime
160+
161+
# Customer Account API uses different field names
162+
for_loader ActiveShopifyGraphQL::CustomerAccountApiLoader do
163+
attribute :name, path: "firstName", type: :string
164+
attribute :last_name, path: "lastName", type: :string
165+
attribute :email, path: "emailAddress.emailAddress", type: :string
166+
attribute :phone, path: "phoneNumber.phoneNumber", type: :string, null: true
103167
end
104168
end
105169
```
@@ -109,11 +173,10 @@ end
109173
Use the `find` method to retrieve records by ID:
110174

111175
```ruby
112-
# Using default loader (Admin API)
176+
# Using Admin API (default)
113177
customer = Customer.find("gid://shopify/Customer/123456789")
114-
115-
# Using specific loader
116-
customer = Customer.find("gid://shopify/Customer/123456789", loader: custom_loader)
178+
# You can also use just the ID number
179+
customer = Customer.find(123456789)
117180

118181
# Using Customer Account API
119182
customer = Customer.with_customer_account_api(token).find
@@ -134,6 +197,63 @@ customer = Customer.with_customer_account_api(token).find
134197
customer = Customer.with_admin_api.find(id)
135198
```
136199

200+
### Querying Records
201+
202+
Use the `where` method to query multiple records using Shopify's search syntax:
203+
204+
```ruby
205+
# Simple conditions
206+
customers = Customer.where(email: "john@example.com")
207+
208+
# Range queries
209+
customers = Customer.where(created_at: { gte: "2024-01-01", lt: "2024-02-01" })
210+
customers = Customer.where(orders_count: { gte: 5 })
211+
212+
# Multi-word values are automatically quoted
213+
customers = Customer.where(first_name: "John Doe")
214+
215+
# With limits
216+
customers = Customer.where({ email: "john@example.com" }, limit: 100)
217+
```
218+
219+
The `where` method automatically converts Ruby conditions into Shopify's GraphQL query syntax and validates that the query fields are supported by Shopify.
220+
221+
### Optimizing Queries with Select
222+
223+
Use the `select` method to only fetch specific attributes, reducing GraphQL query size and improving performance:
224+
225+
```ruby
226+
# Only fetch id, name, and email
227+
customer = Customer.select(:id, :name, :email).find(123)
228+
229+
# Works with where queries too
230+
customers = Customer.select(:id, :name).where(country: "Canada")
231+
232+
# Always includes id even if not specified
233+
customer = Customer.select(:name).find(123)
234+
# This will still include :id in the GraphQL query
235+
```
236+
237+
The `select` method validates that the specified attributes exist and automatically includes the `id` field for proper object identification.
238+
239+
### Optimizing Queries with Select
240+
241+
Use the `select` method to only fetch specific attributes, reducing GraphQL query size and improving performance:
242+
243+
```ruby
244+
# Only fetch id, name, and email
245+
customer = Customer.select(:id, :name, :email).find(123)
246+
247+
# Works with where queries too
248+
customers = Customer.select(:id, :name).where(country: "Canada")
249+
250+
# Always includes id even if not specified
251+
customer = Customer.select(:name).find(123)
252+
# This will still include :id in the GraphQL query
253+
```
254+
255+
The `select` method validates that the specified attributes exist and automatically includes the `id` field for proper object identification.
256+
137257
## Associations
138258

139259
ActiveShopifyGraphQL provides ActiveRecord-like associations to define relationships between the Shopify native models and your own custom ones.
@@ -146,28 +266,25 @@ Use `has_many` to define one-to-many relationships:
146266
class Customer
147267
include ActiveShopifyGraphQL::Base
148268

149-
attr_accessor :id, :display_name, :email, :created_at
269+
graphql_type "Customer"
270+
271+
attribute :id, type: :string
272+
attribute :display_name, type: :string
273+
attribute :email, path: "defaultEmailAddress.emailAddress", type: :string
274+
attribute :created_at, type: :datetime
150275

151276
# Define an association to one of your own ActiveRecord models
152277
# foreign_key maps the id of the GraphQL powered model to the rewards.shopify_customer_id table
153278
has_many :rewards, foreign_key: :shopify_customer_id
154279

155280
validates :id, presence: true
156281
end
157-
158-
class Order
159-
include ActiveShopifyGraphQL::Base
160-
161-
attr_accessor :id, :name, :shopify_customer_id, :created_at
162-
163-
validates :id, presence: true
164-
end
165282
```
166283

167284
#### Using the Association
168285

169286
```ruby
170-
customer = Customer.find("gid://shopify/Customer/123456789")
287+
customer = Customer.find("gid://shopify/Customer/123456789") # or Customer.find(123456789)
171288

172289
# Access associated orders (lazy loaded)
173290
customer.rewards
@@ -191,16 +308,22 @@ The associations automatically handle Shopify GID format conversion, extracting
191308

192309
## Next steps
193310

194-
- [ ] Support `Model.where(param: value)` proxying params to the GraphQL query attribute
311+
- [x] Support `Model.where(param: value)` proxying params to the GraphQL query attribute
312+
- [x] Attribute-based model definition with automatic GraphQL fragment generation
313+
- [x] Metafield attributes for easy access to Shopify metafields
314+
- [x] Query optimization with `select` method
195315
- [ ] Eager loading of GraphQL connections via `Customer.includes(:orders).find(id)` in a single GraphQL query
316+
- [ ] Better error handling and retry mechanisms for GraphQL API calls
317+
- [ ] Caching layer for frequently accessed data
318+
- [ ] Support for GraphQL subscriptions
196319

197320
## Development
198321

199322
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.
200323

201324
## Contributing
202325

203-
Bug reports and pull requests are welcome on GitHub at https://github.com/team-cometeer/active_shopify_graphql.
326+
Bug reports and pull requests are welcome on GitHub at https://github.com/nebulab/active_shopify_graphql.
204327

205328
## License
206329

lib/active_shopify_graphql.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# frozen_string_literal: true
22

3+
require 'active_support'
4+
require 'active_support/inflector'
5+
require 'active_support/concern'
6+
require 'active_support/core_ext/object/blank'
7+
require 'active_model'
8+
require 'globalid'
9+
310
require_relative "active_shopify_graphql/version"
411
require_relative "active_shopify_graphql/configuration"
512
require_relative "active_shopify_graphql/base"

lib/active_shopify_graphql/admin_api_loader.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
module ActiveShopifyGraphQL
44
class AdminApiLoader < Loader
5+
def initialize(model_class = nil, selected_attributes: nil)
6+
super(model_class, selected_attributes: selected_attributes)
7+
end
8+
59
private
610

711
def execute_graphql_query(query, **variables)

0 commit comments

Comments
 (0)