Como ActiveRecord monta SQL por baixo dos panos
Você não está montando SQL. Está montando uma árvore. Entenda Arel, bind parameters, lazy evaluation e a diferença real entre includes, preload e eager_load.
Como ActiveRecord monta SQL por baixo dos panos
A maioria dos devs Rails escreve:
User.where(active: true).order(:name).limit(10)
E pensa: "Beleza, isso vai virar SQL."
Vai.
Mas o caminho até virar SQL é uma das engenharias mais bonitas do Rails — e quase ninguém entende.
Você não está montando SQL. Você está montando uma árvore.
Cada método de query do ActiveRecord (where, order, joins, limit, select) não executa SQL.
Eles constroem um objeto chamado ActiveRecord::Relation.
rel = User.where(active: true)
rel.class # => User::ActiveRecord_Relation
Esse objeto carrega dentro dele a intenção da query.
Você pode encadear mais:
rel.order(:name).limit(10).where(role: "admin")
Continua sendo um Relation. Nada bateu no banco ainda.
Quando o SQL realmente sai
A query é lazy.
Ela só vira SQL quando você força a materialização:
- iterar (
.each,.map) - contar (
.count,.size) - pegar dados (
.to_a,.first,.find_each) - inspecionar valor (
.exists?,.pluck(:id))
Antes disso, está só descrita, não executada.
Isso é poderoso porque permite composição:
def active_users
User.where(active: true)
end
def by_role(scope, role)
scope.where(role: role)
end
by_role(active_users, "admin").limit(5).to_a
Um único SQL no final. Não três.
Por baixo: Arel
O ActiveRecord não monta SQL via concatenação de string (felizmente).
Ele usa Arel — uma biblioteca de manipulação de AST SQL.
users = Arel::Table.new(:users)
query = users
.project(users[:id], users[:name])
.where(users[:active].eq(true))
.order(users[:name])
query.to_sql
# SELECT users.id, users.name FROM users
# WHERE users.active = 't' ORDER BY users.name
Cada where, order, joins do AR é traduzido pra Arel.
Arel mantém a árvore de nodes.
Quando você pede SQL, ela percorre a árvore e gera string.
Por que isso importa
Porque agora você entende coisas tipo:
User.where("name = ?", params[:name]) # ruim
User.where(name: params[:name]) # bom
A primeira interpola string direto.
A segunda passa pelo Arel, que sabe sanitizar valor por valor.
SQL injection vive na primeira. Não na segunda.
E ainda:
User.where("name = '#{params[:name]}'") # criminoso
Não faça. Nunca.
Joins, includes, preload, eager_load
Quatro métodos diferentes pra coisa que parece igual.
User.joins(:posts) # INNER JOIN. Não carrega posts.
User.includes(:posts) # Decide entre preload e eager_load
User.preload(:posts) # 2 queries separadas (IN clause)
User.eager_load(:posts) # LEFT OUTER JOIN, 1 query
A diferença está em como o SQL é gerado.
preload evita N+1 com query secundária:
SELECT * FROM users WHERE active = true;
SELECT * FROM posts WHERE user_id IN (1, 2, 3, ...);
eager_load evita N+1 com join único:
SELECT users.*, posts.*
FROM users LEFT OUTER JOIN posts ON posts.user_id = users.id
WHERE users.active = true;
includes é o "Rails escolhe pra você". Geralmente preload, mas vira eager_load se você adicionar where em coluna do join.
Cada estratégia tem custo diferente em banco grande.
N+1: por que acontece
User.all.each do |user|
puts user.posts.count
end
ActiveRecord gera:
SELECT * FROM users;
SELECT count(*) FROM posts WHERE user_id = 1;
SELECT count(*) FROM posts WHERE user_id = 2;
SELECT count(*) FROM posts WHERE user_id = 3;
...
Um SELECT pra cada user.
100 users = 101 queries.
Resolva com includes:
User.includes(:posts).each do |user|
puts user.posts.size # .size em vez de .count
end
.count força query. .size usa a collection já carregada.
Mais um detalhe que só faz sentido quando você entende o que está acontecendo abaixo.
Bind parameters
Quando você passa valores via hash, AR não interpola.
Ele manda bind parameter pro driver do banco:
SELECT * FROM users WHERE id = $1 LIMIT 1
-- binds: [["id", 42]]
Isso:
- evita SQL injection
- permite o Postgres reutilizar plano de execução (prepared statement cache)
Cada chamada User.find(id) aproveita o mesmo plano cacheado.
Performance que você ganha sem fazer nada — desde que use a API certa.
O abismo entre ActiveRecord e SQL
ActiveRecord é poderoso.
Mas é uma abstração.
E como toda abstração, vaza em algum momento:
- query complexa que vira SQL ineficiente
joinsque duplica linha quando você esperava únicadistinctnecessário onde você não esperavapluckvsselectperformance diferente
Quem só sabe AR sofre.
Quem sabe SQL + AR sabe quando descer pra find_by_sql ou Arel direto.
Olhar o SQL gerado
Sempre que dúvida:
User.where(active: true).joins(:posts).to_sql
Ou no console:
ActiveRecord::Base.logger = Logger.new(STDOUT)
Ou no Rails normal: o SQL aparece no log de desenvolvimento.
A regra é simples: se você não entende o SQL, não entende a query.
A grande virada de chave
ActiveRecord não é mágico.
É um construtor de árvore que aceita métodos encadeáveis e, na hora certa, traduz tudo pra SQL via Arel.
Quando você entende:
- onde a query é executada
- como compor sem materializar cedo
- quais métodos batem no banco
- como o SQL final realmente fica
…você para de escrever Active Record por superstição.
E começa a escrever por intenção.
Conclusão
Toda lentidão de aplicação Rails que eu já vi começou em algum lugar do ActiveRecord.
Não porque AR é ruim.
Porque ninguém olhou o SQL.
Ler SQL é skill básica de Rails dev senior.
E ler ActiveRecord é entender que ele é só uma camada bonita por cima.
Não substitui o banco.
Nunca substituiu.
