Tema
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
| Operador | Significa | SQL |
|---|---|---|
$eq | Igual (implícito al escribir campo: valor) | = |
$ne | Distinto | <> |
$gt / $gte | Mayor / mayor o igual | > / >= |
$lt / $lte | Menor / menor o igual | < / <= |
$in | Está en la lista | IN (...) |
$nin | No está en la lista | NOT 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
| Operador | Significa |
|---|---|
$and | Todas las condiciones (normalmente implícito) |
$or | Al menos una |
$nor | Ninguna |
$not | Niega 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ícitoNotació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ón | Significa |
|---|---|
^abc | Empieza 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
- Rendimiento: solo los regex anclados al inicio y sensibles a mayúsculas (
^Tec) aprovechan un índice./teclado/irecorre todos los documentos. - 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, "\\$&"). - 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ífonosProyecció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, 6Sencilla, 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, 6Si 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
- Productos con stock entre 10 y 30 (inclusive), mostrando nombre y stock.
- Clientes registrados en febrero de 2024.
- Productos de Electrónica que tengan la etiqueta
oficinao cuesten menos de 1000. - Pedidos que contengan al menos un item con precio mayor a 1000.
- Productos cuyo nombre termine en "a" (sin distinguir mayúsculas).
- Pedidos donde algún mismo item tenga
productoId: 7ycantidadmayor 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 2Resumen
- Comparación:
$eq,$ne,$gt,$gte,$lt,$lte,$in,$nin(ojo:$ne/$ninincluyen documentos sin el campo). - Lógicos: AND implícito,
$or,$and,$nor,$not. $existsy$typeencuentran campos faltantes y datos con tipos incorrectos.- Notación de punto para subdocumentos y arrays;
$elemMatchcuando varias condiciones deben cumplirse en el mismo elemento. $regexpara patrones (con cuidado de rendimiento y seguridad);$exprpara comparar campos entre sí.- Pagina con cursor (
_id > último) en lugar deskipgrandes, y nunca uses objetos del usuario como filtro sin validarlos.