MongoDB e Mongoose: banco de dados com Node

[113] MongoDB e Mongoose: banco de dados com Node

Um array na memória some quando o servidor reinicia, e é aí que entra o banco. O artigo conecta o Node ao MongoDB pelo Mongoose e percorre o caminho inteiro: schema com tipos e validação, métodos e hooks, o CRUD, os operadores de consulta, o aggregation pipeline e como traduzir os erros do Mongoose em status HTTP.
Javascript

25 min de leitura

Até agora nossa API guardava dados em um array na memória — tudo sumia quando o servidor reiniciava. Uma aplicação real precisa de um banco de dados persistente.

O MongoDB é um banco de dados NoSQL orientado a documentos — em vez de tabelas e linhas como no SQL, ele armazena coleções de documentos JSON. É a escolha mais comum no ecossistema Node.js pela naturalidade com JavaScript.

O Mongoose é a biblioteca que conecta o Node ao MongoDB, adicionando schemas, validações, e uma API elegante sobre o driver nativo.

SQL vs NoSQL — quando usar cada um

┌─────────────────────────────────────────────────────────┐
│              SQL (PostgreSQL, MySQL)                    │
│                                                         │
│  Dados: Tabelas com colunas fixas                       │
│  Relações: JOINs entre tabelas                          │
│  Schema: Rígido — definido antes dos dados              │
│  Consultas: SQL padronizado                             │
│  Ideal para: finanças, ERP, dados altamente relacionais │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│              NoSQL — MongoDB                            │
│                                                         │
│  Dados: Coleções de documentos JSON                     │
│  Relações: Referências ou embedding                     │
│  Schema: Flexível — documentos podem variar             │
│  Consultas: API própria (find, aggregate...)            │
│  Ideal para: catálogos, redes sociais, IoT, conteúdo    │
└─────────────────────────────────────────────────────────┘

Instalando e conectando

# Instalar Mongoose
npm install mongoose

# Para desenvolvimento local, você pode:
# 1. Instalar MongoDB localmente: https://www.mongodb.com/try/download/community
# 2. Usar MongoDB Atlas (gratuito): https://www.mongodb.com/atlas
# 3. Usar Docker: docker run -d -p 27017:27017 mongo
// src/config/database.js
const mongoose = require("mongoose");

async function conectar() {
  const url = process.env.MONGODB_URL || "mongodb://localhost:27017/meuapp";

  try {
    await mongoose.connect(url, {
      // Opções recomendadas
      serverSelectionTimeoutMS: 5000, // timeout de 5s para encontrar servidor
      socketTimeoutMS: 45000,         // timeout de operações
    });

    console.log(`✅ MongoDB conectado: ${mongoose.connection.host}`);

    // Eventos de conexão
    mongoose.connection.on("disconnected", () => {
      console.warn("⚠️ MongoDB desconectado.");
    });

    mongoose.connection.on("error", (erro) => {
      console.error("❌ Erro no MongoDB:", erro.message);
    });

  } catch (erro) {
    console.error("❌ Falha ao conectar ao MongoDB:", erro.message);
    process.exit(1); // Encerra se não conseguir conectar
  }
}

async function desconectar() {
  await mongoose.connection.close();
  console.log("MongoDB desconectado.");
}

module.exports = { conectar, desconectar };
// src/index.js
require("dotenv").config();
const express = require("express");
const { conectar } = require("./config/database");

const app = express();
app.use(express.json());

// Conecta ao banco ANTES de iniciar o servidor
conectar().then(() => {
  app.listen(3000, () => {
    console.log("🚀 Servidor rodando em http://localhost:3000");
  });
});

Schemas e Models — definindo a estrutura dos dados

No Mongoose, um Schema define a estrutura de um documento. Um Model é a classe que usamos para interagir com a coleção:

// src/models/Usuario.js
const mongoose = require("mongoose");
const { Schema } = mongoose;

const usuarioSchema = new Schema(
  {
    // Tipos básicos
    nome: {
      type: String,
      required: [true, "Nome é obrigatório."],
      trim: true,
      minlength: [2, "Nome deve ter pelo menos 2 caracteres."],
      maxlength: [100, "Nome não pode exceder 100 caracteres."],
    },

    email: {
      type: String,
      required: [true, "Email é obrigatório."],
      unique: true,               // cria índice único
      lowercase: true,            // converte para minúsculo automaticamente
      trim: true,
      match: [/^\S+@\S+\.\S+$/, "Email inválido."],
    },

    senha: {
      type: String,
      required: true,
      minlength: 6,
      select: false,              // nunca retorna a senha em queries
    },

    idade: {
      type: Number,
      min: [0, "Idade não pode ser negativa."],
      max: [150, "Idade inválida."],
    },

    papel: {
      type: String,
      enum: {
        values: ["usuario", "admin", "moderador"],
        message: "Papel inválido.",
      },
      default: "usuario",
    },

    ativo: {
      type: Boolean,
      default: true,
    },

    avatar: {
      type: String,
      default: null,
    },

    // Array de strings
    habilidades: [String],

    // Objeto embutido (embedded)
    endereco: {
      rua: String,
      cidade: String,
      estado: { type: String, maxlength: 2 },
      cep: String,
    },

    // Referência a outro documento (relação)
    departamento: {
      type: Schema.Types.ObjectId,
      ref: "Departamento",       // nome do Model referenciado
      default: null,
    },

    ultimoAcesso: {
      type: Date,
      default: null,
    },
  },
  {
    // Adiciona createdAt e updatedAt automaticamente
    timestamps: true,

    // Remove __v do resultado por padrão
    versionKey: false,

    // Transforma o documento ao converter para JSON
    toJSON: {
      transform(doc, ret) {
        ret.id = ret._id;
        delete ret._id;
        delete ret.senha; // nunca expõe senha
        return ret;
      },
    },
  }
);

// ── Índices ────────────────────────────────────────
usuarioSchema.index({ nome: "text" }); // busca de texto
usuarioSchema.index({ createdAt: -1 }); // ordenação por data

// ── Métodos de instância ───────────────────────────
usuarioSchema.methods.verificarSenha = async function(senhaDigitada) {
  const bcrypt = require("bcrypt");
  return bcrypt.compare(senhaDigitada, this.senha);
};

usuarioSchema.methods.toPublico = function() {
  return {
    id: this._id,
    nome: this.nome,
    email: this.email,
    papel: this.papel,
    createdAt: this.createdAt,
  };
};

// ── Métodos estáticos ──────────────────────────────
usuarioSchema.statics.buscarAtivos = function() {
  return this.find({ ativo: true });
};

usuarioSchema.statics.buscarPorEmail = function(email) {
  return this.findOne({ email: email.toLowerCase() }).select("+senha");
};

// ── Virtuals — campos calculados ───────────────────
usuarioSchema.virtual("nomeAbreviado").get(function() {
  const partes = this.nome.split(" ");
  if (partes.length < 2) return this.nome;
  return `${partes[0]} ${partes[partes.length - 1][0]}.`;
});

// ── Hooks (middleware do Mongoose) ─────────────────
// Executa ANTES de salvar — ideal para hash de senha
usuarioSchema.pre("save", async function(next) {
  // Só faz hash se a senha foi modificada
  if (!this.isModified("senha")) return next();

  const bcrypt = require("bcrypt");
  this.senha = await bcrypt.hash(this.senha, 12);
  next();
});

// Executa ANTES de qualquer find — filtro automático
usuarioSchema.pre(/^find/, function(next) {
  // Exclui usuários inativos de todas as buscas por padrão
  // this.find({ ativo: { $ne: false } });
  next();
});

const Usuario = mongoose.model("Usuario", usuarioSchema);
module.exports = Usuario;

CRUD completo com Mongoose

// src/services/usuarioService.js
const Usuario = require("../models/Usuario");

// ── CREATE ──────────────────────────────────────────

async function criar(dados) {
  const usuario = new Usuario(dados);
  return await usuario.save();
  // ou: return await Usuario.create(dados);
}

// Criar múltiplos de uma vez
async function criarVarios(lista) {
  return await Usuario.insertMany(lista, { ordered: false });
  // ordered: false → continua mesmo se algum falhar
}

// ── READ ────────────────────────────────────────────

// Buscar todos
async function listar(filtros = {}, opcoes = {}) {
  const {
    pagina = 1,
    por_pagina = 10,
    ordenar = "-createdAt",    // - = decrescente
    campos = null,             // projeção de campos
  } = opcoes;

  const query = Usuario.find(filtros);

  if (campos) query.select(campos);
  query.sort(ordenar);
  query.skip((pagina - 1) * por_pagina).limit(Number(por_pagina));

  const [dados, total] = await Promise.all([
    query.exec(),
    Usuario.countDocuments(filtros),
  ]);

  return {
    dados,
    paginacao: {
      total,
      pagina: Number(pagina),
      por_pagina: Number(por_pagina),
      total_paginas: Math.ceil(total / por_pagina),
    },
  };
}

// Buscar por ID
async function buscarPorId(id) {
  const usuario = await Usuario.findById(id);
  if (!usuario) throw new Error(`Usuário ${id} não encontrado.`);
  return usuario;
}

// Buscar com filtros
async function buscarUm(filtros) {
  return await Usuario.findOne(filtros);
}

// Buscar com populate (relações)
async function buscarComDepartamento(id) {
  return await Usuario
    .findById(id)
    .populate("departamento", "nome descricao"); // apenas nome e descricao
}

// Busca textual
async function buscarPorTexto(termo) {
  return await Usuario.find(
    { $text: { $search: termo } },
    { score: { $meta: "textScore" } }
  ).sort({ score: { $meta: "textScore" } });
}

// ── UPDATE ──────────────────────────────────────────

// Atualizar por ID (com new: true, devolve o documento DEPOIS da atualização;
// sem ele, o padrão do Mongoose é devolver o estado anterior)
async function atualizar(id, dados) {
  const usuario = await Usuario.findByIdAndUpdate(
    id,
    { $set: dados },
    {
      new: true,           // retorna documento APÓS a atualização
      runValidators: true, // executa as validações do schema
    }
  );

  if (!usuario) throw new Error(`Usuário ${id} não encontrado.`);
  return usuario;
}

// Atualizar instância (mais controle)
async function atualizarComHooks(id, dados) {
  const usuario = await Usuario.findById(id);
  if (!usuario) throw new Error(`Usuário ${id} não encontrado.`);

  Object.assign(usuario, dados);
  return await usuario.save(); // dispara hooks pre/post save
}

// Atualizar muitos
async function ativarTodos() {
  const resultado = await Usuario.updateMany(
    { ativo: false },
    { $set: { ativo: true } }
  );
  return resultado.modifiedCount; // quantos foram atualizados
}

// ── DELETE ──────────────────────────────────────────

// Soft delete (recomendado) — marca como inativo
async function desativar(id) {
  return await atualizar(id, { ativo: false });
}

// Hard delete — remove permanentemente
async function remover(id) {
  const usuario = await Usuario.findByIdAndDelete(id);
  if (!usuario) throw new Error(`Usuário ${id} não encontrado.`);
  return usuario;
}

// Remover muitos
async function removerInativos() {
  const resultado = await Usuario.deleteMany({ ativo: false });
  return resultado.deletedCount;
}

module.exports = {
  criar, criarVarios, listar, buscarPorId, buscarUm,
  buscarComDepartamento, buscarPorTexto,
  atualizar, atualizarComHooks, ativarTodos,
  desativar, remover, removerInativos,
};

Operadores de consulta do MongoDB

// Operadores de comparação
await Usuario.find({ idade: { $gte: 18, $lte: 65 } }); // entre 18 e 65
await Usuario.find({ papel: { $in: ["admin", "moderador"] } }); // um dos valores
await Usuario.find({ papel: { $nin: ["usuario"] } }); // não é usuario
await Usuario.find({ avatar: { $ne: null } }); // não é nulo
await Usuario.find({ avatar: { $exists: true } }); // campo existe

// Operadores lógicos
await Usuario.find({
  $and: [{ ativo: true }, { idade: { $gte: 18 } }]
});

await Usuario.find({
  $or: [{ papel: "admin" }, { idade: { $gte: 60 } }]
});

await Usuario.find({
  $nor: [{ ativo: false }, { papel: "usuario" }]
});

// Operadores de array
await Usuario.find({ habilidades: "JavaScript" });    // contém
await Usuario.find({ habilidades: { $in: ["JS", "Python"] } }); // qualquer um
await Usuario.find({ habilidades: { $all: ["JS", "Node"] } });  // todos
await Usuario.find({ habilidades: { $size: 3 } });    // exatamente 3 itens

// Regex
await Usuario.find({ nome: /^ana/i }); // começa com "ana" (case-insensitive)
await Usuario.find({ email: { $regex: "gmail\\.com$" } });
// Dentro de uma STRING a barra invertida precisa ser dobrada. Escrita com uma só,
// ela se perde e o padrão chega ao MongoDB como gmail.com$ — aí o ponto casa com
// qualquer caractere, e gmailXcom também passaria. Com barra literal o problema
// não existe: { email: /gmail\.com$/ }

// Projeção — escolher campos retornados
await Usuario.find({}).select("nome email -_id"); // inclui nome/email, exclui _id
await Usuario.find({}).select({ nome: 1, email: 1 });
// Não misture inclusão e exclusão na mesma projeção: { nome: 1, senha: 0 } faz
// o MongoDB recusar a consulta. A única exceção é o _id, que pode ser
// excluído ao lado de inclusões: { nome: 1, _id: 0 }.

Aggregation Pipeline — consultas avançadas

O Aggregation Pipeline é o recurso mais poderoso do MongoDB — permite transformar e analisar dados em etapas:

// Relatório por papel
async function relatorioPorPapel() {
  return await Usuario.aggregate([
    // Estágio 1: filtrar apenas ativos
    { $match: { ativo: true } },

    // Estágio 2: agrupar por papel
    {
      $group: {
        _id: "$papel",
        total: { $sum: 1 },
        idadeMedia: { $avg: "$idade" },
        idadeMaxima: { $max: "$idade" },
        idadeMinima: { $min: "$idade" },
        usuarios: { $push: "$nome" },
      },
    },

    // Estágio 3: renomear _id
    {
      $project: {
        papel: "$_id",
        total: 1,
        idadeMedia: { $round: ["$idadeMedia", 1] },
        idadeMaxima: 1,
        idadeMinima: 1,
        _id: 0,
      },
    },

    // Estágio 4: ordenar por total decrescente
    { $sort: { total: -1 } },
  ]);
}

// Busca com lookup (similar a JOIN)
async function usuariosComDepartamento() {
  return await Usuario.aggregate([
    { $match: { ativo: true } },
    {
      $lookup: {
        from: "departamentos",     // nome da coleção (não do Model)
        localField: "departamento",
        foreignField: "_id",
        as: "departamentoInfo",
      },
    },
    { $unwind: { path: "$departamentoInfo", preserveNullAndEmptyArrays: true } },
    {
      $project: {
        nome: 1,
        email: 1,
        "departamentoInfo.nome": 1,
      },
    },
  ]);
}

Conectando ao Router do Express

// src/routes/usuarios.js
const express = require("express");
const router = express.Router();
const usuarioService = require("../services/usuarioService");

// Wrapper para async/await com tratamento de erros
const asyncHandler = fn => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

// GET /usuarios
router.get("/", asyncHandler(async (req, res) => {
  const { busca, pagina, por_pagina, papel } = req.query;

  const filtros = {};
  if (papel) filtros.papel = papel;
  if (busca) filtros.$text = { $search: busca };

  const resultado = await usuarioService.listar(filtros, { pagina, por_pagina });
  res.json(resultado);
}));

// GET /usuarios/:id
router.get("/:id", asyncHandler(async (req, res) => {
  const usuario = await usuarioService.buscarPorId(req.params.id);
  res.json(usuario);
}));

// POST /usuarios
router.post("/", asyncHandler(async (req, res) => {
  const usuario = await usuarioService.criar(req.body);
  res.status(201).json(usuario);
}));

// PUT /usuarios/:id
router.put("/:id", asyncHandler(async (req, res) => {
  const usuario = await usuarioService.atualizar(req.params.id, req.body);
  res.json(usuario);
}));

// DELETE /usuarios/:id
router.delete("/:id", asyncHandler(async (req, res) => {
  await usuarioService.remover(req.params.id);
  res.json({ mensagem: "Usuário removido com sucesso." });
}));

// Tratamento de erros do Mongoose no error handler global
module.exports = router;
// src/middlewares/erros.js — com tratamento de erros do Mongoose
function tratadorDeErros(erro, req, res, next) {
  console.error("[Erro]", erro.message);

  // ID inválido (ObjectId malformado)
  if (erro.name === "CastError") {
    return res.status(400).json({ erro: `ID inválido: ${erro.value}` });
  }

  // Erro de validação do Mongoose
  if (erro.name === "ValidationError") {
    const erros = Object.values(erro.errors).map(e => e.message);
    return res.status(422).json({ erro: "Dados inválidos.", detalhes: erros });
  }

  // Duplicidade (chave única violada)
  if (erro.code === 11000) {
    const campo = Object.keys(erro.keyValue)[0];
    return res.status(409).json({
      erro: `${campo} já está em uso.`,
    });
  }

  // Erro não encontrado (lançado pelo service)
  if (erro.message?.includes("não encontrado")) {
    return res.status(404).json({ erro: erro.message });
  }

  // Erro genérico
  res.status(500).json({ erro: "Erro interno do servidor." });
}

module.exports = { tratadorDeErros };

Boas práticas com MongoDB e Mongoose

// ✅ 1. Sempre defina índices para campos consultados frequentemente
usuarioSchema.index({ email: 1 });      // consultas por email
usuarioSchema.index({ createdAt: -1 }); // ordenação por data
usuarioSchema.index({ nome: "text" });  // busca textual

// ✅ 2. Use select("+senha") apenas quando necessário
// select: false no schema protege o campo por padrão

// ✅ 3. Use lean() para consultas somente leitura — muito mais rápido
const usuarios = await Usuario.find({}).lean();
// Retorna objetos JS simples, não documentos Mongoose

// ✅ 4. Prefira soft delete a hard delete
usuarioSchema.add({ deletadoEm: { type: Date, default: null } });

// ✅ 5. Use transações para operações relacionadas
const session = await mongoose.startSession();
session.startTransaction();
try {
  await Usuario.create([dados], { session });
  await Pedido.create([pedido], { session });
  await session.commitTransaction();
} catch (erro) {
  await session.abortTransaction();
  throw erro;
} finally {
  session.endSession();
}

// ✅ 6. Valide ObjectIds antes de consultar
const { isValidObjectId } = mongoose;
if (!isValidObjectId(id)) {
  return res.status(400).json({ erro: "ID inválido." });
}

// ✅ 7. Nunca exponha __v e _id diretamente — use toJSON transform

Tarefa para você

Construa um sistema de blog com as seguintes coleções e relacionamentos:

// Coleção: Autor
// { nome, email, bio, avatar, createdAt }

// Coleção: Post
// {
//   titulo, conteudo, slug (único, gerado do título),
//   autor (ref → Autor), tags: [String],
//   publicado: Boolean, visualizacoes: Number,
//   createdAt, updatedAt
// }

// Coleção: Comentario
// { post (ref → Post), autor: String, conteudo, aprovado, createdAt }

// Implemente:
// 1. CRUD completo para Autores e Posts
// 2. GET /posts/:slug — busca por slug em vez de ID
// 3. GET /posts com filtro por tag e paginação
// 4. POST /posts/:id/comentarios — adiciona comentário
// 5. Aggregation: relatório com posts mais visualizados por autor
// 6. Hook pre-save para gerar o slug automaticamente do título
//    "Meu Post Incrível" → "meu-post-incrivel"
//    Use: slug = titulo.toLowerCase().replace(/\s+/g, "-").replace(/[^\w-]/g, "")
// 7. Virtual: tempoLeitura estimado (palavras / 200 por minuto)
Ver solução — os três schemas, o hook de slug, o virtual e a aggregation
// npm install mongoose express
//
// ---- src/modelos.js
import mongoose from "mongoose";

const { Schema, model } = mongoose;

// ---------------------------------------------------------------
// Autor
// ---------------------------------------------------------------
const esquemaAutor = new Schema(
  {
    nome: { type: String, required: true, trim: true },
    email: {
      type: String,
      required: true,
      unique: true,
      lowercase: true,
      trim: true,
      match: [/^\S+@\S+\.\S+$/, "e-mail inválido"],
    },
    bio: { type: String, maxlength: 500 },
    avatar: String,
  },
  { timestamps: true } // cria e mantém createdAt e updatedAt sozinho
);

// ---------------------------------------------------------------
// Post
// ---------------------------------------------------------------
const esquemaPost = new Schema(
  {
    titulo: { type: String, required: true, trim: true },
    conteudo: { type: String, required: true },
    slug: { type: String, unique: true, index: true },
    autor: { type: Schema.Types.ObjectId, ref: "Autor", required: true, index: true },
    tags: { type: [String], default: [], index: true },
    publicado: { type: Boolean, default: false, index: true },
    visualizacoes: { type: Number, default: 0, min: 0 },
  },
  {
    timestamps: true,
    // Sem isto os virtuals somem do res.json(): o Express serializa o
    // documento, e o toJSON padrão do Mongoose não os inclui.
    toJSON: { virtuals: true },
    toObject: { virtuals: true },
  }
);

// ---------------------------------------------------------------
// 6 — hook pre-save para o slug
// ---------------------------------------------------------------
function gerarSlug(texto) {
  return texto
    .toLowerCase()
    // O enunciado sugere só replace(/\s+/g,"-") — mas aí "Ação"
    // viraria "ação" e a URL sairia com acento percent-encoded.
    // normalize("NFD") separa a letra do acento, e o range remove só
    // o acento.
    .normalize("NFD")
    .replace(/[\u0300-\u036f]/g, "")
    .replace(/[^a-z0-9\s-]/g, "")
    .trim()
    .replace(/\s+/g, "-")
    .replace(/-+/g, "-");
}

esquemaPost.pre("save", async function (proximo) {
  // isModified evita regerar o slug a cada save: mudar a URL de um
  // post publicado quebra todos os links que apontam para ele.
  if (!this.isModified("titulo")) return proximo();

  const base = gerarSlug(this.titulo);
  let slug = base;
  let sufixo = 1;

  // Dois posts com o mesmo título são normais. Sem o desempate, o
  // segundo estouraria com erro de índice único (E11000).
  while (
    await this.constructor.exists({ slug, _id: { $ne: this._id } })
  ) {
    slug = `${base}-${++sufixo}`;
  }

  this.slug = slug;
  proximo();
});

// ---------------------------------------------------------------
// 7 — virtual de tempo de leitura
// ---------------------------------------------------------------
// Virtual é campo calculado: não ocupa espaço no banco e nunca fica
// desatualizado em relação ao conteúdo.
esquemaPost.virtual("tempoLeitura").get(function () {
  const palavras = (this.conteudo ?? "").trim().split(/\s+/).filter(Boolean).length;
  return Math.max(1, Math.ceil(palavras / 200)); // nada leva "0 min"
});

esquemaPost.virtual("comentarios", {
  ref: "Comentario",
  localField: "_id",
  foreignField: "post",
});

// ---------------------------------------------------------------
// Comentário
// ---------------------------------------------------------------
const esquemaComentario = new Schema(
  {
    post: { type: Schema.Types.ObjectId, ref: "Post", required: true, index: true },
    autor: { type: String, required: true, trim: true },
    conteudo: { type: String, required: true, maxlength: 2000 },
    aprovado: { type: Boolean, default: false }, // moderação por padrão
  },
  { timestamps: true }
);

export const Autor = model("Autor", esquemaAutor);
export const Post = model("Post", esquemaPost);
export const Comentario = model("Comentario", esquemaComentario);

// ---- src/rotas.js
import express from "express";

export const rotas = express.Router();

// ---------------------------------------------------------------
// 3 — listagem com filtro por tag e paginação
// ---------------------------------------------------------------
rotas.get("/posts", async (req, res, next) => {
  try {
    const pagina = Math.max(1, Number(req.query.pagina) || 1);
    const porPagina = Math.min(50, Math.max(1, Number(req.query.por_pagina) || 10));

    const filtro = { publicado: true };
    if (req.query.tag) filtro.tags = req.query.tag;

    // Contagem e busca em paralelo: em série, a resposta demora a
    // soma das duas.
    const [total, posts] = await Promise.all([
      Post.countDocuments(filtro),
      Post.find(filtro)
        .populate("autor", "nome avatar") // só os campos usados na listagem
        .select("-conteudo")              // o corpo do post não vai na lista
        .sort({ createdAt: -1 })
        .skip((pagina - 1) * porPagina)
        .limit(porPagina)
        .lean(),                          // objeto puro, bem mais leve
      // Atenção: lean() descarta os virtuals — o tempoLeitura não vem junto.
      // Para tê-los numa consulta lean existe o plugin mongoose-lean-virtuals;
      // sem ele, calcule no cliente ou abra mão do lean nesta rota.
    ]);

    res.json({
      dados: posts,
      total,
      pagina,
      por_pagina: porPagina,
      total_paginas: Math.ceil(total / porPagina) || 1,
    });
  } catch (erro) {
    next(erro);
  }
});

// ---------------------------------------------------------------
// 2 — busca por slug
// ---------------------------------------------------------------
rotas.get("/posts/:slug", async (req, res, next) => {
  try {
    // findOneAndUpdate com $inc numa operação só: ler, somar e salvar
    // em três passos perde contagem quando há acesso simultâneo.
    const post = await Post.findOneAndUpdate(
      { slug: req.params.slug, publicado: true },
      { $inc: { visualizacoes: 1 } },
      { new: true }
    )
      .populate("autor", "nome bio avatar")
      .populate({
        match: { aprovado: true },
        path: "comentarios",
        options: { sort: { createdAt: -1 } },
      });

    if (!post) return res.status(404).json({ erro: "Post não encontrado" });

    res.json(post);
  } catch (erro) {
    next(erro);
  }
});

// ---------------------------------------------------------------
// 1 — CRUD (o restante segue o mesmo formato)
// ---------------------------------------------------------------
rotas.post("/posts", async (req, res, next) => {
  try {
    // new + save, e não Post.create(...) direto? Os dois servem — mas
    // o hook pre("save") NÃO roda em updateOne nem em
    // findOneAndUpdate. Slug gerado por hook exige save().
    const post = new Post(req.body);
    await post.save();

    res.status(201).location(`/posts/${post.slug}`).json(post);
  } catch (erro) {
    if (erro.code === 11000) {
      return res.status(409).json({ erro: "Já existe post com esse slug" });
    }
    if (erro.name === "ValidationError") {
      return res.status(422).json({
        erro: "Dados inválidos",
        campos: Object.values(erro.errors).map((e) => ({
          campo: e.path, mensagem: e.message,
        })),
      });
    }
    next(erro);
  }
});

// ---------------------------------------------------------------
// 4 — comentário
// ---------------------------------------------------------------
rotas.post("/posts/:id/comentarios", async (req, res, next) => {
  try {
    // exists() em vez de findById(): só interessa saber se existe, e
    // ele não traz o documento inteiro pela rede.
    if (!(await Post.exists({ _id: req.params.id }))) {
      return res.status(404).json({ erro: "Post não encontrado" });
    }

    const comentario = await Comentario.create({
      post: req.params.id,
      autor: req.body.autor,
      conteudo: req.body.conteudo,
    });

    res.status(201).json({
      ...comentario.toJSON(),
      aviso: "Comentário aguardando moderação.",
    });
  } catch (erro) {
    if (erro.name === "CastError") {
      // ObjectId malformado dá CastError, não "não encontrado".
      return res.status(400).json({ erro: "ID inválido" });
    }
    next(erro);
  }
});

// ---------------------------------------------------------------
// 5 — aggregation: posts mais vistos por autor
// ---------------------------------------------------------------
rotas.get("/relatorios/autores", async (req, res, next) => {
  try {
    const relatorio = await Post.aggregate([
      { $match: { publicado: true } },

      // $group primeiro, $lookup depois: assim a junção roda sobre
      // uma dúzia de autores em vez de sobre todos os posts.
      {
        $group: {
          _id: "$autor",
          totalPosts: { $sum: 1 },
          totalVisualizacoes: { $sum: "$visualizacoes" },
          mediaVisualizacoes: { $avg: "$visualizacoes" },
          maisVisto: { $max: "$visualizacoes" },
          posts: { $push: { titulo: "$titulo", slug: "$slug", visualizacoes: "$visualizacoes" } },
        },
      },

      {
        $lookup: {
          from: "autors", // o Mongoose pluraliza "Autor" assim mesmo
          localField: "_id",
          foreignField: "_id",
          as: "autor",
        },
      },
      { $unwind: "$autor" },

      {
        $project: {
          _id: 0,
          autor: "$autor.nome",
          email: "$autor.email",
          totalPosts: 1,
          totalVisualizacoes: 1,
          mediaVisualizacoes: { $round: ["$mediaVisualizacoes", 1] },
          topPosts: {
            $slice: [
              { $sortArray: { input: "$posts", sortBy: { visualizacoes: -1 } } },
              3,
            ],
          },
        },
      },

      { $sort: { totalVisualizacoes: -1 } },
    ]);

    res.json(relatorio);
  } catch (erro) {
    next(erro);
  }
});

// ---------------------------------------------------------------
// O detalhe que morde: hook não roda em update
// ---------------------------------------------------------------
// `pre("save")` só dispara em document.save() e em Model.create().
// Estes NÃO passam por ele:
//
//   Post.updateOne({ _id }, { titulo: "Novo título" })
//   Post.findByIdAndUpdate(id, { titulo: "Novo título" })
//
// O título muda e o slug fica o antigo — inconsistência que só
// aparece semanas depois. Ou você carrega o documento e usa save(),
// ou registra também o hook de query:
//
//   esquemaPost.pre("findOneAndUpdate", async function (proximo) {
//     const dados = this.getUpdate();
//     if (dados.titulo) this.set({ slug: gerarSlug(dados.titulo) });
//     proximo();
//   });

pre("save") não roda em updateOne nem em findByIdAndUpdate — o título muda e o slug fica o antigo. Hooks de documento e de query são famílias separadas no Mongoose, e é preciso registrar as duas. Some a isso o toJSON: { virtuals: true }: sem ele, os campos calculados existem no servidor e somem na resposta HTTP.

O MongoDB não tem esquema; o Mongoose tem. Essa camada é o que oferece tipo, validação, valor padrão e hook — e é também de onde vêm as surpresas, porque o que o Mongoose valida num save() ele não valida num findByIdAndUpdate(), e o unique cria um índice em vez de validar coisa alguma. Os índices, aliás, são a diferença entre a consulta instantânea e a varredura da coleção inteira, e ninguém repara nisso enquanto a base é pequena.

Fontes e Referências

Exercícios

Exercício 1

O PUT /usuarios/:id do artigo recebe { "senha": "nova-senha-123" }. O usuário depois não consegue mais entrar. O que aconteceu?

// o hook do schema
usuarioSchema.pre("save", async function (next) {
  if (!this.isModified("senha")) return next();
  this.senha = await bcrypt.hash(this.senha, 12);
  next();
});

// o service chamado pela rota
async function atualizar(id, dados) {
  return Usuario.findByIdAndUpdate(id, { $set: dados }, { new: true, runValidators: true });
}
Ver resposta

✓ Resposta: A senha foi gravada em texto puro. O hook pre("save") só dispara em save() — e findByIdAndUpdate não é save(): ele manda a operação direto ao MongoDB, sem instanciar o documento. O login deixa de funcionar porque o bcrypt.compare passa a comparar a senha digitada com uma string que nunca foi processada, e o resultado é sempre falso; o estrago maior, porém, é a senha legível no banco. Essa é a diferença que mais surpreende no Mongoose: existem dois caminhos de escrita, e os hooks de documento (save, validate) cobrem só um deles. O outro tem hooks próprios, de querypre("findOneAndUpdate") —, onde o documento não está disponível e é preciso mexer em this.getUpdate(). Vale notar que o runValidators: true ali está corrigindo o problema irmão, já que as validações do schema também não rodam num update por padrão — mas mesmo com ele, um required não é verificado, porque o Mongoose não sabe quais campos o documento já tinha. Para qualquer alteração que dependa de hook, o caminho é findById, alterar e save(), que é exatamente o que a função atualizarComHooks do artigo faz.

Exercício 2

O campo senha tem select: false. Por que o login abaixo falha para todo usuário, inclusive com a senha correta?

async function login(email, senha) {
  const usuario = await Usuario.findOne({ email });
  if (!usuario) return null;

  const ok = await usuario.verificarSenha(senha);
  return ok ? usuario : null;
}
Ver resposta

✓ Resposta: Porque select: false faz o campo ficar de fora de toda consulta, inclusive desta — usuario.senha é undefined, e o bcrypt.compare recebe undefined como hash. Dependendo da versão, ele lança Error: data and hash arguments required ou simplesmente devolve false; nos dois casos ninguém entra. A correção é pedir o campo de volta explicitamente nesta consulta e só nela: findOne({ email }).select("+senha"), que é o que o método estático buscarPorEmail do artigo já faz — e é por isso que ele existe. O sinal de + é a sintaxe para reincluir um campo excluído por padrão. Duas observações que valem além do Mongoose: devolver o documento inteiro depois do login vaza o hash para quem consumir a API, então ou se remove o campo antes de responder, ou se confia no toJSON transform do schema, que o artigo configurou justamente para isso; e, na resposta ao cliente, "email não existe" e "senha errada" devem produzir a mesma mensagem, porque distingui-las entrega a lista de quem tem conta.

Exercício 3

O email tem unique: true no schema. Dois cadastros com o mesmo email são feitos. O que o cliente recebe — e por que não é um erro de validação?

email: {
  type: String,
  required: [true, "Email é obrigatório."],
  unique: true,
  lowercase: true,
  match: [/^\S+@\S+\.\S+$/, "Email inválido."],
}
Ver resposta

✓ Resposta: O segundo cadastro falha com E11000 duplicate key error, um erro vindo do MongoDB, não do Mongoose — e por isso ele não é um ValidationError e não traz a mensagem amigável que os outros campos trazem. unique não é um validador: é uma instrução para criar um índice único na coleção. A diferença tem três consequências práticas. A primeira é de tratamento: o erro chega com erro.code === 11000 e precisa ser convertido em 409 Conflict à mão, exatamente como o tratador do artigo faz. A segunda é de tempo: o índice é criado de forma assíncrona quando a aplicação sobe, então numa coleção que já tem duplicatas ele simplesmente falha ao ser construído e a restrição nunca passa a valer — e ninguém percebe, porque a aplicação continua respondendo. A terceira é sobre o lowercase: true, que salva o dia aqui: sem ele, Ana@x.com e ana@x.com seriam chaves diferentes para o índice e as duas contas conviveriam. Em produção, vale checar Usuario.syncIndexes() ou conferir os índices direto no banco, em vez de supor que a declaração no schema virou restrição real.

Exercício 4

A coleção tem 500 mil usuários. Estas duas consultas parecem equivalentes em custo. Não são — e há ainda uma terceira diferença entre elas.

// A
const a = await Usuario.find({ email: "ana@email.com" });

// B
const b = await Usuario.find({ ativo: true }).limit(20);

// C
const c = await Usuario.find({ ativo: true }).limit(20).lean();
Ver resposta

✓ Resposta: A é instantânea porque email tem índice — o unique: true criou um, e o MongoDB vai direto ao registro. B parece barata por causa do limit(20), mas ativo não tem índice: o banco percorre os 500 mil documentos até juntar vinte que sirvam, e como a maioria costuma estar ativa, ele para cedo e a consulta parece rápida — até o dia em que o filtro é { ativo: false } e a varredura vai até o fim. O limit limita o que volta pela rede, não o que é examinado. A terceira diferença está em C: sem lean(), o Mongoose transforma cada resultado num documento completo, com getters, virtuals, rastreamento de alterações e o método save() — conveniente quando se vai alterar, e puro desperdício quando o destino é res.json(). Em listagens grandes o lean() costuma valer várias vezes em tempo e memória. O preço é perder justamente o que se descartou: virtuals somem, e o objeto devolvido não tem save(). A regra prática: índice para todo campo que aparece em filtro ou ordenação, e lean() para tudo que é só leitura.

Exercício 5

Esta rota existe para transferir saldo entre dois usuários. Ela está dentro de uma transação. Ainda assim, é possível que o dinheiro suma. Por quê?

const session = await mongoose.startSession();
session.startTransaction();

try {
  await Usuario.updateOne({ _id: de }, { $inc: { saldo: -valor } });
  await Usuario.updateOne({ _id: para }, { $inc: { saldo: valor } }, { session });
  await session.commitTransaction();
} catch (erro) {
  await session.abortTransaction();
  throw erro;
} finally {
  session.endSession();
}
Ver resposta

✓ Resposta: A primeira operação não recebeu a session — e uma operação sem sessão não faz parte da transação: ela é gravada na hora, em definitivo. Se a segunda falhar, o abortTransaction desfaz apenas o crédito, que estava dentro; o débito já aconteceu e fica. O dinheiro some. O detalhe cruel é que o código parece transacional — há startSession, commitTransaction e abortTransaction no lugar certo, e a revisão em diagonal aprova. Cada operação precisa carregar { session }, sem exceção, e é por isso que muitos projetos passam a sessão adiante por parâmetro explícito em vez de confiar na memória de quem escreve. Vale saber ainda que transação no MongoDB exige replica set — uma instância avulsa, como a que sobe no docker run mongo do começo do artigo, responde Transaction numbers are only allowed on a replica set member —, e que ela custa caro o bastante para não ser a primeira ferramenta a alcançar: quando a atualização cabe num único documento, o $inc sozinho já é atômico e resolve sem transação nenhuma.

Comentários

Mais em Javascript

A evolução das requisições: de XMLHttpRequest ao Fetch
A evolução das requisições: de XMLHttpRequest ao Fetch

O fetch não caiu do céu. Antes dele foram quinze anos de XMLHttpRequest —…

Arquitetura de Software: SOLID, Clean Architecture e DDD
Arquitetura de Software: SOLID, Clean Architecture e DDD

Se houver uma frase para levar daqui, é a regra das dependências: elas apontam…

NPM: gerenciando pacotes e dependências
NPM: gerenciando pacotes e dependências

Todo projeto Node começa com um package.json de quinze linhas e termina com um…