{"title":"Querying","description":"Finding and filtering data with Grant's fluent query interface","section":"guides/models/grant","version":"v2","path":"guides/models/grant/queries","canonical_url":"https://amberframework.org/docs/v2/guides/models/grant/queries","markdown_url":"https://amberframework.org/docs/v2/guides/models/grant/queries.md","inherited":false,"content_markdown":"# Querying\n\n> **Supported web path:** Amber CLI `2.0.6` includes Grant in every generated\n> web application and pins the reviewed V2 commit. Preserve that pin while\n> following this beta.\n\n## Where the examples go\n\nQuery expressions run from the controller, job, service, or spec that owns the\nread; they are not complete model files. Named and default scopes belong inside\nthe matching Grant model under `src/models/`. The complex-query example should\nbe extracted to a service or query object under `src/services/` when it is\nshared or independently tested. Blocks on this page use those destinations\nunless a closer comment identifies a different role.\n\nGrant provides a fluent, chainable query API that generates efficient SQL while maintaining type safety.\n\n## Basic Querying\n\n```crystal\n# Find all active users\nusers = User.where(active: true)\n\n# Chain multiple conditions (AND)\nposts = Post.where(published: true, featured: true)\n            .where(author_id: current_user.id)\n\n# Find with multiple fields\npost = Post.find_by(slug: \"my-post\", published: true)\n```\n\n### Query Execution\n\nQueries are lazy - they don't execute until you call a terminal method:\n\n```crystal\n# Building query (not executed)\nquery = User.where(active: true).order(:name)\n\n# Execution happens here\nusers = query.select     # Returns array of User\nfirst = query.first      # Returns User?\ncount = query.count      # Returns Int32\nexists = query.exists?   # Returns Bool\n```\n\n## Where Conditions\n\n### Basic WHERE\n\n```crystal\n# Equality\nUser.where(status: \"active\")\nUser.where(age: 25)\n\n# Multiple conditions (AND)\nUser.where(status: \"active\", verified: true)\n```\n\n### Comparison Operators\n\n```crystal\nPost.where(:views, :gt, 100)        # Greater than\nPost.where(:price, :lteq, 50.0)     # Less than or equal\nPost.where(:created_at, :gt, 7.days.ago)\n\n# Available operators\nPost.where(:field, :eq, value)      # =\nPost.where(:field, :neq, value)     # !=\nPost.where(:field, :gt, value)      # >\nPost.where(:field, :lt, value)      # <\nPost.where(:field, :gteq, value)    # >=\nPost.where(:field, :lteq, value)    # <=\nPost.where(:field, :in, array)      # IN\nPost.where(:field, :nin, array)     # NOT IN\nPost.where(:field, :like, pattern)  # LIKE\n```\n\n### WhereChain Methods\n\n```crystal\n# Pattern matching\nUser.where.like(:email, \"%@gmail.com\")\nUser.where.not_like(:name, \"test%\")\n\n# Comparisons\nUser.where.gt(:age, 18)\nUser.where.lt(:age, 65)\nUser.where.gteq(:score, 80)\nUser.where.lteq(:price, 100)\n\n# NULL checks\nUser.where.is_null(:deleted_at)\nUser.where.is_not_null(:verified_at)\n\n# Ranges\nUser.where.between(:age, 25..35)\nProduct.where.between(:price, 10.0..50.0)\n\n# NOT IN\nUser.where.not_in(:id, [1, 2, 3])\n```\n\n### Raw SQL Conditions\n\n```crystal\n# With placeholders\nPost.where(\"LOWER(title) LIKE ?\", [\"%crystal%\"])\nUser.where(\"age * 2 > ?\", [50])\n\n# PostgreSQL specific\nPost.where(\"tags @> ARRAY[?]::varchar[]\", [\"ruby\"])\nPost.where(\"metadata->>'key' = $\", [\"value\"])\n```\n\n## OR and NOT Conditions\n\n### OR Groups\n\n```crystal\n# Simple OR\nUser.where(role: \"admin\").or { |q| q.where(role: \"moderator\") }\n# SQL: WHERE role = 'admin' OR role = 'moderator'\n\n# Complex OR\nUser.where(verified: true)\n    .or do |q|\n      q.where(role: \"admin\")\n       .where.gt(:level, 10)\n    end\n# SQL: WHERE verified = true OR (role = 'admin' AND level > 10)\n```\n\n### NOT Groups\n\n```crystal\n# Simple NOT\nUser.not { |q| q.where(status: \"banned\") }\n\n# Complex NOT\nUser.not do |q|\n  q.where(active: false)\n   .where.is_null(:email_verified_at)\nend\n# SQL: WHERE NOT (active = false AND email_verified_at IS NULL)\n```\n\n## Ordering and Limiting\n\n```crystal\n# Single field\nUser.order(:name)              # ASC by default\nUser.order(created_at: :desc)  # Explicit direction\n\n# Multiple fields\nPost.order(featured: :desc, created_at: :desc)\n\n# Limit and offset\nPost.limit(10)\nPost.offset(20).limit(10)  # Pagination\n\n# First/Last\nUser.first          # Single record\nUser.first(5)       # First 5 records\nUser.last(10)       # Last 10 records\n\n# Distinct\nUser.distinct.select(:country)\n```\n\n## Scopes\n\n### Defining Scopes\n\n```crystal\nclass Post < Grant::Base\n  # Simple scopes\n  scope :published, -> { where(published: true) }\n  scope :featured, -> { where(featured: true) }\n  scope :recent, -> { order(created_at: :desc) }\n\n  # Parameterized scopes\n  scope :by_author, ->(author_id : Int32) { where(author_id: author_id) }\n  scope :tagged_with, ->(tag : String) { where(\"tags @> ARRAY[?]\", [tag]) }\n  scope :older_than, ->(date : Time) { where.lt(:created_at, date) }\n\n  # Complex scopes\n  scope :popular, -> {\n    where.gt(:views, 1000)\n         .where.gt(:likes, 100)\n         .order(views: :desc)\n  }\nend\n\n# Using scopes\nPost.published.recent.limit(10)\nPost.by_author(current_user.id).featured\n```\n\n### Default Scopes\n\n```crystal\nclass Product < Grant::Base\n  # Applied to all queries automatically\n  default_scope { where(active: true).where.is_null(:deleted_at) }\n\n  # Bypass default scope\n  scope :all_including_deleted, -> { unscoped }\nend\n\nProduct.all              # Includes default scope\nProduct.unscoped.all     # Bypasses default scope\n```\n\n## Joins and Eager Loading\n\n### Joins\n\n```crystal\n# Join with association\nPost.joins(:author)\n    .where(\"users.active = ?\", [true])\n\n# Left joins (include records without association)\nUser.left_joins(:posts)\n    .where(\"posts.id IS NULL\")  # Users without posts\n```\n\n### Eager Loading\n\n```crystal\n# Preload associations\nposts = Post.includes(:author, :comments)\nposts.each do |post|\n  puts post.author.name        # No additional query\n  puts post.comments.size      # No additional query\nend\n\n# Nested includes\nUser.includes(posts: [:comments, :tags])\n```\n\n## Aggregations\n\n```crystal\n# Count\nUser.count\nUser.where(active: true).count\nUser.distinct.count(:country)\n\n# Sum, Average, Min, Max\nOrder.sum(:total)\nProduct.average(:price)\nProduct.minimum(:price)\nProduct.maximum(:stock)\n\n# With grouping\nOrder.group_by(:customer_id).sum(:total)\nReview.group_by(:product_id).average(:rating)\n```\n\n## Batch Processing\n\n```crystal\n# Bad: Loads everything at once\nUser.all.each { |user| user.process! }\n\n# Good: Process in batches\nUser.find_in_batches(batch_size: 1000) do |users|\n  users.each(&.process!)\nend\n```\n\n## Pluck for Values\n\n```crystal\n# Bad: Instantiate models\nemails = User.where(active: true).map(&.email)\n\n# Good: Direct database values\nemails = User.where(active: true).pluck(:email)\n```\n\n## Complex Query Example\n\n```crystal\ndef search_products(params)\n  query = Product.where(active: true)\n\n  # Text search\n  if term = params[\"q\"]?\n    query = query.where.like(:name, \"%#{term}%\")\n                 .or { |q| q.where.like(:description, \"%#{term}%\") }\n  end\n\n  # Price range\n  if min_price = params[\"min_price\"]?\n    query = query.where.gteq(:price, min_price.to_f)\n  end\n  if max_price = params[\"max_price\"]?\n    query = query.where.lteq(:price, max_price.to_f)\n  end\n\n  # Categories\n  if categories = params[\"categories\"]?\n    query = query.where.in(:category_id, categories.split(\",\"))\n  end\n\n  # In stock only\n  if params[\"in_stock\"]?\n    query = query.where.gt(:stock, 0)\n  end\n\n  # Sorting\n  case params[\"sort\"]?\n  when \"price_asc\"\n    query = query.order(:price)\n  when \"price_desc\"\n    query = query.order(price: :desc)\n  when \"newest\"\n    query = query.order(created_at: :desc)\n  else\n    query = query.order(:name)\n  end\n\n  query.limit(params.fetch(\"limit\", \"20\").to_i)\nend\n```\n\n## Best Practices\n\n### 1. Use Indexes\n\n```crystal\n# Ensure indexed columns in WHERE\nUser.where(email: \"user@example.com\")  # email should be indexed\n```\n\n### 2. Select Only Needed Columns\n\n```crystal\n# Bad: Loads all columns\nusers = User.where(active: true)\n\n# Good: Load only required columns\nusers = User.where(active: true).select(:id, :name, :email)\n```\n\n### 3. Avoid N+1 Queries\n\n```crystal\n# Bad: N+1 queries\nposts = Post.all\nposts.each { |post| puts post.author.name }\n\n# Good: Eager loading\nposts = Post.includes(:author)\nposts.each { |post| puts post.author.name }\n```"}