MongoDB e Mongoose: banco de dados com Node

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 leituras recomendadas

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 query — pre("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

Criando um servidor HTTP com Node.js puro
Criando um servidor HTTP com Node.js puro

Um require de http, uma função de dois argumentos, e o servidor está de pé. O…

Trabalhando com arquivos CSV
Trabalhando com arquivos CSV

CSV parece o formato mais simples que existe até o primeiro campo com vírgula…

Git avançado e fluxo de trabalho em equipe
Git avançado e fluxo de trabalho em equipe

Saber add, commit e push é o mínimo. O que aparece em projeto com mais gente é…