1.4.1

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 }