Querying
Preview ecosystem guide: Grant is not part of the Amber 2.0.0-beta.2 core web-app release gate. Its package version, API, and platform support may change independently. Do not add a personal fork as a default dependency.
Grant provides a fluent, chainable query API that generates efficient SQL while maintaining type safety.
Basic Querying
# Find all active users
users = User.where(active: true)
# Chain multiple conditions (AND)
posts = Post.where(published: true, featured: true)
.where(author_id: current_user.id)
# Find with multiple fields
post = Post.find_by(slug: "my-post", published: true)
Query Execution
Queries are lazy - they don't execute until you call a terminal method:
# Building query (not executed)
query = User.where(active: true).order(:name)
# Execution happens here
users = query.select # Returns array of User
first = query.first # Returns User?
count = query.count # Returns Int32
exists = query.exists? # Returns Bool
Where Conditions
Basic WHERE
# Equality
User.where(status: "active")
User.where(age: 25)
# Multiple conditions (AND)
User.where(status: "active", verified: true)
Comparison Operators
Post.where(:views, :gt, 100) # Greater than
Post.where(:price, :lteq, 50.0) # Less than or equal
Post.where(:created_at, :gt, 7.days.ago)
# Available operators
Post.where(:field, :eq, value) # =
Post.where(:field, :neq, value) # !=
Post.where(:field, :gt, value) # >
Post.where(:field, :lt, value) # <
Post.where(:field, :gteq, value) # >=
Post.where(:field, :lteq, value) # <=
Post.where(:field, :in, array) # IN
Post.where(:field, :nin, array) # NOT IN
Post.where(:field, :like, pattern) # LIKE
WhereChain Methods
# Pattern matching
User.where.like(:email, "%@gmail.com")
User.where.not_like(:name, "test%")
# Comparisons
User.where.gt(:age, 18)
User.where.lt(:age, 65)
User.where.gteq(:score, 80)
User.where.lteq(:price, 100)
# NULL checks
User.where.is_null(:deleted_at)
User.where.is_not_null(:verified_at)
# Ranges
User.where.between(:age, 25..35)
Product.where.between(:price, 10.0..50.0)
# NOT IN
User.where.not_in(:id, [1, 2, 3])
Raw SQL Conditions
# With placeholders
Post.where("LOWER(title) LIKE ?", ["%crystal%"])
User.where("age * 2 > ?", [50])
# PostgreSQL specific
Post.where("tags @> ARRAY[?]::varchar[]", ["ruby"])
Post.where("metadata->>'key' = $", ["value"])
OR and NOT Conditions
OR Groups
# Simple OR
User.where(role: "admin").or { |q| q.where(role: "moderator") }
# SQL: WHERE role = 'admin' OR role = 'moderator'
# Complex OR
User.where(verified: true)
.or do |q|
q.where(role: "admin")
.where.gt(:level, 10)
end
# SQL: WHERE verified = true OR (role = 'admin' AND level > 10)
NOT Groups
# Simple NOT
User.not { |q| q.where(status: "banned") }
# Complex NOT
User.not do |q|
q.where(active: false)
.where.is_null(:email_verified_at)
end
# SQL: WHERE NOT (active = false AND email_verified_at IS NULL)
Ordering and Limiting
# Single field
User.order(:name) # ASC by default
User.order(created_at: :desc) # Explicit direction
# Multiple fields
Post.order(featured: :desc, created_at: :desc)
# Limit and offset
Post.limit(10)
Post.offset(20).limit(10) # Pagination
# First/Last
User.first # Single record
User.first(5) # First 5 records
User.last(10) # Last 10 records
# Distinct
User.distinct.select(:country)
Scopes
Defining Scopes
class Post < Grant::Base
# Simple scopes
scope :published, -> { where(published: true) }
scope :featured, -> { where(featured: true) }
scope :recent, -> { order(created_at: :desc) }
# Parameterized scopes
scope :by_author, ->(author_id : Int32) { where(author_id: author_id) }
scope :tagged_with, ->(tag : String) { where("tags @> ARRAY[?]", [tag]) }
scope :older_than, ->(date : Time) { where.lt(:created_at, date) }
# Complex scopes
scope :popular, -> {
where.gt(:views, 1000)
.where.gt(:likes, 100)
.order(views: :desc)
}
end
# Using scopes
Post.published.recent.limit(10)
Post.by_author(current_user.id).featured
Default Scopes
class Product < Grant::Base
# Applied to all queries automatically
default_scope { where(active: true).where.is_null(:deleted_at) }
# Bypass default scope
scope :all_including_deleted, -> { unscoped }
end
Product.all # Includes default scope
Product.unscoped.all # Bypasses default scope
Joins and Eager Loading
Joins
# Join with association
Post.joins(:author)
.where("users.active = ?", [true])
# Left joins (include records without association)
User.left_joins(:posts)
.where("posts.id IS NULL") # Users without posts
Eager Loading
# Preload associations
posts = Post.includes(:author, :comments)
posts.each do |post|
puts post.author.name # No additional query
puts post.comments.size # No additional query
end
# Nested includes
User.includes(posts: [:comments, :tags])
Aggregations
# Count
User.count
User.where(active: true).count
User.distinct.count(:country)
# Sum, Average, Min, Max
Order.sum(:total)
Product.average(:price)
Product.minimum(:price)
Product.maximum(:stock)
# With grouping
Order.group_by(:customer_id).sum(:total)
Review.group_by(:product_id).average(:rating)
Batch Processing
# Bad: Loads everything at once
User.all.each { |user| user.process! }
# Good: Process in batches
User.find_in_batches(batch_size: 1000) do |users|
users.each(&.process!)
end
Pluck for Values
# Bad: Instantiate models
emails = User.where(active: true).map(&.email)
# Good: Direct database values
emails = User.where(active: true).pluck(:email)
Complex Query Example
def search_products(params)
query = Product.where(active: true)
# Text search
if term = params["q"]?
query = query.where.like(:name, "%#{term}%")
.or { |q| q.where.like(:description, "%#{term}%") }
end
# Price range
if min_price = params["min_price"]?
query = query.where.gteq(:price, min_price.to_f)
end
if max_price = params["max_price"]?
query = query.where.lteq(:price, max_price.to_f)
end
# Categories
if categories = params["categories"]?
query = query.where.in(:category_id, categories.split(","))
end
# In stock only
if params["in_stock"]?
query = query.where.gt(:stock, 0)
end
# Sorting
case params["sort"]?
when "price_asc"
query = query.order(:price)
when "price_desc"
query = query.order(price: :desc)
when "newest"
query = query.order(created_at: :desc)
else
query = query.order(:name)
end
query.limit(params.fetch("limit", "20").to_i)
end
Best Practices
1. Use Indexes
# Ensure indexed columns in WHERE
User.where(email: "[email protected]") # email should be indexed
2. Select Only Needed Columns
# Bad: Loads all columns
users = User.where(active: true)
# Good: Load only required columns
users = User.where(active: true).select(:id, :name, :email)
3. Avoid N+1 Queries
# Bad: N+1 queries
posts = Post.all
posts.each { |post| puts post.author.name }
# Good: Eager loading
posts = Post.includes(:author)
posts.each { |post| puts post.author.name }