mcpField
A
active-record-querying

active-record-querying

github.com/thinkoodle开发7178 次安装8 周前更新
代码仓库

关于 active-record-querying

面向 Rails 开发者的 Active Record 查询技能,提供编写高效、可组合查询的实践指导。内容涵盖预加载、SQL 过滤与聚合、批量处理和安全参数化查询。

主要功能

  1. 01选择记录查找与过滤方法
  2. 02使用预加载避免 N+1 查询
  3. 03通过 pluck 和 pick 获取原始值
  4. 04分批处理大型数据集
  5. 05安全编写原始 SQL

适用场景

  1. 01优化关联数据展示的查询
  2. 02批量处理大量记录
  3. 03导出所需字段而不实例化完整模型
  4. 04构建筛选、排序和聚合查询

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/thinkoodle/rails-skills --skill active-record-querying

安装后在 Claude Code 中输入/active-record-querying确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
batching.md
calculations.md
finders.md
joins-and-includes.md
performance.md
raw-sql.md
scopes.md

详细介绍

Active Record 查询专家

编写高效、正确且易于维护的 Active Record 查询。避免 N+1 查询、不必要的内存分配和反模式查询。

理念

  1. 让数据库完成工作 — 在 SQL 中筛选、排序、计数和聚合,而不是在 Ruby 中处理
  2. 只加载所需内容 — 只需要一列时,不要使用 SELECT *
  3. 默认预防 N+1 查询 — 迭代前务必考虑关联
  4. 优先使用作用域,而非临时查询 — 作用域有名称、可组合、可测试
  5. 明确失败 — 缺少记录属于错误时,使用带 bang 的方法

决策树

查找单条记录
Need a record by primary key?
  → find(id)                    # Raises if not found — this is usually what you want
  → find_by(id: id)             # Returns nil if not found

Need a record by attributes?
  → find_by(email: "[email protected]")  # Returns nil — good for "maybe exists" cases
  → find_by!(email: "[email protected]") # Raises — good for "must exist" cases

Avoid:
  → where(email: "x").first    # Unnecessary — find_by does this in one step
  → where(email: "x").take     # Same thing, less clear intent
加载关联(预防 N+1 查询)
Do you need associated data while iterating?
  YES → Use eager loading (see below)
  NO  → Don't eager load — it wastes memory

Which eager loading method?
  includes   → DEFAULT CHOICE. Rails picks best strategy (2 queries or JOIN)
  preload    → FORCE separate queries. Use when includes picks wrong strategy
  eager_load → FORCE single LEFT OUTER JOIN. Use when you need to filter on association

includes/preload/eager_load 的选择:

方法策略适用场景
includes自动(通常执行 2 次查询)默认选择。 能正确处理大多数情况
preload始终使用独立查询includes 错误地使用 JOIN,或你希望查询次数可预测时
eager_load始终使用 LEFT OUTER JOIN需要对关联应用 where 条件时
joinsINNER JOIN(不加载)需要筛选,但不需要关联对象时
# GOOD — includes handles this with 2 queries
posts = Post.includes(:comments).where(published: true)

# GOOD — eager_load when filtering on association
posts = Post.eager_load(:comments).where(comments: { approved: true })

# GOOD — joins when you filter but don't use the association data
posts = Post.joins(:comments).where(comments: { approved: true }).distinct

# BAD — this loads ALL comments into memory just to check existence
posts = Post.all.select { |p| p.comments.any? }

# GOOD — let the DB check existence
posts = Post.where.associated(:comments)
检查是否存在
# GOOD — single SELECT 1 ... LIMIT 1 query
User.exists?(email: "[email protected]")
User.where(active: true).exists?

# OK — but slightly slower (loads the relation first if not already loaded)
User.where(active: true).any?

# BAD — loads ALL records into Ruby, then checks (wastes memory + time)
User.where(active: true).present?
User.where(active: true).to_a.any?

# BAD — count scans the full result set; exists? stops at the first match
User.where(active: true).count > 0  # Use exists? instead

规则: exists? > any? > present? > count > 0

获取数据
Need Ruby objects with methods/callbacks?
  → Use where/find/select (returns AR objects)

Need raw values for display, export, or IDs?
  → Use pluck (returns arrays, skips AR instantiation)

Need a single value?
  → Use pick (like pluck but returns one value)

Need to count/sum/average?
  → Use count/sum/average/minimum/maximum (SQL aggregates)
# GOOD — pluck for raw values (fast, low memory)
user_ids = User.where(active: true).pluck(:id)
emails = User.where(role: :admin).pluck(:email)
pairs = User.pluck(:id, :email)  # => [[1, "[email protected]"], [2, "[email protected]"]]

# GOOD — pick for a single value
User.where(id: 1).pick(:email)  # => "[email protected]"

# BAD — instantiates full AR objects just to extract one field
User.where(active: true).map(&:id)        # Use pluck(:id)
User.where(active: true).select(:id).map(&:id)  # Still slower than pluck

# GOOD — ids shorthand
User.where(active: true).ids  # Same as pluck(:id) but reads better

# GOOD — SQL aggregates
Order.where(status: :complete).sum(:total)
Order.average(:total)
Product.maximum(:price)

作用域

何时使用作用域,何时使用类方法

适合使用作用域的场景:

  • 简单、可组合的查询片段
  • 经常进行链式调用的条件
  • 应始终返回 Relation(绝不为 nil)的查询

适合使用类方法的场景:

  • 带有条件逻辑的复杂查询
  • 可能返回 nil 的查询(类方法返回 nil 表示不应用作用域;作用域返回 nil 则表示 .all)
  • 需要执行多条语句的查询
class Post < ApplicationRecord
  # GOOD — clean, composable scopes
  scope :published, -> { where(published: true) }
  scope :recent, -> { order(created_at: :desc) }
  scope :by_author, ->(author) { where(author: author) }
  scope :popular, -> { where("view_count > ?", 100) }

  # GOOD — class method for conditional logic
  def self.search(query)
    return all if query.blank?  # Return all, not nil!
    where("title ILIKE ?", "%#{sanitize_sql_like(query)}%")
  end

  # BAD — scope with conditional that returns nil
  # If time is nil, this returns nil → breaks chaining
  scope :created_before, ->(time) { where(created_at: ...time) if time.present? }
  # Actually this is OK in scopes (Rails auto-converts nil → all)
  # But it's confusing. Prefer explicit:
  scope :created_before, ->(time) { time.present? ? where(created_at: ...time) : all }
end

# Compose freely
Post.published.recent.by_author(user).limit(10)
作用域反模式
# BAD — default_scope is almost always a mistake
class Post < ApplicationRecord
  default_scope { where(deleted: false) }  # Infects EVERY query, including joins
end

# BAD — overly broad scope name
scope :active, -> { where(active: true).where(verified: true).where("last_login > ?", 30.days.ago) }
# Better: break into composable pieces
scope :active, -> { where(active: true) }
scope :verified, -> { where(verified: true) }
scope :recently_active, -> { where("last_login > ?", 30.days.ago) }

批量处理大型数据集

分批迭代大型数据表 — 对没有限制的 Relation 使用 .each,会一次性将整个结果集加载到内存中:

# BAD — loads entire table into memory
User.all.each { |u| u.send_newsletter }

# GOOD — processes in batches of 1000 (default)
User.find_each { |u| u.send_newsletter }

# GOOD — custom batch size
User.find_each(batch_size: 500) { |u| u.send_newsletter }

# GOOD — when you need the batch as an array
User.find_in_batches(batch_size: 1000) do |batch|
  SomeService.bulk_process(batch)
end

# GOOD — when you need an ActiveRecord::Relation per batch (Rails 5+)
User.in_batches(of: 1000) do |batch_relation|
  batch_relation.update_all(processed: true)  # Single UPDATE query per batch
end

批处理方法:

  • find_each — 逐条返回记录。最常用。
  • find_in_batches — 返回记录数组。适用于对对象进行批量操作。
  • in_batches — 返回 Relations。适用于批量 SQL 操作(update_all、delete_all)。
  • 这三种方法内部都按主键排序。它们不能与自定义 .order() 组合使用,因为批处理依赖主键排序来进行分页。

联接

了解 joins 与 includes 的区别
# joins = INNER JOIN, for FILTERING. Does NOT load association.
Post.joins(:comments).where(comments: { approved: true }).distinct
# Use: "Give me posts that HAVE approved comments"
# The comments are NOT loaded — accessing post.comments triggers another query!

# includes = eager loading, for USING associations. Prevents N+1.
Post.includes(:comments).where(published: true)
# Use: "Give me posts AND their comments, because I'll display them"

# Common mistake: using joins thinking it loads the association
posts = Post.joins(:author).limit(10)
posts.each { |p| p.author.name }  # N+1! joins doesn't eager load!

# Fix:
posts = Post.includes(:author).limit(10)
posts.each { |p| p.author.name }  # No N+1
left_outer_joins
# Include ALL records, even without the association
Customer.left_outer_joins(:orders)
  .select("customers.*, COUNT(orders.id) AS orders_count")
  .group("customers.id")

# Cleaner: find records WITH or WITHOUT associations
Customer.where.associated(:orders)    # Has orders (INNER JOIN + NOT NULL)
Customer.where.missing(:orders)       # No orders (LEFT JOIN + IS NULL)

Where 条件

Hash 条件(首选)
# Equality
User.where(active: true)

# IN
User.where(role: [:admin, :moderator])

# Range (BETWEEN)
Order.where(created_at: 1.week.ago..Time.current)

# Greater/less than (endless/beginless ranges)
Order.where(total: 100..)    # total >= 100
Order.where(total: ..100)    # total <= 100

# Nil
User.where(deleted_at: nil)  # IS NULL

# NOT
User.where.not(role: :banned)
User.where.not(deleted_at: nil)  # IS NOT NULL

# OR
User.where(role: :admin).or(User.where(role: :moderator))

# Association conditions
Post.where(author: { active: true })  # Only with joins/includes
String 条件(Hash 无法满足需求时)
# ALWAYS use parameterized queries — never interpolate user input
User.where("email LIKE ?", "%#{User.sanitize_sql_like(query)}%")
User.where("created_at > :date", date: 1.week.ago)

# NEVER do this — SQL injection lets attackers read/modify your entire database
User.where("email = '#{params[:email]}'")  # VULNERABLE!

原始 SQL

适用场景
  • AR 无法清晰表达的复杂查询
  • 需要特定 SQL 的性能关键型查询
  • 包含复杂聚合的报告/分析查询
安全使用方式
# GOOD — parameterized
User.where("age > ? AND city = ?", 18, "NYC")

# GOOD — named parameters
User.where("age > :min_age AND city = :city", min_age: 18, city: "NYC")

# GOOD — sanitize for LIKE
User.where("name LIKE ?", "#{User.sanitize_sql_like(query)}%")

# GOOD — find_by_sql for fully custom queries
results = User.find_by_sql([
  "SELECT users.*, COUNT(orders.id) as order_count
   FROM users LEFT JOIN orders ON orders.user_id = users.id
   WHERE users.active = ?
   GROUP BY users.id
   HAVING COUNT(orders.id) > ?",
  true, 5
])

# GOOD — select_all for raw hashes (no AR objects)
result = ActiveRecord::Base.lease_connection.select_all(
  "SELECT DATE(created_at) as day, COUNT(*) as total FROM orders GROUP BY day"
)
result.to_a  # => [{"day" => "2025-01-01", "total" => 42}, ...]

# GOOD — Arel for complex programmatic query building
users = User.arel_table
User.where(users[:age].gt(18).and(users[:city].eq("NYC")))

枚举

class Order < ApplicationRecord
  enum :status, { pending: 0, processing: 1, shipped: 2, delivered: 3, cancelled: 4 }
end

# Auto-generated scopes
Order.pending          # WHERE status = 0
Order.not_pending      # WHERE status != 0
Order.shipped          # WHERE status = 2

# Check and set
order.pending?         # true/false
order.shipped!         # UPDATE ... SET status = 2

# Query with symbol (preferred)
Order.where(status: :shipped)
Order.where(status: [:shipped, :delivered])

# Avoid raw integers — if enum values shift, queries silently break
Order.where(status: 2)  # Use the symbol instead

常见反模式

1. 将所有内容加载到内存中
# BAD
User.all.select { |u| u.active? }     # Loads ALL users, filters in Ruby
User.all.count                          # .all is redundant, and forces load if cached

# GOOD
User.where(active: true)               # SQL filter
User.count                              # SQL COUNT
2. N+1 查询
# BAD — 1 query for posts + N queries for authors
Post.limit(10).each { |p| puts p.author.name }

# GOOD
Post.includes(:author).limit(10).each { |p| puts p.author.name }
3. 不必要的预加载
# BAD — loads ALL associations when you only need the post
Post.includes(:comments, :tags, :author).find(1)
# (If you only display the post title, this wastes memory)

# GOOD — only eager load what you'll use
Post.find(1)  # If you just need the post
Post.includes(:author).find(1)  # If you'll display author too
4. 能用 pluck 时却使用 map
# BAD
User.where(active: true).map(&:email)   # Instantiates AR objects
User.select(:email).map(&:email)         # Still instantiates objects

# GOOD
User.where(active: true).pluck(:email)   # Returns plain strings
5. count、size 与 length
relation = User.where(active: true)

relation.count   # ALWAYS hits DB with COUNT query
relation.length  # Loads ALL records, counts in Ruby (bad if not already loaded)
relation.size    # Smart: uses count if not loaded, length if already loaded

# Rule: use .size on relations, .count when you need a fresh DB count
6. 使用 joins 时忘记 distinct
# BAD — returns duplicate posts (one per comment)
Post.joins(:comments).where(comments: { approved: true })

# GOOD
Post.joins(:comments).where(comments: { approved: true }).distinct

查询调试

# See the SQL a relation will generate
puts User.where(active: true).to_sql

# See the explain plan
puts User.where(active: true).explain

# In console — enable query logging
ActiveRecord::Base.logger = Logger.new(STDOUT)

# Count queries in a block (tests)
assert_queries_count(2) { User.includes(:posts).first.posts.to_a }
assert_no_queries { cached_result }

快速参考:方法速查表

想要……使用返回值
按主键查找(必须存在)find(id)记录或引发异常
按主键查找(可能不存在)find_by(id: id)记录或 nil
按属性查找find_by(email: "x")记录或 nil
按属性查找(必须存在)find_by!(email: "x")记录或引发异常
筛选记录where(active: true)关联
排除记录where.not(role: :banned)关联
存在关联where.associated(:orders)关联
缺少关联where.missing(:orders)关联
排序order(created_at: :desc)关联
限制数量limit(10)关联
偏移offset(20)关联
去重distinct关联
原始值pluck(:email)数组
单个原始值pick(:email)值
所有 IDids数组
计数count整数
是否存在?exists?(email: "x")布尔值
分组group(:status).count哈希
求和/平均值/最小值/最大值sum(:total)数值
预加载(自动)includes(:author)关联
预加载(单独查询)preload(:author)关联
预加载(单个 JOIN)eager_load(:author)关联
INNER JOIN(仅筛选)joins(:author)关联
LEFT JOINleft_outer_joins(:orders)关联
批量迭代find_each逐条生成记录
批量数组find_in_batches逐批生成数组
批量关联in_batches逐批生成关联
查找或创建find_or_create_by(name: "x")记录
查找或初始化find_or_initialize_by(name: "x")记录(可能尚未保存)

如需了解详细用法、进阶示例和边缘情况,请参阅 references/ 目录:

  • references/finders.md — 查找方法、where 条件、排序、select/pluck、枚举
  • references/joins-and-includes.md — Join、预加载(includes/preload/eager_load)、strict_loading
  • references/scopes.md — Scope 用法、覆盖条件、方法链、查询对象
  • references/batching.md — find_each、find_in_batches、in_batches
  • references/calculations.md — 分组、聚合、存在性检查、锁定
  • references/raw-sql.md — 安全使用原始 SQL 的模式(find_by_sql、select_all、清理)
  • references/performance.md — 性能模式、Rails 8.1 功能、完整方法索引

评论

支持 Markdown