Ir al contenido
wannacode

Bases de datos

En resumen: Una base de datos guarda los datos en tablas y los encuentra rápido con una consulta SQL; en Node.js hablas con ella a través de node:sqlite, y los parámetros con ? protegen contra la inyección SQL.

Problema

Un catálogo en un archivo

El catálogo de la tienda es un archivo CSV, como en el módulo de archivos. Mientras hay cien productos, funciona. Luego hay cien mil, y aparecen problemas:

  • para encontrar un producto, lees el archivo entero;
  • «todo lo que cuesta menos de 5 €, ordenado por precio» es un bucle, un if y una ordenación, escritos a mano;
  • dos cajas escriben en el archivo a la vez, y un registro se pierde o el archivo se estropea;
  • se va la luz en mitad de una escritura, y desaparece la mitad del archivo.

Por eso los datos de casi cualquier programa real viven en una base de datos: un programa cuyo trabajo es guardar datos, encontrarlos rápido y no perderlos nunca.

La idea

Tablas y SQL

Una base de datos relacional guarda los datos en tablas. Una tabla tiene columnas (nombre, tipo) y filas (una por cada producto). La columna id es la clave primaria: su valor es distinto en cada fila, así que con ella se encuentra exactamente una.

Con la base de datos se habla en SQL. Los cinco comandos más comunes:

SQL
CREATE TABLE products (
id INT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
price DECIMAL(8, 2)
);
INSERT INTO products VALUES (1, 'Café', 7.50);
SELECT name, price FROM products WHERE price < 5 ORDER BY price;
UPDATE products SET price = 6.90 WHERE id = 1;
DELETE FROM products WHERE id = 3;
  • CREATE TABLE crea una tabla, INSERT añade una fila, UPDATE cambia filas y DELETE las borra.
  • SELECT lee: qué columnas, de qué tabla, con qué condición (WHERE) y en qué orden (ORDER BY).

Tú dices qué quieres, y la base de datos decide cómo encontrarlo rápido.

Más precisamente, para curiosos
  • Bases de datos relacionales conocidas son PostgreSQL, MySQL, Oracle y SQL Server. Funcionan como servidores aparte. Aquí usamos SQLite: una base de datos pequeña que vive en un archivo o entera en la memoria del programa. El módulo node:sqlite viene incluido en Node 22 y no necesita ningún flag, pero todavía es experimental: al ejecutarlo, imprime ExperimentalWarning: SQLite is an experimental feature en stderr. La dirección ":memory:" es una base de datos en memoria: cada ejecución empieza con una vacía.
  • El texto en SQL va entre comillas simples: 'Café'. En PostgreSQL y MySQL, DECIMAL(8, 2) es un número exacto con 2 decimales, adecuado para dinero. SQLite no guarda números decimales exactos: ahí 7.50 es un número de coma flotante normal.

Míralo

node:sqlite: conexión, consulta, resultado

El módulo node:sqlite habla con SQLite. La conexión es una DatabaseSync, y una consulta con resultado se hace con prepare. La función setup crea la tabla y añade tres productos:
function setup(db) {
db.exec("CREATE TABLE products (id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, price DECIMAL(8, 2))");
db.exec("INSERT INTO products VALUES (1, 'Café', 7.50), (2, 'Té', 3.20), (3, 'Chocolate', 2.40)");
}
Leemos las filas y convertimos cada una en un objeto de la clase Product, con los campos id, name y price:
class Product {
constructor(id, name, price) {
this.id = id;
this.name = name;
this.price = price;
}
}
const db = new DatabaseSync(":memory:");
setup(db);
const products = [];
for (const row of db.prepare("SELECT id, name, price FROM products ORDER BY price").all()) {
products.push(new Product(row.id, row.name, row.price));
}
for (const p of products) {
console.log(p);
}
db.close();
Salida
Product { id: 3, name: 'Chocolate', price: 2.4 }
Product { id: 2, name: 'Té', price: 3.2 }
Product { id: 1, name: 'Café', price: 7.5 }
  • new DatabaseSync(dirección) abre la conexión. La dirección dice qué base de datos: aquí ":memory:", o un nombre de archivo como "shop.db". «Sync» significa que los métodos devuelven el resultado enseguida, sin await.
  • db.exec(sql) envía comandos sin resultado (CREATE, INSERT, UPDATE, DELETE). db.prepare(sql) prepara una consulta, y .all() devuelve todas las filas como un array. Cada fila es un objeto con un campo por cada columna: row.name.
  • .get() devuelve solo la primera fila, y .run() es para los cambios y devuelve cuántas filas cambiaron (changes).
  • Por defecto, cada consulta se guarda enseguida. db.close() cierra la conexión.
  • Los errores se lanzan como un Error con el code "ERR_SQLITE_ERROR".

const db = new DatabaseSync(":memory:");
setup(db);
const products = [];
for (const row of db.prepare("SELECT id, name, price FROM products ORDER BY price").all()) {
products.push(new Product(row.id, row.name, row.price));
}
for (const p of products) {
console.log(p);
}
db.close();
Paso a paso, en palabras
  1. Una conexión con la base de datos en memoria. setup crea la tabla products y añade 3 filas.
  2. Una lista vacía, donde irán los objetos.
  3. SELECT con ORDER BY price: all() devuelve todas las filas de una vez como un array, de la más barata a la más cara.
  4. El bucle toma la fila 1: un objeto con un campo por cada columna. new Product crea con ellos un objeto: Chocolate.
  5. El bucle toma la fila 2: un objeto con un campo por cada columna. new Product crea con ellos un objeto: Té.
  6. El bucle toma la fila 3: un objeto con un campo por cada columna. new Product crea con ellos un objeto: Café.
  7. El array se acabó y el bucle se detiene.
  8. El array tiene objetos normales. console.log muestra la clase y sus campos.
Más precisamente, para curiosos
  • Con una base de datos real en un servidor, la dirección es otra y hay un usuario y una contraseña. Además necesitas el driver (controlador) de la base de datos como dependencia en el proyecto (para MySQL: el paquete mysql2):
import mysql from "mysql2/promise";
const conn = await mysql.createConnection({ host: "localhost", port: 3306, database: "shop", user: "shop_app", password: "secreto" });
console.log("¡Conectado a la base de datos!");
await conn.end();
  • La contraseña no se escribe en el código. Se lee de la configuración o de una variable de entorno (process.env).
  • Aquí los precios son number para que sea breve. En una tienda real, el dinero se guarda exacto: como céntimos enteros, porque JavaScript no tiene un tipo decimal exacto.
  • console.log(row) muestra [Object: null prototype] { … }: las filas son objetos sin prototipo, solo con los campos de las columnas.

Pruébalo

¿Qué se imprimirá?

Los productos son Café 7,50 €, Té 3,20 € y Chocolate 2,40 €. ¿Qué imprimirá el programa?

const db = new DatabaseSync(":memory:");
setup(db);
for (const row of db.prepare("SELECT name FROM products WHERE price < 5 ORDER BY price DESC").all()) {
console.log(row.name);
}
db.close();
Respuesta

Problema

El buscador que lo muestra todo

La web busca un producto por el nombre que escribe el usuario. La consulta se arma con una plantilla `…${name}…`:

function search(db, name) {
const sql = `SELECT name, price FROM products WHERE name = '${name}'`;
for (const row of db.prepare(sql).all()) {
console.log(` ${row.name}: ${row.price} €`);
}
}
const db = new DatabaseSync(":memory:");
setup(db);
console.log("Buscando: Té");
search(db, "Té");
console.log("Buscando: x' OR '1'='1");
search(db, "x' OR '1'='1");
db.close();
Salida
Buscando: Té
Té: 3.2 €
Buscando: x' OR '1'='1
Café: 7.5 €
Té: 3.2 €
Chocolate: 2.4 €

Con «Té» funciona. Con x' OR '1'='1 la consulta se convierte en ... WHERE name = 'x' OR '1'='1'. La condición siempre es verdadera, y sale la tabla entera. La entrada del usuario ha pasado a formar parte del SQL. Esto es la inyección SQL (SQL injection), uno de los ataques más comunes contra sitios web. Con el mismo truco, alguien puede entrar sin contraseña o borrar una tabla.

La idea

Parámetros con ?

La solución es enviar la consulta y los valores por separado. La consulta de prepare tiene ? en el lugar de los valores, y tú se los pasas como argumentos de all, get o run, en el mismo orden:
function search(db, name) {
const sql = "SELECT name, price FROM products WHERE name = ?";
for (const row of db.prepare(sql).all(name)) {
console.log(` ${row.name}: ${row.price} €`);
}
}
Salida
Buscando: Té
Té: 3.2 €
Buscando: x' OR '1'='1

Ahora x' OR '1'='1 es solo un texto que se busca como nombre. No existe ese producto, y no sale nada. Escriba lo que escriba el usuario, no puede convertirse en SQL.

La regla es sencilla: un valor del usuario, de un archivo o de la red nunca se pega dentro del SQL. Usa siempre ?.

Pruébalo

Café con apóstrofo

La función cambia el nombre de un producto. Con el nombre Café 'Arábica' el programa se detiene con Error: near "Arábica": syntax error. ¿En qué línea está el error?

Elige la línea con el error.

La idea

Transacción: todo o nada

Ana le transfiere 150 € a Carlos. Son dos consultas: sumarle a Carlos y restarle a Ana. La tabla tiene la condición CHECK (balance >= 0), así que la segunda consulta falla: Ana solo tiene 100 €.

function openAccounts(db) {
db.exec("CREATE TABLE accounts (id INT PRIMARY KEY, owner VARCHAR(50), balance INT CONSTRAINT no_debt CHECK (balance >= 0))");
db.exec("INSERT INTO accounts VALUES (1, 'Ana', 100), (2, 'Carlos', 50)");
}
function move(db, id, amount) {
db.prepare("UPDATE accounts SET balance = balance + ? WHERE id = ?").run(amount, id);
}
function printBalances(db) {
for (const row of db.prepare("SELECT owner, balance FROM accounts ORDER BY id").all()) {
console.log(` ${row.owner}: ${row.balance} €`);
}
}
function transfer(db, from, to, amount) {
move(db, to, amount);
move(db, from, -amount);
}
const db = new DatabaseSync(":memory:");
openAccounts(db);
try {
transfer(db, 1, 2, 150);
} catch {
console.log("La transferencia de 150 € fue rechazada.");
}
printBalances(db);
db.close();
Salida
La transferencia de 150 € fue rechazada.
Ana: 100 €
Carlos: 200 €
La transferencia se rechazó, pero Carlos ya ha recibido 150 € de la nada. Por defecto, cada consulta se guarda enseguida. Las dos consultas tienen que ser una transacción: o pasan las dos, o ninguna.
function transfer(db, from, to, amount) {
db.exec("BEGIN");
try {
move(db, to, amount);
move(db, from, -amount);
db.exec("COMMIT");
} catch (error) {
db.exec("ROLLBACK");
throw error;
}
}
Salida
La transferencia de 150 € fue rechazada.
Ana: 100 €
Carlos: 50 €
  • db.exec("BEGIN") empieza una transacción: los cambios todavía no son definitivos.
  • db.exec("COMMIT") los guarda todos de una vez.
  • db.exec("ROLLBACK") los deshace todos si algo falla. La excepción sigue hacia arriba con throw, para que también se entere quien llamó.
  • BEGIN, COMMIT y ROLLBACK son comandos SQL normales: así se hacen las transacciones también en el propio SQL.
Más precisamente, para curiosos
  • El error de la consulta que falla tiene el code ERR_SQLITE_ERROR y el mensaje CHECK constraint failed: no_debt. no_debt es el nombre de la condición: en la tabla es balance INT CONSTRAINT no_debt CHECK (balance >= 0).
  • Las bases de datos prometen cuatro cosas sobre las transacciones, conocidas como ACID: todo o nada (atomicidad, atomicity), los datos siguen siendo correctos según las reglas de las tablas (consistencia, consistency), las transacciones que se ejecutan a la vez no se estorban (aislamiento, isolation), y lo que se ha guardado no se pierde aunque se vaya la luz (durabilidad, durability).

La idea

ORM: objetos en lugar de filas

Con node:sqlite, para cada clase escribes SQL y conviertes las filas en objetos a mano. Un ORM (object-relational mapping, mapeo objeto-relacional) lo hace por ti: describes el modelo, y la biblioteca crea sola la tabla, las consultas y los objetos. Node no tiene ORM. Uno de los más antiguos y conocidos es Sequelize (los paquetes sequelize y sqlite3 de npm). Otros son Prisma, TypeORM y Drizzle.
models.js
import { DataTypes, Op, Sequelize } from "sequelize";
const sequelize = new Sequelize({ dialect: "sqlite", storage: ":memory:", logging: false });
const Product = sequelize.define("Product", {
name: { type: DataTypes.STRING(50), allowNull: false },
price: DataTypes.DOUBLE,
}, { tableName: "products", timestamps: false });
  • sequelize.define describe un modelo: una tabla con el nombre products (tableName).
  • Cada campo es una columna con un tipo de DataTypes. allowNull: false es NOT NULL.
  • Sequelize añade solo una columna id como clave primaria, y la base de datos le da un valor.
  • timestamps: false desactiva las columnas createdAt y updatedAt que Sequelize añade si no.

Guardar y leer pasa por el modelo:

await sequelize.sync();
await Product.create({ name: "Café", price: 7.50 });
await Product.create({ name: "Té", price: 3.20 });
const cheap = await Product.findAll({ where: { price: { [Op.lt]: 5 } }, order: [["price", "ASC"]] });
for (const p of cheap) {
console.log(`${p.name}: ${p.price} €`);
}
Salida (con sequelize y sqlite3 instalados)
Té: 3.2 €

new Sequelize({ dialect: "sqlite", storage: ":memory:" }) dice qué base de datos: aquí, SQLite en memoria. sync() crea las tablas. Product.create guarda un objeto. findAll construye la consulta a partir de un objeto: { price: { [Op.lt]: 5 } } es WHERE price < 5. El resultado es un array de objetos ya hechos. Todos los métodos devuelven promesas, así que usas await.

Sequelize es un paquete, y el comprobador del sitio no lo tiene, así que este código no se ejecuta aquí. La salida viene de una ejecución con sequelize 6 y sqlite3 5 instalados (npm install sequelize sqlite3).

Más precisamente, para curiosos
  • Las condiciones de findAll son un objeto normal, así que es fácil armarlas según lo que elige el usuario. Una función que busca por nombre:
function named(name) {
return Product.findAll({ where: { name } });
}
  • Una transacción que se deshace si hay un error: sequelize.transaction con una función lo guarda todo al final, y si la función lanza una excepción, lo deshace todo. Cada consulta de dentro recibe { transaction }:
await sequelize.transaction(async (transaction) => {
await Product.create({ name: "Chocolate", price: 2.40 }, { transaction });
// más cambios: con una excepción se deshace todo; si no, se guarda todo
});
  • Con logging: console.log en lugar de false, Sequelize imprime el SQL que envía. Así ves que por debajo sigue siendo SQL.

Ejercicio

Una tienda con base de datos

Resuelve las tareas en tu editor, con node:sqlite y la base de datos ":memory:".1. Catálogo. Crea una tabla products y las funciones add, setPrice, remove y list, todas con ? menos list. Añade Café (7,50 €), Té (3,20 €) y Chocolate (2,40 €), imprímelos, cambia el precio del café a 6,90 €, borra el chocolate e imprime otra vez:
Salida
1. Café: 7.5 €
2. Té: 3.2 €
3. Chocolate: 2.4 €
---
1. Café: 6.9 €
2. Té: 3.2 €
2. Búsqueda por parte del nombre. Los productos son Café, Cacao, Té y Éclair. Una función find(db, text) encuentra todos los productos cuyo nombre contiene el texto, sin importar mayúsculas y minúsculas. LOWER en SQLite solo cambia las letras de la A a la Z, así que É se queda como está. Dale a la base de datos una función propia: db.function("lower_text", (text) => text.toLowerCase()), luego WHERE lower_text(name) LIKE ? con el valor `%${text.toLowerCase()}%`. Prueba «CA», «éclair» y un intento de inyección:
Salida
Buscando «CA»:
Cacao: 4.8 €
Café: 7.5 €
Buscando «éclair»:
Éclair: 2.4 €
Buscando «' OR '1'='1»:
no se encontraron productos
3. Una transferencia que falla a medias. Las cuentas son Ana (100 €) y Carlos (50 €). transfer(db, from, to, amount) primero resta de from y luego suma a to. Si run() devuelve changes 0 (no existe esa cuenta), lanza new Error(`No existe la cuenta ${id}`). Todo va entre BEGIN y COMMIT. Transfiere 30 € de la 1 a la 2, y luego 40 € de la 1 a la cuenta 99, que no existe:
Salida
Transferidos 30 € de 1 a 2
Rechazada: No existe la cuenta 99
Ana: 70 €
Carlos: 80 €

La segunda transferencia le quitó 40 € a Ana antes de fallar, pero ROLLBACK se los devolvió.

Test

Ponte a prueba

1. ¿Qué devuelve db.prepare("SELECT ...").all()?
2. ¿Por qué es peligroso ... WHERE name = '${name}'?
3. Una transferencia son dos consultas entre BEGIN y COMMIT. La primera sale bien, la segunda falla. ¿Qué hace ROLLBACK?
4. ¿Qué hace un ORM como Sequelize?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·