Skip to content

Consultas y operadores ​

En la lección anterior filtraste por igualdad. Pero un buscador real necesita "precio menor a 500", "de estas tres categorías", "que tenga la etiqueta gamer", "nombre que empiece con T"... En esta lección dominarás los operadores de consulta de MongoDB: comparación, lógicos, de elementos, expresiones regulares y arrays, además de la notación de punto y la paginación eficiente.

Antes de empezar

Ejecuta de nuevo el semilla.js de la lección de CRUD para partir de los datos originales. Todos los resultados mostrados salen de ellos.

Concepto ​

Un filtro de MongoDB es un documento donde cada campo tiene una condición. Los operadores empiezan con $ y van dentro de un subdocumento:

js
db.productos.find({ precio: { $lt: 500 } })
//                  └campo┘ └operador + valor┘

Es el equivalente a escribir WHERE precio < 500 en SQL, pero expresado como un objeto. Esa forma tiene una ventaja enorme: los filtros se construyen fácilmente desde código JavaScript.

Operadores de comparación ​

OperadorSignificaSQL
$eqIgual (implícito al escribir campo: valor)=
$neDistinto<>
$gt / $gteMayor / mayor o igual> / >=
$lt / $lteMenor / menor o igual< / <=
$inEstá en la listaIN (...)
$ninNo está en la listaNOT IN (...)
js
// Productos de menos de 500 pesos
db.productos.find({ precio: { $lt: 500 } }, { _id: 0, nombre: 1, precio: 1 }).sort({ precio: 1 })
js
[
  { nombre: "Cable USB-C", precio: 99 },
  { nombre: "Taza térmica", precio: 199 },
  { nombre: "Cien años de soledad", precio: 289 },
  { nombre: "Libro Aprende SQL", precio: 349 },
  { nombre: "Lámpara de escritorio", precio: 459 }
]
js
// Rango: entre 300 y 1000 (inclusive)
db.productos.find({ precio: { $gte: 300, $lte: 1000 } }, { _id: 0, nombre: 1, precio: 1 })
// Audífonos (899), Libro Aprende SQL (349), Lámpara (459)

// Varias categorías
db.productos.find({ categoria: { $in: ["Libros", "Hogar"] } }, { _id: 0, nombre: 1 })
// Libro Aprende SQL, Cien años de soledad, Lámpara de escritorio, Taza térmica

// Pedidos que no están entregados ni cancelados
db.pedidos.find({ estado: { $nin: ["entregado", "cancelado"] } }, { estado: 1 })
// [ { _id: 3, estado: "enviado" }, { _id: 4, estado: "pendiente" } ]

// Fechas: pedidos de junio de 2024
db.pedidos.find(
  { fecha: { $gte: new Date("2024-06-01"), $lt: new Date("2024-07-01") } },
  { fecha: 1, total: 1 }
)
// pedidos 3, 4 y 5

$ne y $nin incluyen documentos sin el campo

db.productos.find({ categoria: { $ne: "Electrónica" } }) devuelve también el Cable USB-C, que no tiene campo categoria. A diferencia de SQL (donde NULL <> 'x' no es verdadero), en MongoDB "no existe" cuenta como "distinto". Si no lo quieres: { categoria: { $exists: true, $ne: "Electrónica" } }.

Operadores lógicos ​

OperadorSignifica
$andTodas las condiciones (normalmente implícito)
$orAl menos una
$norNinguna
$notNiega una condición de un campo
js
// AND implícito: categoría Electrónica Y precio menor a 1500
db.productos.find({ categoria: "Electrónica", precio: { $lt: 1500 } }, { _id: 0, nombre: 1 })
// Audífonos Bluetooth, Teclado mecánico (el Cable USB-C no tiene categoría)

// OR: muy baratos o con poco stock
db.productos.find(
  { $or: [ { precio: { $lt: 200 } }, { stock: { $lte: 10 } } ] },
  { _id: 0, nombre: 1, precio: 1, stock: 1 }
)
js
[
  { nombre: "Teclado mecánico", precio: 1250, stock: 10 },
  { nombre: "Monitor 24 pulgadas", precio: 3499, stock: 5 },
  { nombre: "Taza térmica", precio: 199, stock: 50 },
  { nombre: "Cable USB-C", precio: 99, stock: 100 }
]
js
// AND + OR combinados: (Libros o Hogar) Y precio menor a 300
db.productos.find({
  $or: [ { categoria: "Libros" }, { categoria: "Hogar" } ],
  precio: { $lt: 300 }
}, { _id: 0, nombre: 1, precio: 1 })
// Cien años de soledad (289), Taza térmica (199)

// $and explícito: necesario cuando repites el mismo campo u operador
db.productos.find({
  $and: [
    { $or: [ { categoria: "Electrónica" }, { stock: { $gt: 40 } } ] },
    { $or: [ { precio: { $lt: 1000 } }, { etiquetas: "gamer" } ] }
  ]
}, { _id: 0, nombre: 1 })
// Audífonos, Teclado, Monitor, Taza térmica, Cable USB-C

// $not: precio NO mayor a 1000 (incluye documentos sin precio)
db.productos.find({ precio: { $not: { $gt: 1000 } } }, { _id: 0, nombre: 1 })

// $nor: ni Electrónica ni con stock mayor a 30
db.productos.find({ $nor: [ { categoria: "Electrónica" }, { stock: { $gt: 30 } } ] }, { _id: 0, nombre: 1 })
// Cien años de soledad (30), Lámpara de escritorio (15)

$in en lugar de $or

Si comparas el mismo campo con varios valores, { categoria: { $in: ["Libros", "Hogar"] } } es más corto y eficiente que un $or.

Operadores de elemento: $exists y $type ​

js
// Productos sin categoría
db.productos.find({ categoria: { $exists: false } }, { nombre: 1 })
// [ { _id: 8, nombre: "Cable USB-C" } ]

// Productos que tienen el atributo color
db.productos.find({ "atributos.color": { $exists: true } }, { _id: 0, nombre: 1, "atributos.color": 1 })
// Audífonos (negro), Teclado (blanco), Lámpara (negro)

// Detectar datos sucios: precios guardados como texto
db.productos.find({ precio: { $type: "string" } })

// Campo con valor null O inexistente
db.productos.find({ categoria: null })            // coincide con ambos casos
db.productos.find({ categoria: { $type: "null" } }) // solo los que tienen null explícito

Notación de punto: subdocumentos ​

js
// Clientes de Ciudad de México
db.clientes.find({ "direccion.ciudad": "Ciudad de México" }, { _id: 0, nombre: 1 })
// Ana López, Jorge Díaz

// Libros de más de 400 páginas
db.productos.find({ "atributos.paginas": { $gt: 400 } }, { _id: 0, nombre: 1 })
// Cien años de soledad

// Monitores de al menos 120 Hz
db.productos.find({ "atributos.hz": { $gte: 120 } }, { _id: 0, nombre: 1, "atributos.hz": 1 })

Igualdad de subdocumento completo

db.clientes.find({ direccion: { ciudad: "Puebla" } }) no encuentra a Lucía: compara el subdocumento exacto (mismos campos, mismo orden), y el de Lucía también tiene cp. Para buscar por un campo interno usa siempre la notación de punto: { "direccion.ciudad": "Puebla" }.

Expresiones regulares: $regex ​

js
// Nombre que empieza con "T"
db.productos.find({ nombre: { $regex: "^T" } }, { _id: 0, nombre: 1 })
// Teclado mecánico, Taza térmica

// Contiene "de", sin distinguir mayúsculas (opción i)
db.productos.find({ nombre: { $regex: "de", $options: "i" } }, { _id: 0, nombre: 1 })
// Libro Aprende SQL ("Apren-de"), Cien años de soledad, Lámpara de escritorio

// Forma corta con literal de JavaScript
db.clientes.find({ email: /@correo\.com$/ })

// Emails de Gmail o Hotmail
db.clientes.find({ email: { $regex: "@(gmail|hotmail)\\.com$", $options: "i" } })
PatrónSignifica
^abcEmpieza con abc
abc$Termina con abc
.Cualquier carácter
.*Cualquier cosa
\.Un punto literal
(a|b)a o b
[0-9]{5}5 dígitos (por ejemplo, un CP)

Regex y seguridad

  1. Rendimiento: solo los regex anclados al inicio y sensibles a mayúsculas (^Tec) aprovechan un índice. /teclado/i recorre todos los documentos.
  2. Inyección: si construyes un regex con texto del usuario, escapa los caracteres especiales; un patrón malicioso como (a+)+$ puede congelar el servidor (ReDoS). En Node: texto.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").
  3. Para buscadores de texto usa un índice de texto ($text) o Atlas Search (Índices y modelado).

Consultas en arrays ​

Valor dentro de un array ​

js
// Basta con que el array contenga el valor
db.productos.find({ etiquetas: "gamer" }, { _id: 0, nombre: 1 })
// Teclado mecánico, Monitor 24 pulgadas

// Contiene alguno de estos
db.productos.find({ etiquetas: { $in: ["audio", "cocina"] } }, { _id: 0, nombre: 1 })
// Audífonos Bluetooth, Taza térmica

$all: contiene todos ​

js
db.productos.find({ etiquetas: { $all: ["gamer", "oficina"] } }, { _id: 0, nombre: 1 })
// Teclado mecánico, Monitor 24 pulgadas (sin importar el orden)

$size: tamaño exacto ​

js
db.productos.find({ etiquetas: { $size: 0 } }, { _id: 0, nombre: 1 })   // Cable USB-C
db.productos.find({ etiquetas: { $size: 2 } }, { _id: 0, nombre: 1 })
// Audífonos, Teclado, Monitor, Cien años de soledad

$size no admite rangos ($gt). Para "más de 2 elementos" usa { "etiquetas.2": { $exists: true } } (existe la posición 2, es decir, un tercer elemento).

Arrays de subdocumentos y $elemMatch ​

js
// Pedidos que incluyen el producto 7 (Taza térmica)
db.pedidos.find({ "items.productoId": 7 }, { _id: 1 })
// pedidos 2 y 4

// Pedidos con algún item de cantidad >= 3
db.pedidos.find({ "items.cantidad": { $gte: 3 } }, { _id: 1 })
// pedido 2 (3 tazas)

Con varias condiciones sobre el mismo elemento, la notación de punto puede engañarte:

js
// Intención: pedidos con un item de Taza térmica con cantidad >= 3
db.pedidos.find({ "items.productoId": 7, "items.cantidad": { $gte: 2 } }, { _id: 1 })
// pedidos 2 y 4... ¿correcto?

Las condiciones pueden cumplirse en elementos distintos del array. Para exigir que el mismo elemento cumpla todo, usa $elemMatch:

js
// Un mismo item: producto 4 Y cantidad >= 2
db.pedidos.find({ items: { $elemMatch: { productoId: 4, cantidad: { $gte: 2 } } } }, { _id: 1 })
// [ { _id: 1 } ]  ← 2 libros en el pedido 1

// Comparación: sin $elemMatch, ¿pedidos con producto 1 y alguna cantidad >= 2?
db.pedidos.find({ "items.productoId": 1, "items.cantidad": { $gte: 2 } }, { _id: 1 })
// [ { _id: 1 } ]  ← ¡falso positivo! el producto 1 tiene cantidad 1; el 2 lo aporta el libro

db.pedidos.find({ items: { $elemMatch: { productoId: 1, cantidad: { $gte: 2 } } } }, { _id: 1 })
// []  ← correcto: ningún pedido tiene 2 o más audífonos

Proyección de elementos de un array ​

js
// Solo el primer item que coincide ($)
db.pedidos.find({ "items.productoId": 7 }, { "items.$": 1 })

// Solo los items que cumplan algo ($elemMatch en proyección)
db.pedidos.find({ _id: 4 }, { items: { $elemMatch: { precio: { $gt: 300 } } } })
// { _id: 4, items: [ { productoId: 6, nombre: "Lámpara de escritorio", cantidad: 1, precio: 459 } ] }

// Los primeros N elementos ($slice)
db.pedidos.find({ _id: 4 }, { items: { $slice: 2 }, total: 1 })

$expr: comparar campos del mismo documento ​

Los operadores normales comparan un campo con un valor fijo. Para comparar dos campos entre sí usa $expr con sintaxis de agregación:

js
// Productos donde el valor del inventario (precio × stock) supera 15,000
db.productos.find(
  { $expr: { $gt: [ { $multiply: ["$precio", "$stock"] }, 15000 ] } },
  { _id: 0, nombre: 1 }
)
// Audífonos (22,475), Monitor (17,495)

"$precio" con $ delante significa "el valor del campo precio". Lo usarás mucho en Agregaciones.

Paginación ​

Con skip y limit ​

js
const porPagina = 3;
const pagina = 2;
db.productos.find({}, { nombre: 1, precio: 1 })
  .sort({ _id: 1 })
  .skip((pagina - 1) * porPagina)
  .limit(porPagina)
// _id 4, 5, 6

Sencilla, pero skip(100000) obliga al servidor a recorrer y descartar 100,000 documentos: se vuelve lenta en páginas profundas.

Con cursor (keyset): la forma eficiente ​

Recuerda el último valor visto y pide "los siguientes":

js
// Primera página
let pagina1 = db.productos.find().sort({ _id: 1 }).limit(3).toArray();
let ultimo = pagina1[pagina1.length - 1]._id;   // 3

// Siguiente página: usa el índice de _id directamente, sin skip
db.productos.find({ _id: { $gt: ultimo } }).sort({ _id: 1 }).limit(3)
// _id 4, 5, 6

Si ordenas por un campo no único (precio), desempata con _id:

js
// Siguiente página tras { precio: 349, _id: 4 }
db.productos.find({
  $or: [
    { precio: { $gt: 349 } },
    { precio: 349, _id: { $gt: 4 } }
  ]
}).sort({ precio: 1, _id: 1 }).limit(3)

Es la técnica detrás de los "cargar más" y el scroll infinito.

Caso real: el buscador de la tienda ​

Construir el filtro dinámicamente según lo que eligió el usuario (en Node):

js
function construirFiltro({ texto, categorias, precioMin, precioMax, etiqueta, soloDisponibles }) {
  const filtro = {};

  if (texto) {
    const seguro = texto.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");   // escapar regex
    filtro.nombre = { $regex: seguro, $options: "i" };
  }
  if (categorias?.length) filtro.categoria = { $in: categorias };
  if (precioMin != null || precioMax != null) {
    filtro.precio = {};
    if (precioMin != null) filtro.precio.$gte = Number(precioMin);
    if (precioMax != null) filtro.precio.$lte = Number(precioMax);
  }
  if (etiqueta) filtro.etiquetas = etiqueta;
  if (soloDisponibles) filtro.stock = { $gt: 0 };

  return filtro;
}

const filtro = construirFiltro({ texto: "a", categorias: ["Electrónica", "Hogar"], precioMin: 100, precioMax: 1000 });
// { nombre: { $regex: "a", $options: "i" }, categoria: { $in: [...] }, precio: { $gte: 100, $lte: 1000 } }
const productos = await db.collection("productos").find(filtro).sort({ precio: 1 }).toArray();
// Taza térmica (199), Lámpara de escritorio (459), Audífonos Bluetooth (899)

Inyección de operadores (NoSQL injection)

Si pasas directamente req.body o req.query como filtro, un atacante puede enviar { "password": { "$ne": null } } y saltarse un login. Nunca uses objetos del usuario como filtro sin validarlos: convierte a los tipos esperados (String(...), Number(...)), construye el filtro tú mismo como en el ejemplo, o usa librerías como express-mongo-sanitize. Mongoose con sanitizeFilter también ayuda.

Ejercicios ​

  1. Productos con stock entre 10 y 30 (inclusive), mostrando nombre y stock.
  2. Clientes registrados en febrero de 2024.
  3. Productos de Electrónica que tengan la etiqueta oficina o cuesten menos de 1000.
  4. Pedidos que contengan al menos un item con precio mayor a 1000.
  5. Productos cuyo nombre termine en "a" (sin distinguir mayúsculas).
  6. Pedidos donde algún mismo item tenga productoId: 7 y cantidad mayor o igual a 3.
Ver solución
js
// 1
db.productos.find({ stock: { $gte: 10, $lte: 30 } }, { _id: 0, nombre: 1, stock: 1 })
// Audífonos 25, Teclado 10, Cien años 30, Lámpara 15

// 2
db.clientes.find(
  { registro: { $gte: new Date("2024-02-01"), $lt: new Date("2024-03-01") } },
  { _id: 0, nombre: 1 }
)
// Carlos Ruiz, María Pérez

// 3
db.productos.find(
  { categoria: "Electrónica", $or: [ { etiquetas: "oficina" }, { precio: { $lt: 1000 } } ] },
  { _id: 0, nombre: 1 }
)
// Audífonos (899), Teclado (oficina), Monitor (oficina)

// 4
db.pedidos.find({ "items.precio": { $gt: 1000 } }, { _id: 1 })
// pedidos 2 (teclado 1250) y 3 (monitor 3499)

// 5
db.productos.find({ nombre: { $regex: "a$", $options: "i" } }, { _id: 0, nombre: 1 })
// Taza térmica

// 6
db.pedidos.find({ items: { $elemMatch: { productoId: 7, cantidad: { $gte: 3 } } } }, { _id: 1 })
// pedido 2

Resumen ​

  • Comparación: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin (ojo: $ne/$nin incluyen documentos sin el campo).
  • Lógicos: AND implícito, $or, $and, $nor, $not.
  • $exists y $type encuentran campos faltantes y datos con tipos incorrectos.
  • Notación de punto para subdocumentos y arrays; $elemMatch cuando varias condiciones deben cumplirse en el mismo elemento.
  • $regex para patrones (con cuidado de rendimiento y seguridad); $expr para comparar campos entre sí.
  • Pagina con cursor (_id > último) en lugar de skip grandes, y nunca uses objetos del usuario como filtro sin validarlos.

Academia Web — aprende, copia y construye.