Skip to content

🎯 Meta: meter lógica dentro de la base de datos. Hasta ahora la BD solo guardaba datos; ahora puede reaccionar, calcular y automatizar. Esto es lo que separa a un backend junior de uno senior.

Seguimos con PostgreSQL 18.


2.1 · ¿Por qué poner lógica en la base de datos?

Normalmente la lógica va en tu app (Laravel, FastAPI…). Pero hay cosas que conviene poner en la propia BD:

  • Integridad garantizada: una regla en la BD se cumple aunque cambies de framework o alguien toque los datos por otro camino.
  • Rendimiento: procesar cerca de los datos evita ir y venir por la red.
  • Automatización: que ciertas cosas pasen "solas" al insertar/actualizar.

⚖️ Equilibrio (tip importante): no metas toda la lógica en la BD. Reglas de negocio complejas van mejor en la app (más fáciles de testear y versionar). Usa la BD para: integridad, auditoría, cálculos derivados y automatismos simples. Un exceso de triggers hace que "pasen cosas mágicas" difíciles de depurar.


2.2 · Vistas (Views) — Consultas guardadas

Una vista es una consulta con nombre que se comporta como una tabla virtual. Útil para encapsular consultas complejas o exponer solo ciertos datos.

sql
CREATE VIEW usuarios_activos AS
SELECT id, nombre, email
FROM usuarios
WHERE activo = true;

-- Ahora la usas como si fuera una tabla:
SELECT * FROM usuarios_activos WHERE email LIKE '%@empresa.com';

Vista materializada (MATERIALIZED VIEW): guarda el resultado en disco (rápida de leer, pero hay que refrescarla). Ideal para informes pesados que no cambian a cada segundo:

sql
CREATE MATERIALIZED VIEW ventas_por_mes AS
SELECT date_trunc('month', creado_en) AS mes, SUM(total) AS total
FROM pedidos
GROUP BY 1;

REFRESH MATERIALIZED VIEW ventas_por_mes;   -- actualizar cuando haga falta

2.3 · Funciones (Functions) — Lógica reutilizable

Una función recibe parámetros, ejecuta lógica y devuelve un valor. En PostgreSQL se escriben en PL/pgSQL (el lenguaje procedimental de Postgres).

sql
CREATE OR REPLACE FUNCTION calcular_igv(monto NUMERIC)
RETURNS NUMERIC AS $$
BEGIN
    RETURN monto * 0.18;   -- IGV del 18% (Perú)
END;
$$ LANGUAGE plpgsql;

-- Usarla:
SELECT calcular_igv(100);          -- devuelve 18.00
SELECT nombre, calcular_igv(total) AS igv FROM pedidos JOIN usuarios ...;

Ejemplo más real: una función que devuelve el total gastado por un usuario.

sql
CREATE OR REPLACE FUNCTION total_gastado(p_usuario_id BIGINT)
RETURNS NUMERIC AS $$
DECLARE
    v_total NUMERIC;
BEGIN
    SELECT COALESCE(SUM(total), 0)
    INTO v_total
    FROM pedidos
    WHERE usuario_id = p_usuario_id;

    RETURN v_total;
END;
$$ LANGUAGE plpgsql;

SELECT nombre, total_gastado(id) FROM usuarios;

Elementos de PL/pgSQL:

ElementoQué hace
DECLAREDeclara variables locales
v_total NUMERICUna variable (prefijo v_ por convención)
SELECT ... INTO variableGuarda el resultado de la consulta en la variable
COALESCE(x, 0)Si x es NULL, usa 0 (evita nulos)
RETURNDevuelve el valor

2.4 · Procedimientos almacenados (Stored Procedures)

Un procedimiento es como una función pero no devuelve un valor (o devuelve varios vía parámetros) y puede controlar transacciones (COMMIT/ROLLBACK dentro). Se usa para acciones, no para cálculos.

sql
CREATE OR REPLACE PROCEDURE transferir(
    p_origen  BIGINT,
    p_destino BIGINT,
    p_monto   NUMERIC
)
LANGUAGE plpgsql AS $$
BEGIN
    -- Validación
    IF p_monto <= 0 THEN
        RAISE EXCEPTION 'El monto debe ser positivo, recibido: %', p_monto;
    END IF;

    UPDATE cuentas SET saldo = saldo - p_monto WHERE id = p_origen;
    UPDATE cuentas SET saldo = saldo + p_monto WHERE id = p_destino;

    -- Comprobar que la cuenta origen no quedó en negativo
    IF (SELECT saldo FROM cuentas WHERE id = p_origen) < 0 THEN
        RAISE EXCEPTION 'Saldo insuficiente en la cuenta %', p_origen;
    END IF;
END;
$$;

-- Llamarlo:
CALL transferir(1, 2, 100.00);

🧠 Función vs Procedimiento:

  • Funcióncalcula y devuelve algo. Se usa dentro de un SELECT.
  • Procedimientohace algo (acción). Se llama con CALL. Puede gestionar transacciones.

Si al escribirlo piensas "quiero un resultado" → función. Si piensas "quiero que ocurra algo" → procedimiento.

RAISE EXCEPTION lanza un error y aborta la operación (hace rollback automático). El % se sustituye por el valor. Esto es tu red de seguridad dentro de la BD.


2.5 · Triggers — Reaccionar automáticamente

Un trigger ("disparador") ejecuta una función automáticamente cuando pasa algo en una tabla (INSERT, UPDATE, DELETE). El caso más común: auditoría y campos automáticos.

Ejemplo 1: actualizar actualizado_en automáticamente

sql
-- 1. La función que se ejecutará
CREATE OR REPLACE FUNCTION set_actualizado_en()
RETURNS TRIGGER AS $$
BEGIN
    NEW.actualizado_en = now();   -- NEW = la fila que se está guardando
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- 2. El trigger que la dispara
CREATE TRIGGER trg_usuarios_actualizado
    BEFORE UPDATE ON usuarios
    FOR EACH ROW
    EXECUTE FUNCTION set_actualizado_en();

Ahora, cada vez que hagas UPDATE sobre usuarios, la columna actualizado_en se pone sola en la fecha actual. No tienes que acordarte nunca.

Ejemplo 2: tabla de auditoría (registrar quién cambió qué)

sql
CREATE TABLE auditoria_pedidos (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    pedido_id   BIGINT,
    accion      VARCHAR(10),          -- INSERT / UPDATE / DELETE
    datos_antes JSONB,
    datos_despues JSONB,
    fecha       TIMESTAMPTZ DEFAULT now()
);

CREATE OR REPLACE FUNCTION auditar_pedido()
RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO auditoria_pedidos (pedido_id, accion, datos_antes, datos_despues)
    VALUES (
        COALESCE(NEW.id, OLD.id),
        TG_OP,                        -- operación: 'INSERT', 'UPDATE' o 'DELETE'
        to_jsonb(OLD),                -- fila anterior (NULL en INSERT)
        to_jsonb(NEW)                 -- fila nueva (NULL en DELETE)
    );
    RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_auditar_pedidos
    AFTER INSERT OR UPDATE OR DELETE ON pedidos
    FOR EACH ROW
    EXECUTE FUNCTION auditar_pedido();

Ahora todo cambio en pedidos queda registrado automáticamente en auditoria_pedidos. Oro puro para sistemas serios (banca, salud, ERP).

Variables mágicas dentro de un trigger

VariableQué contiene
NEWLa fila nueva (en INSERT y UPDATE)
OLDLa fila anterior (en UPDATE y DELETE)
TG_OPQué operación: 'INSERT', 'UPDATE', 'DELETE'
TG_TABLE_NAMENombre de la tabla

BEFORE vs AFTER: BEFORE se ejecuta antes de guardar (puedes modificar NEW o cancelar); AFTER se ejecuta después (útil para auditar o disparar otras acciones).

⚠️ Cuidado con los triggers en cascada: un trigger que modifica otra tabla que a su vez tiene otro trigger… puede crear cadenas difíciles de seguir o incluso bucles. Documéntalos bien y no abuses.


2.6 · CTEs — Consultas legibles (WITH)

Una CTE (Common Table Expression) es una "sub-consulta con nombre" que hace las consultas complejas mucho más legibles:

sql
WITH gastos_por_usuario AS (
    SELECT usuario_id, SUM(total) AS gastado
    FROM pedidos
    GROUP BY usuario_id
)
SELECT u.nombre, g.gastado
FROM usuarios u
JOIN gastos_por_usuario g ON g.usuario_id = u.id
WHERE g.gastado > 500
ORDER BY g.gastado DESC;

En vez de anidar sub-consultas ilegibles, defines cada paso arriba con WITH y lo usas abajo. Se leen de arriba a abajo, como una receta.

CTE recursiva (para jerarquías: organigramas, categorías anidadas, árboles):

sql
WITH RECURSIVE subordinados AS (
    -- caso base: el jefe
    SELECT id, nombre, jefe_id FROM empleados WHERE id = 1
    UNION ALL
    -- paso recursivo: los que reportan a alguien ya incluido
    SELECT e.id, e.nombre, e.jefe_id
    FROM empleados e
    JOIN subordinados s ON e.jefe_id = s.id
)
SELECT * FROM subordinados;

2.7 · Funciones de ventana (Window Functions)

Permiten hacer cálculos "por grupos" sin colapsar las filas (a diferencia de GROUP BY). Perfectas para rankings, running totals, comparar con la fila anterior.

sql
-- Ranking de pedidos por usuario según su monto
SELECT
    usuario_id,
    total,
    ROW_NUMBER() OVER (PARTITION BY usuario_id ORDER BY total DESC) AS ranking,
    SUM(total)   OVER (PARTITION BY usuario_id) AS total_usuario
FROM pedidos;
  • OVER (...) define la "ventana" (el grupo sobre el que calcular).
  • PARTITION BY divide en grupos (como GROUP BY pero sin colapsar).
  • Funciones útiles: ROW_NUMBER(), RANK(), LAG() / LEAD() (fila anterior/siguiente), SUM() OVER (...) (acumulados).

💡 Estas son las consultas que impresionan en una entrevista técnica y las que necesitas para dashboards e informes. Si las dominas, estás por encima de la media.


2.8 · JSONB — datos semi-estructurados dentro de una columna relacional

En el cap. 01 viste el tipo JSONB para "metadatos flexibles". Aquí lo consultas de verdad. Úsalo para datos que varían de forma (atributos de producto, preferencias de usuario), no como excusa para no modelar relaciones reales.

sql
CREATE TABLE productos (
    id        BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nombre    VARCHAR(200) NOT NULL,
    atributos JSONB NOT NULL DEFAULT '{}'
);

INSERT INTO productos (nombre, atributos) VALUES
    ('Teclado RGB', '{"marca": "Logitech", "colores": ["negro", "blanco"], "precio_usd": 89.9}');
sql
-- ->  devuelve el valor como JSON;  ->>  lo devuelve como texto
SELECT atributos->'marca'            FROM productos;   -- "Logitech" (JSON)
SELECT atributos->>'marca'           FROM productos;   -- Logitech   (texto)

-- Filtrar por un campo dentro del JSON
SELECT * FROM productos WHERE atributos->>'marca' = 'Logitech';

-- @>  "contiene" — ¿el JSON incluye esta clave/valor?
SELECT * FROM productos WHERE atributos @> '{"marca": "Logitech"}';

-- Acceso anidado y a arrays: atributos->'colores'->>0 es el primer color
SELECT nombre, atributos->'colores'->>0 AS primer_color FROM productos;

-- Actualizar solo una clave del JSON, sin tocar el resto
UPDATE productos SET atributos = atributos || '{"precio_usd": 79.9}'::jsonb WHERE id = 1;
sql
-- Índice GIN: imprescindible si filtras por JSONB en tablas grandes
CREATE INDEX idx_productos_atributos ON productos USING GIN (atributos);

⚠️ JSONB no reemplaza el modelado relacional. Si vas a filtrar, ordenar o hacer JOIN por un campo con frecuencia, esa es una columna real (o una tabla relacionada), no una clave dentro de un JSON. JSONB brilla para datos verdaderamente variables (atributos por categoría de producto, configuración de usuario) — no para modelar pereza.


2.9 · Seguridad: Row-Level Security (RLS)

PostgreSQL puede filtrar filas automáticamente según quién consulta. Clave para apps multi-inquilino (SaaS donde cada empresa solo ve sus datos):

sql
ALTER TABLE pedidos ENABLE ROW LEVEL SECURITY;

CREATE POLICY pedidos_por_empresa ON pedidos
    USING (empresa_id = current_setting('app.empresa_id')::BIGINT);

Ahora, sin importar la consulta, cada empresa solo ve sus pedidos. La seguridad vive en la BD, no depende de que el programador recuerde poner el WHERE.

🔗 Nota: tu proyecto CLAINEV ERP usa exactamente RLS de PostgreSQL para el aislamiento multi-inquilino. Este es el mecanismo que lo hace seguro a nivel de base de datos.


2.10 · Niveles de aislamiento — qué puede salir mal con transacciones concurrentes

El cap. 01 te dio la "I" de ACID como "las transacciones no se pisan", pero eso tiene matices: cuánto se aíslan dos transacciones simultáneas es configurable, y el valor por defecto no siempre te protege de todo. Tres problemas clásicos:

Dirty read:        lees un dato que otra transacción escribió pero AÚN NO confirmó
                    (si esa transacción hace ROLLBACK, leíste algo que nunca existió).

Non-repeatable read: lees la misma fila dos veces en la misma transacción y obtienes
                    valores distintos (otra transacción la modificó y confirmó entre medias).

Phantom read:       repites la misma consulta con WHERE y la segunda vez aparecen
                    filas nuevas que otra transacción insertó y confirmó entre medias.

PostgreSQL ofrece 3 niveles reales (el estándar SQL define 4, pero Postgres nunca permite dirty read ni en el más flojo):

sql
BEGIN;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;    -- por defecto: cada SELECT ve lo ya confirmado
-- ...
COMMIT;
NivelEvitaCosteCuándo usarlo
READ COMMITTED (por defecto)Dirty readBajoEl 95% del código de aplicación normal
REPEATABLE READ+ Non-repeatable readMedioInformes/cálculos que leen varias veces y necesitan una "foto" consistente
SERIALIZABLE+ Phantom read (aísla como si fueran secuenciales)Alto (puede forzar reintentos)Dinero, inventario, reservas — donde una condición de carrera cuesta caro
sql
-- SERIALIZABLE puede rechazar tu transacción si detecta un conflicto real con otra concurrente
BEGIN ISOLATION LEVEL SERIALIZABLE;
    SELECT stock FROM productos WHERE id = 1;   -- ambas transacciones leen stock = 1
    -- ambas intentan vender la última unidad...
COMMIT;   -- una de las dos falla con "could not serialize access" y debe reintentar

🧠 La regla práctica: empieza siempre en READ COMMITTED (es el default, y basta para casi todo). Sube a SERIALIZABLE solo en el código crítico donde una condición de carrera significa vender dos veces el mismo asiento o el mismo producto — y prepárate para reintentar la transacción si Postgres la rechaza, es el comportamiento esperado, no un bug.

💡 Alternativa más barata que SERIALIZABLE: un SELECT ... FOR UPDATE bloquea explícitamente las filas que lees hasta que termine tu transacción, evitando que otra transacción las modifique mientras tanto — suficiente para la mayoría de los casos de "reservar stock" sin pagar el coste de SERIALIZABLE completo.

sql
BEGIN;
    SELECT stock FROM productos WHERE id = 1 FOR UPDATE;  -- bloquea esta fila hasta el COMMIT
    UPDATE productos SET stock = stock - 1 WHERE id = 1;
COMMIT;

2.11 · Búsqueda de texto completo (sin instalar nada más)

Antes de montar Elasticsearch (apéndice T), sepas esto: PostgreSQL trae un buscador dentro, y para la inmensa mayoría de proyectos es más que suficiente.

El problema con LIKE

sql
SELECT * FROM articulos WHERE cuerpo LIKE '%programación%';

Falla en todo lo que importa: no encuentra "programacion" (sin tilde) ni "programar" (misma raíz), no ordena por relevancia, y % al principio impide usar cualquier índice → escaneo completo.

tsvector y tsquery

sql
-- El texto se convierte en un vector de LEXEMAS (raíces normalizadas)
SELECT to_tsvector('spanish', 'Los programadores programan programas');
-- → 'program':2,3,4    ← las tres palabras comparten raíz, y "los" se descarta

SELECT to_tsvector('spanish', 'Los programadores programan')
       @@ to_tsquery('spanish', 'programa');   -- → true  (@@ es "coincide con")

Implementación recomendada: columna generada + índice GIN

sql
ALTER TABLE articulos
  ADD COLUMN busqueda tsvector
  GENERATED ALWAYS AS (
      setweight(to_tsvector('spanish', coalesce(titulo, '')),  'A') ||
      setweight(to_tsvector('spanish', coalesce(cuerpo, '')),  'B')
  ) STORED;

CREATE INDEX idx_articulos_busqueda ON articulos USING GIN (busqueda);

setweight marca la importancia: 'A' (título) pesa más que 'B' (cuerpo). GENERATED … STORED significa que PostgreSQL la mantiene actualizada solo en cada INSERT/UPDATE: no hay que acordarse de nada, no hace falta un trigger.

sql
-- Buscar, ordenado por relevancia y con el fragmento resaltado
SELECT titulo,
       ts_rank(busqueda, consulta)                        AS relevancia,
       ts_headline('spanish', cuerpo, consulta)           AS fragmento
  FROM articulos, websearch_to_tsquery('spanish', 'bases de datos -mysql') AS consulta
 WHERE busqueda @@ consulta
 ORDER BY relevancia DESC
 LIMIT 20;

💡 Usa websearch_to_tsquery, no to_tsquery. Acepta directamente la sintaxis que la gente ya conoce de Google: "frase exacta", -excluir, o para alternativas. Con to_tsquery tienes que construir la expresión tú y validar la entrada del usuario, porque una consulta mal formada lanza una excepción.

Búsquedas parciales y tolerantes a erratas: pg_trgm

El texto completo busca palabras enteras. Para autocompletado o erratas, otra extensión:

sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_productos_nombre_trgm ON productos USING GIN (nombre gin_trgm_ops);

-- Ahora SÍ funciona con índice, incluso con % al principio:
SELECT * FROM productos WHERE nombre ILIKE '%tecla%';

-- Y por similitud (tolera erratas):
SELECT nombre, similarity(nombre, 'teclao mecanico') AS s
  FROM productos
 WHERE nombre % 'teclao mecanico'          -- % = "se parece a"
 ORDER BY s DESC LIMIT 10;

🧠 ¿Cuándo pasar a un buscador dedicado (apéndice T)? Cuando necesites facetas complejas, sinónimos gestionados por negocio, más de unos pocos millones de documentos, o búsqueda distribuida entre muchos tipos de contenido. Por debajo de eso, PostgreSQL te ahorra un servicio entero que operar, sincronizar y monitorizar — y sobre todo, te ahorra el problema de mantener dos fuentes de verdad sincronizadas, que es la parte cara de verdad de tener un buscador aparte.


2.12 · Particionar tablas enormes

Cuando una tabla llega a cientos de millones de filas, el problema no suele ser buscar (para eso están los índices): es borrar datos viejos y que las operaciones de mantenimiento tarden horas.

El particionado divide una tabla grande en varias físicas, transparentes para tus consultas.

sql
-- Tabla particionada por rango de fechas
CREATE TABLE eventos (
    id         BIGINT GENERATED ALWAYS AS IDENTITY,
    usuario_id BIGINT      NOT NULL,
    tipo       VARCHAR(50) NOT NULL,
    creado_en  TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (id, creado_en)          -- ⚠️ la clave debe incluir la columna de partición
) PARTITION BY RANGE (creado_en);

-- Una partición por mes
CREATE TABLE eventos_2026_07 PARTITION OF eventos
    FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
CREATE TABLE eventos_2026_08 PARTITION OF eventos
    FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');

Las consultas no cambian en absoluto:

sql
SELECT * FROM eventos WHERE creado_en >= '2026-07-15' AND tipo = 'login';
-- PostgreSQL hace "partition pruning": sabe que solo debe mirar eventos_2026_07
-- e ignora por completo las demás particiones. Compruébalo con EXPLAIN.

Lo que de verdad te compra el particionado:

sql
-- ❌ Borrar un mes de datos en una tabla normal: horas, bloqueos, y la tabla
--    no devuelve el espacio al sistema operativo (hace falta VACUUM FULL)
DELETE FROM eventos WHERE creado_en < '2026-01-01';

-- ✅ Con particiones: instantáneo, sin bloqueos, y libera el disco de inmediato
DROP TABLE eventos_2026_01;
-- o, para archivarla sin borrarla:
ALTER TABLE eventos DETACH PARTITION eventos_2026_01;
GanasPagas
Borrado y archivado instantáneosHay que crear las particiones futuras (automatízalo)
VACUUM y reindexado por partesLa clave primaria debe incluir la columna de partición
Consultas que descartan particiones enterasLas claves foráneas hacia la tabla son más limitadas
Índices más pequeños por particiónConsultas que no filtran por esa columna van a todas

⚠️ Particiona por la columna que usas para descartar datos, casi siempre una fecha. Si particionas por usuario_id pero tus consultas filtran por fecha, cada consulta tocará todas las particiones y habrás empeorado las cosas. Y automatiza la creación de particiones futuras (con pg_partman o un cron): el día que llegue un INSERT sin partición donde caber, fallará.

🧠 Regla de decisión: el particionado no es una optimización de velocidad de consulta — para eso están los índices. Es una estrategia de gestión del ciclo de vida de los datos. Si tu pregunta es "va lento", empieza por EXPLAIN y un índice. Si tu pregunta es "tengo 400 millones de eventos y necesito conservar solo los últimos 12 meses", ahí sí, particiona.


✅ Ejercicio del capítulo

Sobre el esquema de biblioteca del capítulo anterior:

sql
-- 1. FUNCTION: libros_disponibles() que devuelva cuántos libros no están prestados.
-- 2. TRIGGER: al insertar un préstamo, que marque automáticamente el libro
--    como no disponible (BEFORE/AFTER INSERT).
-- 3. TRIGGER: mantener actualizado_en en la tabla libros.
-- 4. PROCEDURE: devolver_libro(prestamo_id) que marque el préstamo como
--    devuelto Y el libro como disponible, todo en una transacción, con
--    RAISE EXCEPTION si el préstamo no existe.
-- 5. VIEW: prestamos_activos con título, usuario y días transcurridos.
-- 6. AUDITORÍA: tabla + trigger que registre cada préstamo y devolución.

Cuando termines esto, entiendes la base de datos mejor que la mayoría de desarrolladores backend. Ahora sí: vamos a construir aplicaciones que la usen.

💡 Pistas de la solución (abre solo si te atascas)
  • El trigger del punto 2 debe ser AFTER INSERT sobre prestamos, no BEFORE: primero se confirma que el préstamo se insertó, luego reaccionas actualizando libros.
  • El PROCEDURE devolver_libro necesita RAISE EXCEPTION cuando el prestamo_id no existe — así, si algo llama al procedimiento con un id inválido, falla ruidosamente en vez de no hacer nada en silencio.
  • Para la auditoría (punto 6), un solo trigger genérico que reaccione a INSERT/UPDATE en prestamos y escriba en auditoria es más mantenible que uno por cada operación.

🧠 Autoevaluación

Porque la seguridad no depende de que el programador recuerde poner el WHERE empresa_id = ? en absolutamente todas las consultas — un solo endpoint olvidado es una fuga de datos entre clientes. Con RLS, PostgreSQL aplica el filtro automáticamente sin importar quién ni cómo consulte la tabla.

El trigger reacciona automáticamente a un evento (INSERT/UPDATE/DELETE) sin que nadie lo llame explícitamente — ideal para invariantes que SIEMPRE deben cumplirse (actualizar actualizado_en). El procedimiento se invoca a propósito desde tu código cuando quieres ejecutar una operación concreta con parámetros — ideal para acciones de negocio como "devolver_libro".

GROUP BY colapsa las filas de cada grupo en una sola fila de resumen — pierdes el detalle. Una función de ventana calcula el agregado SOBRE cada fila sin colapsarlas: puedes ver el detalle de cada préstamo Y su posición/ranking dentro del grupo en la misma consulta.

Una VIEW normal es una consulta guardada con nombre: cada vez que la consultas, PostgreSQL ejecuta el SQL subyacente contra los datos actuales — siempre está al día pero no ahorra cómputo. Una tabla materializada SÍ guarda el resultado físicamente y hay que refrescarla explícitamente — gana velocidad de lectura a cambio de poder mostrar datos desactualizados.

Porque una tabla de auditoría es consultable con SQL normal: puedes preguntar "quién devolvió el libro X el mes pasado" con un simple SELECT. Un log de texto plano requiere parsearlo fuera de la base de datos, perdiendo toda la potencia de índices, JOINs y agregaciones que ya tienes.

Úsala cuando la forma de los datos varía genuinamente entre filas (atributos de producto que cambian según la categoría, preferencias de usuario) y no necesitas filtrar/ordenar por esos campos con frecuencia. Si un campo dentro del JSON se usa a menudo en WHERE, JOIN o ORDER BY, debe ser una columna real (o una tabla aparte) con su propio índice — meterlo en JSONB "porque es más flexible" suele ser pereza de modelado, no una decisión técnica.

Porque READ COMMITTED solo garantiza que no lees datos sin confirmar (dirty read) — cada SELECT ve el último estado confirmado en ESE instante. Si ambas transacciones leen stock = 1 antes de que la otra confirme su compra, las dos ven stock disponible y ambas restan uno, dejando el stock en -1. Se soluciona con SELECT ... FOR UPDATE (bloquea la fila) o con SERIALIZABLE (detecta el conflicto y fuerza a una de las dos a reintentar).


Siguiente: 03-php-laravel.md — tu primer framework backend completo.