Generate query objects for complex database queries. Use when queries are complex, reusable, need filtering/sorting, or says "complex query", "search", "filter posts", "advanced search", "query builder", "scope is too complex".
Extract complex database queries into reusable query objects for better organization and performance.
Task Progress (copy and check off):
- [ ] 1. Identify complex query to extract
- [ ] 2. Create query class
- [ ] 3. Move query logic
- [ ] 4. Add chainable methods
- [ ] 5. Use in controller
- [ ] 6. Test query performance
✅ Good candidates:
❌ Don't use for:
Model.where(status: :active)app/queries/
├── application_query.rb # Base class
├── posts_query.rb
├── users_query.rb
├── job_posts_query.rb
└── search/
├── post_search_query.rb
└── user_search_query.rb
# app/queries/application_query.rb
class ApplicationQuery
def initialize(relation = nil)
@relation = relation
end
def call
@relation
end
alias_method :resolve, :call
private
attr_reader :relation
end
# app/queries/example_query.rb
class ExampleQuery < ApplicationQuery
def initialize(relation = Model.all)
super(relation)
end
def call
@relation
.where(conditions)
.joins(associations)
.order(ordering)
end
private
def conditions
# WHERE conditions
end
def associations
# JOIN logic
end
def ordering
{ created_at: :desc }
end
end
Usage:
# In controller
@results = ExampleQuery.new.call
# Or with relation
@results = ExampleQuery.new(Model.active).call
# app/queries/posts_query.rb
class PostsQuery < ApplicationQuery
def initialize(relation = Post.includes(:user))
super(relation)
@filters = {}
end
def call
apply_filters
@relation
end
# Chainable filter methods
def search(term)
return self if term.blank?
@filters[:search] = term
self
end
def by_status(status)
return self if status.blank?
@filters[:status] = status
self
end
def by_user(user_id)
return self if user_id.blank?
@filters[:user_id] = user_id
self
end
def published_only
@filters[:published] = true
self
end
def recent
@filters[:recent] = true
self
end
def popular(min_likes = 10)
@filters[:popular] = min_likes
self
end
private
def apply_filters
apply_search_filter
apply_status_filter
apply_user_filter
apply_published_filter
apply_recent_filter
apply_popular_filter
end
def apply_search_filter
return unless @filters[:search]
term = @filters[:search]
@relation = @relation.where(
"title LIKE ? OR content LIKE ?",
"%#{term}%",
"%#{term}%"
)
end
def apply_status_filter
return unless @filters[:status]
@relation = @relation.where(status: @filters[:status])
end
def apply_user_filter
return unless @filters[:user_id]
@relation = @relation.where(user_id: @filters[:user_id])
end
def apply_published_filter
return unless @filters[:published]
@relation = @relation.where(status: :published)
end
def apply_recent_filter
return unless @filters[:recent]
@relation = @relation.where("created_at > ?", 7.days.ago)
.order(created_at: :desc)
end
def apply_popular_filter
return unless @filters[:popular]
@relation = @relation.where("likes_count >= ?", @filters[:popular])
.order(likes_count: :desc)
end
end
Controller Usage:
class PostsController < ApplicationController
def index
@posts = PostsQuery.new
.search(params[:q])
.by_status(params[:status])
.published_only
.recent
.call
.page(params[:page])
end
end
# app/queries/search/post_search_query.rb
module Search
class PostSearchQuery < ApplicationQuery
def initialize(params = {})
super(Post.includes(:user))
@params = params
end
def call
@relation = filter_by_keyword
@relation = filter_by_category
@relation = filter_by_date_range
@relation = filter_by_likes
@relation = sort_results
@relation
end
private
def filter_by_keyword
keyword = @params[:keyword]
return @relation if keyword.blank?
@relation.where(
"LOWER(title) LIKE :term OR LOWER(content) LIKE :term",
term: "%#{keyword.downcase}%"
)
end
def filter_by_category
category = @params[:category]
return @relation if category.blank?
@relation.where(category: category)
end
def filter_by_date_range
start_date = @params[:start_date]
end_date = @params[:end_date]
return @relation if start_date.blank? || end_date.blank?
@relation.where(created_at: start_date..end_date)
end
def filter_by_likes
min_likes = @params[:min_likes]&.to_i || 0
@relation.where("likes_count >= ?", min_likes)
end
def sort_results
sort_by = @params[:sort_by] || "recent"
case sort_by
when "popular"
@relation.order(likes_count: :desc, created_at: :desc)
when "commented"
@relation.order(comments_count: :desc, created_at: :desc)
else # recent
@relation.order(created_at: :desc)
end
end
end
end
# app/queries/job_posts_query.rb
class JobPostsQuery < ApplicationQuery
VALID_CATEGORIES = %w[development design planning marketing].freeze
VALID_TYPES = %w[short_term long_term].freeze
VALID_STATUSES = %w[open closed].freeze
def initialize(relation = JobPost.includes(:user))
super(relation)
end
def by_category(category)
return self unless VALID_CATEGORIES.include?(category)
@relation = @relation.where(category: category)
self
end
def by_project_type(type)
return self unless VALID_TYPES.include?(type)
@relation = @relation.where(project_type: type)
self
end
def by_status(status)
return self unless VALID_STATUSES.include?(status)
@relation = @relation.where(status: status)
self
end
def open_only
@relation = @relation.where(status: :open)
self
end
def with_budget
@relation = @relation.where.not(budget: nil)
self
end
def budget_range(min, max)
return self if min.blank? || max.blank?
@relation = @relation.where(
"CAST(REPLACE(REPLACE(budget, '만원', ''), ',', '') AS INTEGER) BETWEEN ? AND ?",
min,
max
)
self
end
def recent(days = 7)
@relation = @relation.where("created_at > ?", days.days.ago)
self
end
def popular(min_views = 100)
@relation = @relation.where("views_count >= ?", min_views)
self
end
def call
@relation.order(created_at: :desc)
end
end
# app/queries/user_analytics_query.rb
class UserAnalyticsQuery < ApplicationQuery
def initialize(relation = User.all)
super(relation)
end
def active_users(days = 30)
@relation = @relation.where("last_sign_in_at > ?", days.days.ago)
self
end
def with_posts
@relation = @relation.where("posts_count > 0")
self
end
def top_contributors(limit = 10)
@relation = @relation.order(posts_count: :desc).limit(limit)
self
end
def by_role(role_title)
return self if role_title.blank?
@relation = @relation.where(role_title: role_title)
self
end
def joined_between(start_date, end_date)
@relation = @relation.where(created_at: start_date..end_date)
self
end
def call
@relation
end
end
# app/queries/flexible_query.rb
class FlexibleQuery < ApplicationQuery
def initialize(relation = Model.all, filters = {})
super(relation)
@filters = filters
end
def call
@relation = apply_filters(@relation, @filters)
@relation
end
private
def apply_filters(relation, filters)
filters.each do |key, value|
next if value.blank?
relation = case key.to_sym
when :status
relation.where(status: value)
when :user_id
relation.where(user_id: value)
when :search
relation.where("title LIKE ?", "%#{value}%")
when :min_date
relation.where("created_at >= ?", value)
when :max_date
relation.where("created_at <= ?", value)
else
relation
end
end
relation
end
end
Usage:
filters = {
status: params[:status],
user_id: params[:user_id],
search: params[:q],
min_date: params[:from]
}
@results = FlexibleQuery.new(Post.all, filters).call
class OptimizedPostsQuery < ApplicationQuery
def initialize
super(Post.includes(:user, :comments, :likes))
end
def call
@relation.order(created_at: :desc)
end
end
class LightweightPostsQuery < ApplicationQuery
def call
@relation.select(:id, :title, :created_at, :user_id, :likes_count)
end
end
class PopularPostsQuery < ApplicationQuery
def call
# Uses counter_cache, no COUNT query
@relation.where("likes_count > 10")
.order(likes_count: :desc)
end
end
class BatchQuery < ApplicationQuery
def find_each_batch(batch_size = 1000)
@relation.find_each(batch_size: batch_size) do |record|
yield record
end
end
end
# test/queries/posts_query_test.rb
require "test_helper"
class PostsQueryTest < ActiveSupport::TestCase
test "filters by search term" do
post1 = create(:post, title: "Ruby on Rails")
post2 = create(:post, title: "Python Django")
post3 = create(:post, title: "Rails Tutorial")
results = PostsQuery.new.search("Rails").call
assert_includes results, post1
assert_includes results, post3
assert_not_includes results, post2
end
test "filters by status" do
draft = create(:post, status: :draft)
published = create(:post, status: :published)
results = PostsQuery.new.by_status(:published).call
assert_includes results, published
assert_not_includes results, draft
end
test "chains multiple filters" do
user = create(:user)
post1 = create(:post, user: user, status: :published, likes_count: 20)
post2 = create(:post, user: user, status: :draft)
post3 = create(:post, status: :published, likes_count: 5)
results = PostsQuery.new
.by_user(user.id)
.published_only
.popular(10)
.call
assert_includes results, post1
assert_not_includes results, post2
assert_not_includes results, post3
end
test "returns correct SQL query" do
query = PostsQuery.new.search("test").by_status(:published)
sql = query.call.to_sql
assert_match /title LIKE/, sql
assert_match /status/, sql
end
end
self for method chainingclass PostsController < ApplicationController
def index
@posts = PostsQuery.new
.search(params[:q])
.by_status(params[:status])
.recent
.call
.page(params[:page])
.per(20)
end
end
class AnalyticsJob < ApplicationJob
def perform
users = UserAnalyticsQuery.new
.active_users(30)
.with_posts
.call
users.find_each do |user|
calculate_stats(user)
end
end
end
module Api
module V1
class PostsController < BaseController
def index
@posts = PostsQuery.new
.by_status(params[:status])
.recent
.call
.limit(50)
render json: { data: @posts.map { |p| post_json(p) } }
end
end
end
end