MariaDB · bases de datos de cero a experto
Instalación en Windows y Ubuntu, modelado de la base clinica, SQL completo, transacciones, vistas, procedures, triggers de auditoría dual y conexión PHP con PDO. El primer motor de la serie de Bases de datos.
1 · Por qué una base de datos bien diseñada
Básico ~12 minAntes del motor, el problema. Guardar datos "en cualquier lado" funciona hasta que deja de funcionar: duplicados, datos perdidos, respuestas lentas y nadie sabe quién cambió qué. Este capítulo nombra ese dolor y presenta al responsable de resolverlo.
- Reconocer los 4 dolores de guardar datos sin un motor.
- Saber qué aporta exactamente un sistema gestor de base de datos.
- Distinguir base de datos, motor y modelo.
- Presentar el dominio del curso: una clínica real.
El dolor de la hoja de cálculo
Imagina la agenda de la clínica en un archivo compartido: pacientes en una hoja, citas en otra, pagos en una tercera. Funciona... hasta el primer lunes ocupado:
| Situación | Hoja de cálculo | Motor de BD |
|---|---|---|
| Dos recepcionistas agendan al mismo tiempo | una pisa a la otra | concurrencia controlada |
| Paciente con documento repetido | nadie se entera | restricción UNIQUE lo rechaza |
| Cita sin paciente registrado | posible y silenciosa | llave foránea lo impide |
| "¿Cuánto facturó julio por especialidad?" | fórmulas frágiles | una consulta SQL |
| "¿Quién borró esta receta?" | imposible saberlo | auditoría registrada |
Qué aporta el motor (SGBD)
Un sistema gestor de base de datos (SGBD o DBMS) es el software que guarda los datos y hace cumplir las reglas. Sus cuatro promesas:
- Integridad: las reglas viven EN la base — tipos, obligatorios, unicidad, relaciones. Ni un cliente descuidado las rompe.
- Concurrencia: cientos de usuarios leen y escriben a la vez sin pisarse (transacciones, cap 25).
- Consultas declarativas: describes QUÉ quieres en SQL, no CÓMO buscarlo — el motor decide el camino (cap 30).
- Seguridad y auditoría: usuarios con permisos mínimos y registro de quién hizo qué — el hilo rojo de este curso.
Tres palabras que no son sinónimos
| Término | Es... | En el curso |
|---|---|---|
| Base de datos | el conjunto organizado de datos | clinica |
| Motor (SGBD) | el software que la gestiona | MariaDB 12.3 |
| Modelo | el diseño: tablas y relaciones | 8 tablas (cap 6) |
"Base de datos" coloquialmente nombra a las tres; en el manual seremos precisos porque cada una tiene su propio capítulo.
El dominio del curso: la clínica
Toda la serie webcode aprende sobre un caso real (Pedidos en la fila web). Aquí será una clínica: pacientes, médicos, especialidades, citas, recetas y pagos. Elegimos este dominio porque obliga a las preguntas serias: la historia clínica no puede perderse, dos citas no pueden caer en el mismo horario del mismo médico, y CADA cambio sensible debe quedar auditado — ¿quién lo hizo y desde qué cuenta? Esa auditoría dual es el caso estrella de la Parte V.
Puntos clave
- Hoja de cálculo: sin integridad, sin concurrencia, sin auditoría.
- El SGBD hace cumplir las reglas EN la base, no en cada programa.
- BD (datos) ≠ motor (software) ≠ modelo (diseño).
- SQL declarativo: qué quieres, no cómo buscarlo.
- Dominio del curso: clínica, con auditoría dual como caso estrella.
2 · Los cuatro motores del curso: pros y contras
Básico ~14 minEste curso recorre cuatro motores con EL MISMO modelo de clínica y EL MISMO esqueleto de capítulos: así cada diferencia que encuentres es del motor, no del ejemplo. Hoy los conocemos a fondo y elegimos el orden.
- Entender la relación MySQL ↔ MariaDB (y por qué MariaDB).
- Comparar los 4 motores: licencia, modelo y caso ideal.
- Saber por qué SQLite juega en otra liga (y por qué lo incluimos).
- Conocer la versión objetivo del curso: MariaDB 12.3 LTS.
MySQL y MariaDB: la historia en 30 segundos
MySQL nació en 1995 y dominó la web. En 2008 Sun Microsystems lo compró y en 2010 Oracle compró a Sun. Michael "Monty" Widenius —autor original de MySQL— temiendo su futuro bajo Oracle, hizo un fork: copió el proyecto y lo continuó como MariaDB, con fundación independiente y licencia GPL garantizada.
Desde entonces divergieron poco a poco, pero el SQL del día a día sigue siendo prácticamente el mismo: lo que aprendas aquí sirve para ambos. Elegimos MariaDB por su gobernanza comunitaria y porque es el servidor por defecto en Ubuntu/Debian. Versión objetivo del curso: MariaDB 12.3 LTS (12.3.3, agosto de 2026, mantenimiento hasta 2029; las series 11.8 y 11.4 también siguen soportadas — todo el curso funciona en cualquiera de las tres).
La comparativa honesta
| MariaDB | PostgreSQL | SQL Server | SQLite | |
|---|---|---|---|---|
| Licencia | GPL (libre) | PostgreSQL (libre) | comercial (Developer gratis) | dominio público |
| Arquitectura | cliente-servidor | cliente-servidor | cliente-servidor | embebida (un archivo) |
| Fortaleza | web, simple y rápida | el estándar de cumplimiento SQL | empresas Windows/.NET | embebido, cero administración |
| Procedures/triggers | sí | sí (potentes) | sí (T-SQL) | NO |
| Usuarios/roles | sí | sí | sí | NO (archivo = permisos del SO) |
| Ideal para... | sitios web, arranques | integridad seria, datos complejos | entornos corporativos Microsoft | apps locales, móviles, IoT |
| En el curso | base de la comparación | dialecto "académico" | dialecto T-SQL | contrapunto embebido |
SQLite: el caso especial
SQLite no es un "MySQL pequeño": es otra categoría. No hay servidor, ni usuarios, ni red — la base completa es UN archivo que tu programa abre directamente. Potencia la lectura hasta límites increíbles y administra cero. El precio: sin procedures, sin roles, y varias restricciones de ALTER TABLE.
Por eso su manual usará un esquema algo más simplificado y documentará con honestidad cada cosa que "los otros tres tienen y este no" — esas ausencias SON su lección de pros y contras.
El orden del curso y por qué
MariaDB primero (el más difundido en web, tu entorno actual), PostgreSQL después (el más riguroso con el estándar SQL), SQL Server tercero (el dialecto corporativo T-SQL) y SQLite al final (el contrapunto embebido). Cada manual marcará con etiquetas las diferencias frente a los otros: al terminar tendrás la tabla comparativa definitiva hecha con tu propio código.
Puntos clave
- MariaDB = fork comunitario de MySQL (2010), misma sintaxis base.
- Versión del curso: MariaDB 12.3 LTS (soporte hasta 2029).
- PostgreSQL: el más fiel al estándar · SQL Server: el corporativo.
- SQLite: embebido sin servidor — sin procedures ni usuarios.
- Mismo modelo clínica en los 4: las diferencias serán del motor.
3 · Instalación en Windows y Ubuntu
Básico ~15 minMariaDB en dos sistemas operativos, con verificación incluida. Al terminar tendrás el servidor corriendo como servicio y la clave de root puesta — listo para el capítulo siguiente.
- Instalar en Windows con el instalador MSI oficial.
- Instalar en Ubuntu desde los repositorios de la distribución.
- Asegurar la instalación (mysql_secure_installation).
- Verificar versión y servicio en ambos sistemas.
Windows: el instalador MSI
- Descarga el MariaDB Community Server desde mariadb.org/downloads — elige la serie 12.3 (o 11.8/11.4 LTS) y el MSI para x86_64 de Windows.
- Ejecuta el asistente: acepta la ruta por defecto (C:\Program Files\MariaDB {versión}).
- En el paso "root password" DEFINE la clave del administrador y NO marques "Enable access from remote machines" para un entorno de aprendizaje (solo local).
- Deja marcado "Install as Service" con el nombre sugerido (MariaDB) y el puerto 3306.
- Opcional pero recomendado: marca también HeidiSQL en la última pantalla — es un cliente gráfico gratuito muy cómodo.
Ubuntu: desde los repositorios
Ubuntu trae MariaDB en sus repos oficiales — dos comandos y listo:
sudo apt update
sudo apt install mariadb-server
# el servicio arranca solo; verificar:
systemctl status mariadb
# asegurar la instalación (responde 'y' a todo):
sudo mysql_secure_installationEl script mysql_secure_installation te pedirá la clave de root de la BASE (en Ubuntu 22.04+ root usa autenticación unix_socket: entra con sudo, sin clave — lo veremos en el cap 4), quita usuarios anónimos, prohíbe el login remoto de root y elimina la base de prueba.
Verificación en ambos sistemas
| Tarea | Windows (PowerShell admin) | Ubuntu |
|---|---|---|
| Versión del cliente | mariadb --version | mariadb --version |
| Estado del servicio | Get-Service MariaDB | systemctl status mariadb |
| Arrancar | net start MariaDB | sudo systemctl start mariadb |
| Detener | net stop MariaDB | sudo systemctl stop mariadb |
Si el comando mariadb no se reconoce en Windows, agrega su
carpeta bin (C:\Program Files\MariaDB 12.3\bin) al PATH del sistema — o usa
la ruta completa en cada llamada.
El puerto 3306
MariaDB escucha por defecto en el puerto 3306 (el histórico
de MySQL, que heredó). Solo te importará cuando conectes desde PHP (cap 30) o
si algún día instalas DOS motores en la misma máquina — en ese caso el segundo
deberá usar otro puerto. Anótalo: host localhost, puerto
3306.
Puntos clave
- Windows: MSI oficial + servicio MariaDB + puerto 3306.
- Ubuntu: apt install mariadb-server + mysql_secure_installation.
- La serie del repo de Ubuntu sirve para aprender; 12.3 si quieres la última LTS.
- mariadb --version y systemctl/Get-Service: tus dos verificaciones.
- Root local por unix_socket en Ubuntu: entra con sudo (cap 4).
4 · El cliente CLI y primer contacto
Básico ~14 minEl cliente de consola (CLI) es el laboratorio del curso: ahí probaremos cada consulta antes de llevarla a código. Hoy aprendemos a entrar, orientarnos y crear nuestra base de práctica con su usuario propio.
- Entrar al cliente como root (clave y unix_socket).
- Leer el prompt y ejecutar los primeros comandos de orientación.
- Crear la base clinica con utf8mb4.
- Crear un usuario de práctica con permisos SOLO sobre esa base.
Entrar al cliente
# Windows (clave definida en el instalador):
mariadb -u root -p
# Ubuntu (autenticación unix_socket — entra con sudo, sin clave):
sudo mariadbEl prompt cambia a MariaDB [(none)]>: estás DENTRO del
servidor y aún no elegiste base de datos (por eso el (none)). Todo lo que
escribas aquí termina en ; — el punto y coma es lo que envía la
sentencia.
Orientarse: cuatro comandos de cortesía
SELECT VERSION(), CURRENT_USER();
SHOW DATABASES;
-- ayuda general y de un comando puntual:
HELP SHOW;
HELP CREATE DATABASE;Verás las bases del sistema: information_schema y mysql (metadatos y usuarios — NO las toques) y quizá performance_schema. Tu trabajo vivirá en una base propia.
Crear la base del curso
CREATE DATABASE clinica
CHARACTER SET utf8mb4
COLLATE utf8mb4_spanish_ci;
USE clinica;
SELECT DATABASE(); -- confirma dónde estás paradoutf8mb4 guarda español con tildes, ñ — y emojis si algún día hiciera falta. Es el estándar moderno; el viejo utf8 de MySQL/MariaDB solo guarda 3 bytes por carácter y se quedó corto. El collate utf8mb4_spanish_ci ordena y compara texto con reglas del español, sin distinguir mayúsculas (ci = case insensitive).
El usuario de práctica
Trabajar SIEMPRE como root es el mal hábito más común de los tutoriales. Creemos un usuario con permisos solo sobre clinica — el mismo patrón que usará PHP en el cap 30:
CREATE USER 'curso'@'localhost' IDENTIFIED BY 'Clinica.2026';
GRANT ALL PRIVILEGES ON clinica.* TO 'curso'@'localhost';
FLUSH PRIVILEGES;
-- probar la identidad del nuevo usuario (salir y volver a entrar):
exit
mariadb -u curso -p
SELECT CURRENT_USER(), DATABASE();
USE clinica;'curso'@'localhost' se lee: usuario curso, SOLO desde esta
máquina. Esa segunda parte (el host) es la primera mitad del modelo
de seguridad de MariaDB — la profundizaremos en la Parte V con roles.
mysql vs mariadb: dos nombres, un cliente
El cliente histórico se llamaba mysql; MariaDB mantiene ese
comando como alias por compatibilidad. mariadb -u root -p y
mysql -u root -p abren lo mismo. En este manual usaremos el
nombre nuevo, pero no te sorprendas al ver el viejo en tutoriales ajenos.
Puntos clave
- sudo mariadb (Ubuntu) / mariadb -u root -p (Windows).
- Cada sentencia termina en ; — sin él, no se envía.
- utf8mb4 + collate spanish_ci: español correcto desde el día 1.
- Usuario 'curso'@'localhost' con permisos SOLO en clinica.
- information_schema y mysql: del sistema — no tocar.
5 · El ecosistema: clientes gráficos
Básico ~11 minCierre de Parte I. El CLI es el laboratorio, pero el trabajo diario se apoya en herramientas gráficas para explorar, diseñar y depurar. Conocer cuál usar para cada tarea ahorra horas.
- Conocer HeidiSQL, DBeaver y phpMyAdmin y su rol.
- Conectar DBeaver a nuestra base clinica.
- Decidir cuándo gráfico y cuándo CLI.
- Cerrar Parte I con el entorno completo listo.
El ecosistema: cinco herramientas
| Herramienta | Qué es | Punto fuerte |
|---|---|---|
| HeidiSQL | multiplataforma: Windows, Linux y macOS | la histórica de MariaDB/MySQL; instaladores .exe/.deb/.rpm/App bundle; viene con el instalador de MariaDB en Windows |
| DBeaver Community | gráfica multiplataforma (Java) | habla con LOS CUATRO motores del curso: una sola herramienta |
| DBGate | escritorio Y web (contenedor Docker) | (no)SQL: MySQL, PostgreSQL, SQL Server, SQLite, MongoDB, Redis; comparación de esquemas, diseñador visual de consultas, import/export CSV-Excel-JSON |
| Adminer | web — UN solo archivo PHP | gestión completa desde un adminer.php: tablas, vistas, procedures, triggers y export SQL/CSV; alternativa minimalista a phpMyAdmin |
| phpMyAdmin | web (PHP) | el clásico de los hostings compartidos |
Dato clave para este curso: HeidiSQL, DBeaver, DBGate y Adminer hablan los CUATRO motores que veremos (MariaDB, PostgreSQL, SQL Server y SQLite) — una sola herramienta te acompaña por toda la categoría. phpMyAdmin, en cambio, se queda en la familia MySQL/MariaDB.
Recomendación del curso: DBeaver Community como gráfica principal — cuando lleguemos a PostgreSQL y SQL Server no cambiarás de herramienta, solo de conexión. DBGate es su primo ligero: la misma filosofía multi-motor, con versión web en Docker para equipos remotos. HeidiSQL ya no es solo Windows: tiene instaladores oficiales para Linux (.deb y .rpm) y macOS (App bundle) — ligera y minimalista en las tres plataformas. Y Adminer merece una nota aparte: es EL cliente de emergencia — sube un archivo al servidor y administra la base desde el navegador.
Conectar DBeaver a clinica
- Nueva conexión → elegir MariaDB (o MySQL — mismo protocolo).
- Servidor:
localhost· Puerto:3306. - Usuario:
curso· Clave: la que definiste en el cap 4. - Base de datos:
clinica. Probar conexión → Finalizar.
Verás el árbol del esquema: tablas, vistas, columnas con sus tipos, índices y llaves. Haz clic en cualquier tabla vacía y DBeaver te muestra el CREATE TABLE que la generó — útil para aprender sintaxis leyendo.
¿Gráfico o CLI?
| Tarea | Herramienta ideal | Por qué |
|---|---|---|
| Aprender SQL (este curso) | CLI | te obliga a escribir; el dedo aprende |
| Explorar estructura, datos de una tabla | gráfica | visual y con filtros instantáneos |
| Diseñar/editar tablas puntualmente | gráfica | genera el DDL por ti (luego LÉELO) |
| Depurar una consulta lenta | ambas | gráfica para ver planes, CLI para repetir |
| Scripts del curso y de producción | CLI / archivos | versionables y repetibles |
Puntos clave
- DBeaver: una gráfica para los 4 motores del curso.
- HeidiSQL: nativa Windows, viene con el instalador.
- Gráfica para explorar; CLI para aprender y para scripts.
- Conexión: localhost:3306 · curso · clinica.
- Que la herramienta genere SQL está bien — LÉELO siempre.
6 · El modelo clínica: ocho tablas con propósito
Básico ~14 minArranca Parte II. Antes de escribir un solo CREATE TABLE, entendamos QUÉ vamos a construir y POR QUÉ cada tabla existe. Un modelo entendido se diseña bien; un modelo copiado se sufre.
- Recorrer las 8 tablas agrupadas por función.
- Entender las relaciones 1:N que las unen.
- Saber por qué usuarios_app NO es un usuario del motor.
- Identificar las reglas de negocio que el modelo debe impedir.
Las cuatro familias
| Familia | Tablas | Función |
|---|---|---|
| Catálogo | especialidades | listas cortas que otras tablas referencian |
| Personas | medicos, pacientes, usuarios_app | los actores del sistema |
| Operación | citas, recetas, pagos | lo que ocurre todos los días |
| Control | auditoria | quién hizo qué, con qué cuenta |
El mapa de relaciones
especialidades 1---N medicos
pacientes 1---N citas N---1 medicos
citas 1---1 recetas
citas 1---N pagos
auditoria (independiente: referencia por nombre de tabla + id)Léelo en voz alta: "una especialidad tiene muchos médicos; cada cita pertenece a UN paciente y UN médico; una cita genera a lo sumo una receta; puede tener varios pagos (uno fallido y uno exitoso, por ejemplo)". Si puedes leer el modelo como frases, lo entiendes.
La tabla que no es del motor: usuarios_app
Aquí está la distinción que ordena todo el curso:
- usuarios del MOTOR (curso@localhost, cap 4): definen QUIÉN SE CONECTA al servidor y qué puede hacer ahí. Viven en la base mysql del sistema.
- usuarios_app (tabla nuestra): definen QUIÉN USA LA APLICACIÓN — la recepcionista Rosa, el doctor Torres. La aplicación los autentica con su propia lógica (PHP, cap 30).
¿Por qué separarlos? Porque cientos de usuarios de la app comparten UNA cuenta de conexión al motor (menos conexiones, permisos mínimos, sin claves de BD repartidas). El precio: el motor ya no sabe "quién es Rosa" — y ese vacío lo llena la auditoría dual.
auditoria: la memoria de quién hizo qué
Cada cambio sensible registrará la dupla completa:
usuario_app : 'rrosa' <-- la persona, llega como parámetro/variable de sesión
usuario_bd : 'curso'@'localhost' <-- la cuenta de conexión, la aporta el motor
tabla_afectada, operacion, registro_id, datos_anteriores, datos_nuevosAsí respondemos las DOS preguntas de un incidente: "¿qué cuenta de la app hizo esto?" y "¿con qué cuenta de BD?". La Parte V lo implementa con triggers y variables de sesión.
Reglas de negocio que el modelo debe impedir
| Regla | ¿Quién la impone? |
|---|---|
| No existen dos pacientes con el mismo documento | UNIQUE (cap 8) |
| No existe cita sin paciente ni sin médico | FOREIGN KEY (cap 8) |
| Un médico no tiene dos citas a la misma hora | UNIQUE compuesta (cap 8) |
| Un estado de cita solo puede ser uno de la lista | ENUM (cap 9) |
| Agendar valida disponibilidad antes de insertar | procedure (cap 29) |
Puntos clave
- 4 familias: catálogo, personas, operación, control.
- El modelo se lee como frases: 1---N con sentido de negocio.
- usuarios_app (personas) ≠ usuarios del motor (conexiones).
- auditoria registra la dupla: usuario_app + usuario_bd.
- Lo innegociable vive en la base, no en la app.
7 · Crear tablas: los tipos de dato
Básico ~15 minElegir mal un tipo de dato es la deuda técnica más silenciosa: la app funciona... hasta que el monto sale con centavos inventados o la fecha del 2038 explota. Hoy, los tipos de MariaDB con sus rangos reales.
- Dominar los tipos enteros y el alias BOOLEAN.
- Usar DECIMAL para dinero — y saber por qué NUNCA FLOAT.
- Elegir entre VARCHAR y TEXT con criterio.
- Crear las tres tablas de personas del modelo.
Enteros: del TINYINT al BIGINT
| Tipo | Rango con signo | Uso típico |
|---|---|---|
| TINYINT | -128 a 127 | banderas, edades pequeñas |
| SMALLINT | -32 768 a 32 767 | conteos moderados |
| INT | -2 147 millones a 2 147 millones | ids estándar |
| BIGINT | ±9.2 trillones (18 dígitos) | ids gigantes, contadores globales |
BOOLEAN es un alias: doc literal, es "sinónimo de TINYINT(1)" y TRUE/FALSE son "meros alias de 1 y 0". Funciona, pero recuerda que debajo vive un entero.
Dinero: DECIMAL, jamás FLOAT
monto DECIMAL(10,2) -- hasta 99 999 999.99, exacto
-- FLOAT/DOUBLE guardan en binario: 0.1 + 0.2 NO es 0.3
SELECT 0.1 + 0.2; -- 0.30000000000000004 (con DOUBLE)DECIMAL guarda exactamente los dígitos que declaras: DECIMAL(10,2) son 10 dígitos totales, 2 decimales. Máximo de la doc: 65 dígitos, 38 decimales. Para montos de clínica, DECIMAL(10,2) sobra y es exacto.
Texto: VARCHAR vs TEXT
| VARCHAR(n) | TEXT | |
|---|---|---|
| Longitud | declarada (hasta ~65 532 chars según charset) | sin declarar |
| Índice | completo | solo por prefijo |
| Almacenamiento | en la página de la fila | fuera de página (puntero) |
| Úsalo para | nombres, correos, documentos | textos largos: observaciones, historias |
Fechas y horas
- DATE: '1000-01-01' a '9999-12-31' — para fechas puras (fecha_nacimiento).
- DATETIME: fecha+hora SIN zona horaria — el valor es el que es (ideal para citas: "10:00 hora local de la clínica").
- TIMESTAMP: guarda en UTC y convierte a la zona de la sesión al leer. Buen dato moderno (≥11.5): el rango ya NO termina en 2038 — llega hasta 2106.
Regla práctica: fechas del negocio → DATETIME (la cita fue a las 10:00, punto); marcas técnicas de registro → TIMESTAMP.
Las tres tablas de personas
USE clinica;
CREATE TABLE especialidades (
id INT AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(80) NOT NULL,
descripcion VARCHAR(255)
);
CREATE TABLE medicos (
id INT AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(60) NOT NULL,
apellido VARCHAR(60) NOT NULL,
colegiatura VARCHAR(20) NOT NULL,
especialidad_id INT NOT NULL,
email VARCHAR(120),
telefono VARCHAR(20),
activo BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE pacientes (
id INT AUTO_INCREMENT PRIMARY KEY,
documento VARCHAR(15) NOT NULL,
nombre VARCHAR(60) NOT NULL,
apellido VARCHAR(60) NOT NULL,
fecha_nacimiento DATE NOT NULL,
sexo CHAR(1),
telefono VARCHAR(20),
email VARCHAR(120),
direccion VARCHAR(160),
tipo_sangre CHAR(3),
alergias VARCHAR(255),
activo BOOLEAN NOT NULL DEFAULT TRUE
);Sobre AUTO_INCREMENT, la doc fija tres reglas: una sola columna AUTO_INCREMENT por tabla, debe ser una clave (aquí la PRIMARY KEY), y los valores borrados nunca se reutilizan — no cuentes con el 7 libre porque borraste al paciente 7.
Puntos clave
- BOOLEAN = TINYINT(1); TRUE/FALSE = 1/0.
- Dinero SIEMPRE DECIMAL(p,s) — exacto. FLOAT miente.
- VARCHAR indexa completo; TEXT es off-page y por prefijo.
- DATETIME para negocio; TIMESTAMP para marcas técnicas (ya sin límite 2038).
- AUTO_INCREMENT: uno por tabla, es clave, no recicla valores.
8 · Constraints: las reglas que la base hace cumplir
Básico ~15 minLas constraints convierten las reglas de negocio en leyes físicas: ni un INSERT descuidado, ni un bug a las 3 AM, ni un script torpe pueden violarlas. Hoy completamos las 8 tablas del modelo con todas sus leyes.
- Aplicar NOT NULL, DEFAULT, UNIQUE y CHECK.
- Declarar FOREIGN KEY con la sintaxis COMPLETA (y su trampa).
- Elegir ON DELETE con criterio: RESTRICT, CASCADE, SET NULL.
- Verificar el resultado con SHOW CREATE TABLE.
La trampa del REFERENCES inline
Mucha gente escribe esto y cree que tiene una llave foránea:
-- TRAMPA: la doc es literal — esta forma "does nothing":
especialidad_id INT REFERENCES especialidades(id)
-- FORMA CORRECTA (constraint de tabla):
especialidad_id INT NOT NULL,
FOREIGN KEY (especialidad_id) REFERENCES especialidades(id)MariaDB la acepta sin error y simplemente la IGNORA. Es el bug silencioso más clásico de los tutoriales: la tabla "funciona" pero acepta médicos con especialidades inexistentes. Siempre con la forma completa.
Las tablas de operación, con todas sus leyes
CREATE TABLE citas (
id INT AUTO_INCREMENT PRIMARY KEY,
paciente_id INT NOT NULL,
medico_id INT NOT NULL,
fecha_hora DATETIME NOT NULL,
estado ENUM('PROGRAMADA','ATENDIDA','CANCELADA','NO_ASISTIO')
NOT NULL DEFAULT 'PROGRAMADA',
motivo VARCHAR(255) NOT NULL,
diagnostico VARCHAR(500),
UNIQUE KEY uq_medico_fecha (medico_id, fecha_hora),
CONSTRAINT fk_citas_paciente FOREIGN KEY (paciente_id)
REFERENCES pacientes(id),
CONSTRAINT fk_citas_medico FOREIGN KEY (medico_id)
REFERENCES medicos(id)
);
CREATE TABLE recetas (
id INT AUTO_INCREMENT PRIMARY KEY,
cita_id INT NOT NULL,
medicamento VARCHAR(120) NOT NULL,
dosis VARCHAR(80) NOT NULL,
indicaciones VARCHAR(255),
duracion_dias TINYINT UNSIGNED NOT NULL,
UNIQUE KEY uq_receta_cita (cita_id),
CONSTRAINT fk_recetas_cita FOREIGN KEY (cita_id)
REFERENCES citas(id)
);
CREATE TABLE pagos (
id INT AUTO_INCREMENT PRIMARY KEY,
cita_id INT NOT NULL,
monto DECIMAL(10,2) NOT NULL,
metodo ENUM('EFECTIVO','TARJETA','TRANSFERENCIA') NOT NULL,
estado ENUM('PENDIENTE','PAGADO','ANULADO') NOT NULL DEFAULT 'PENDIENTE',
fecha DATETIME NOT NULL,
CONSTRAINT fk_pagos_cita FOREIGN KEY (cita_id) REFERENCES citas(id),
CONSTRAINT chk_monto_positivo CHECK (monto >= 0)
);Qué hace cada constraint
| Constraint | Ley que impone | Ejemplo del modelo |
|---|---|---|
| NOT NULL | la columna exige valor | toda cita tiene fecha_hora |
| DEFAULT | valor si nadie lo pasa | estado = 'PROGRAMADA' |
| UNIQUE | no se repite (o combinación) | uq_medico_fecha: sin doble agenda |
| PRIMARY KEY | identidad de la fila | id en todas |
| FOREIGN KEY | la referencia EXISTE | cita con paciente real |
| CHECK | expresión booleana obligatoria | monto >= 0 (desde 10.2.1 se aplica) |
| ENUM | solo valores de la lista | estados de cita y de pago |
Fíjate en uq_medico_fecha: UNIQUE sobre DOS columnas — la combinación no se repite, aunque cada columna sola sí. Es la regla de la doble agenda hecha ley.
ON DELETE: qué pasa cuando se borra el padre
| Opción | Efecto al borrar el padre | En la clínica |
|---|---|---|
| RESTRICT (default) | rechaza el borrado | no borras un paciente con citas ✓ |
| CASCADE | borra también los hijos | peligroso: borrado en cadena |
| SET NULL | deja la referencia vacía | requiere columna nullable |
El default RESTRICT es exactamente lo que una clínica necesita: nadie borra un paciente que tiene historial — primero se desactiva (activo = FALSE, borrado lógico).
Verificar lo construido
SHOW TABLES;
SHOW CREATE TABLE citas\G
-- prueba de fuego: la FK debe rechazar esto:
INSERT INTO citas (paciente_id, medico_id, fecha_hora, motivo)
VALUES (9999, 1, '2026-09-01 10:00:00', 'prueba');
-- ERROR 1452: Cannot add or update a child rowSi el INSERT falló con error 1452, las leyes están vivas. Ese mensaje (Constraint fk_citas_paciente) es el sonido de la base defendiéndose.
Puntos clave
- REFERENCES inline NO crea FK — siempre FOREIGN KEY (...).
- UNIQUE compuesta = reglas de combinación (doble agenda).
- CHECK se aplica desde 10.2.1: montos y rangos en la base.
- ON DELETE RESTRICT por defecto: borrado lógico, no físico.
- Error 1452 = la FK trabajando. Es buena señal.
9 · INSERT: cargar la clínica de datos
Básico ~14 minLas tablas están vacías y las leyes armadas. Hoy las poblamos con datos deterministas — fechas y valores FIJOS, elegidos a propósito para que las consultas de los próximos capítulos den siempre el mismo resultado en tu máquina y en la mía.
- INSERT de una fila y de muchas en una sola sentencia.
- Obtener el id generado con LAST_INSERT_ID().
- Cargar la clínica completa con patrones de negocio reales.
- Entender por qué datos deterministas ayudan a aprender.
INSERT: una fila y varias
INSERT INTO especialidades (nombre, descripcion)
VALUES ('Medicina General', 'Atención primaria y derivaciones');
-- varias filas en UNA sentencia (más eficiente que N inserts):
INSERT INTO especialidades (nombre, descripcion) VALUES
('Cardiología', 'Corazón y sistema circulatorio'),
('Pediatría', 'Atención de niños y adolescentes'),
('Dermatología', 'Piel, cabello y uñas'),
('Traumatología', 'Huesos, articulaciones y músculos'),
('Oftalmología', 'Ojos y visión');Con las 6 especialidades cargadas, los médicos referencian por id — pero ¿qué ids les tocó? Para encadenar inserciones dentro de un script, el motor te responde:
INSERT INTO medicos (nombre, apellido, colegiatura,
especialidad_id, email, telefono, activo)
VALUES ('Ana', 'Torres', 'CT-12045', 2, 'atorres@clinica.pe', '987001001', TRUE);
SELECT LAST_INSERT_ID(); -- el id que ACABA de generarse para tu INSERTLAST_INSERT_ID() es POR CONEXIÓN: devuelve el último id que TU sesión generó, aunque otros usuarios inserten a la vez. Es la base del patrón "insertar padre + hijos" que usaremos en PHP.
Los datos deterministas del curso
El script completo bd_mysql_clinica.sql acompaña el manual y
carga todo de una vez. Sus patrones NO son al azar — cada uno existe para que
una consulta futura tenga algo interesante que encontrar:
| Patrón sembrado | Para ejercitar... |
|---|---|
| 20 pacientes, 8 médicos, 6 especialidades | JOINs y catálogos |
| ~60 citas en fechas FIJAS de 2026 (julio-septiembre) | BETWEEN, GROUP BY mes |
| Citas ATENDIDAS sin pago (deudores) | LEFT JOIN + IS NULL |
| Citas CANCELADAS y NO_ASISTIO | filtros por estado |
| Un paciente sin NINGUNA cita | anti-joins, NOT EXISTS |
| Recetas solo en citas ATENDIDAS | integridad de negocio |
| Pagos en 3 métodos y 3 estados | agregados por categoría |
Muestra del script
INSERT INTO pacientes
(documento, nombre, apellido, fecha_nacimiento, sexo, telefono, tipo_sangre)
VALUES
('45781234', 'María', 'Quispe', '1990-03-15', 'F', '987111222', 'O+'),
('41234567', 'Juan', 'Mamani', '1985-07-22', 'M', '987222333', 'A+'),
('47112233', 'Rosa', 'Huamán', '1978-11-02', 'F', NULL, 'O-'),
('40998877', 'Carlos', 'Sánchez', '2001-01-30', 'M', '987333444', 'B+');
INSERT INTO usuarios_app (usuario, nombre, rol, hash_clave, activo) VALUES
('admin', 'Administrador', 'ADMIN', '$2y$10$...', TRUE),
('rrosa', 'Rosa Reyes', 'RECEPCION', '$2y$10$...', TRUE),
('atorres','Dra. Ana Torres', 'MEDICO', '$2y$10$...', TRUE);
INSERT INTO citas (paciente_id, medico_id, fecha_hora, estado, motivo) VALUES
(1, 2, '2026-07-06 09:00:00', 'ATENDIDA', 'Control de presión'),
(2, 2, '2026-07-06 09:30:00', 'ATENDIDA', 'Dolor torácico'),
(3, 3, '2026-07-07 10:00:00', 'CANCELADA', 'Chequeo anual'),
(4, 2, '2026-07-08 11:00:00', 'NO_ASISTIO', 'Seguimiento');El hash $2y$10$... representa un hash bcrypt real generado
desde PHP (password_hash) — las claves NUNCA viajan en texto plano, ni siquiera
en un curso (cap 30).
¿Por qué deterministas?
Si las fechas fueran NOW() o los datos aleatorios, "los pacientes deudores" daría 5 en mi máquina y 7 en la tuya — y ninguna consulta se podría verificar. Con datos fijos, cuando el cap 17 diga "esta consulta devuelve 4 filas", TÚ debes ver 4. Ese es el contrato del curso: mismo modelo, mismos datos, mismos resultados.
bd_mysql_clinica.sql
crea TODO (tablas + datos) de una pasada. Ejecútalo en una base limpia y
quedarás sincronizado con el resto del manual.Puntos clave
- INSERT multi-fila: una sentencia, N valores — más eficiente.
- LAST_INSERT_ID(): el id de TU sesión, base del patrón padre-hijos.
- Patrones sembrados: deudores, cancelados, paciente sin citas.
- hash_clave con bcrypt: las claves jamás en texto plano.
- Determinismo = resultados verificables entre lectores.
10 · Trampas de tipos, NULL y sql_mode
Básico ~14 minCierre de Parte II. MariaDB puede ser indulgente o estricta según su configuración — y una base indulgente es una bomba de tiempo. Hoy activamos el rigor y destapamos las trampas clásicas: NULL vs cadena vacía, fechas inválidas y comparaciones que no comparan.
- Entender el sql_mode strict (default moderno) y por qué conservarlo.
- Dominar NULL: qué es, qué NO es, y cómo se compara.
- Evitar las trampas: '' vs NULL, fechas inválidas, divisiones.
- Cerrar Parte II con el esquema completo y poblado.
sql_mode: el carácter del servidor
SELECT @@sql_mode;
-- STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,
-- NO_ENGINE_SUBSTITUTION,... (default de MariaDB moderno)Con STRICT_TRANS_TABLES (default actual), insertar 'abc' en una columna INT da ERROR — correcto. En modos antiguos e indulgentes, el motor truncaba a 0 y seguía de largo: datos corruptos con una sonrisa. No desactives el strict mode; si un sistema viejo lo exige, es señal de deuda técnica, no de configuración.
NULL: la ausencia que rompe lógicas
NULL no es cero, no es cadena vacía: es "NO SABEMOS / NO APLICA". Y tiene una regla que sorprende a todos:
SELECT NULL = NULL; -- NULL (¡no es TRUE!)
SELECT NULL != NULL; -- NULL
SELECT 1 = NULL; -- NULL
-- la única comparación válida contra NULL:
SELECT * FROM pacientes WHERE telefono IS NULL;
SELECT * FROM pacientes WHERE telefono IS NOT NULL;En el modelo, Rosa no tiene teléfono registrado (NULL) y Juan sí dejó uno vacío en la recepción... ¿cómo se guarda eso? Dos opciones con significados DISTINTOS:
| Valor | Significado | Consulta que lo encuentra |
|---|---|---|
| NULL | desconocido / no aplica | IS NULL |
| '' (cadena vacía) | se preguntó y el dato es "nada" | = '' |
Regla del modelo clinica: si el dato se desconoce → NULL; si se sabe que no hay → cadena vacía o mejor aún, un valor explícito. Elegir UNA convención y no mezclar.
Trampas clásicas (y su antidoto)
| Trampa | Síntoma | Antídoto |
|---|---|---|
| Comparar con = NULL | la consulta devuelve SIEMPRE vacío | IS NULL / IS NOT NULL |
| '2026-02-30' (fecha inexistente) | error en strict / NULL con warning | deja que strict te proteja |
| WHERE telefono = '987...' con espacio final | no encuentra la fila | TRIM() al cargar datos |
| COUNT(columna) esperando todas las filas | cuenta menos (excluye NULLs) | COUNT(*) cuenta filas; COUNT(col) cuenta valores |
| División entera 7/2 | esperabas 3.5, obtienes 3.5 (MariaDB divide decimal) | ok aquí — pero en otros motores es entera: ojo al migrar |
Prueba de las trampas, en vivo
USE clinica;
-- 1) NULL vs vacío en acción:
SELECT COUNT(*) FROM pacientes; -- 20 (filas)
SELECT COUNT(telefono) FROM pacientes; -- menos (excluye NULLs)
-- 2) la comparación que no compara:
SELECT COUNT(*) FROM pacientes WHERE telefono = NULL; -- 0 (¡siempre!)
SELECT COUNT(*) FROM pacientes WHERE telefono IS NULL; -- la correcta
-- 3) strict mode defendiendo el tipo:
INSERT INTO pacientes (documento, nombre, apellido,
fecha_nacimiento, sexo)
VALUES ('99999999', 'Prueba', 'Trampa', '2026-02-30', 'X');
-- ERROR: Incorrect date value: '2026-02-30'Puntos clave
- STRICT_TRANS_TABLES: déjalo activo — es tu red de seguridad.
- NULL ≠ '' ≠ 0: tres ausencias distintas con distinto significado.
- Con NULL solo hay dos comparaciones válidas: IS NULL / IS NOT NULL.
- COUNT(*) cuenta filas; COUNT(col) excluye NULLs.
- Declarar charset/collate explícito evita sorpresas entre servidores.
11 · Tu primer SELECT
Intermedio ~13 minArranca Parte III, la más larga del curso: consultar. Todo lo demás (insertar, procedures, PHP) existe para que ESTO sea posible. Empezamos por la anatomía completa de la sentencia más importante del SQL.
- Escribir la anatomía de un SELECT: qué va en cada cláusula.
- Elegir columnas con alias legibles.
- Calcular expresiones y eliminar duplicados con DISTINCT.
- Saber el ORDEN real de ejecución (y por qué importa).
La anatomía
SELECT columna1, columna2 -- 1. QUÉ columnas devolver
FROM tabla -- 2. DE DÓNDE salen las filas
WHERE condición -- 3. QUÉ filas (filtro de filas)
GROUP BY columna -- 4. agrupación (cap 14)
HAVING condición_de_grupo -- 5. filtro de grupos (cap 14)
ORDER BY columna -- 6. orden del resultado
LIMIT n; -- 7. cuántas filas (cap 12)No todas las cláusulas aparecen siempre — SELECT y FROM son los únicos obligatorios — pero el orden NO es negociable: es sintaxis.
Primeros pasos sobre pacientes
USE clinica;
-- todo (solo para explorar tablas pequeñas):
SELECT * FROM especialidades;
-- columnas explícitas (la forma profesional):
SELECT nombre, apellido, fecha_nacimiento
FROM pacientes;
-- con alias para leer mejor el resultado:
SELECT nombre AS nombre_paciente,
apellido AS apellido_paciente,
fecha_nacimiento AS nacimiento
FROM pacientes;SELECT * en producción es mala señal: traes columnas que no
usas (red y memoria), y si mañana la tabla gana una columna pesada, tu
consulta la arrastra sin que la pidieras. En el curso la usaremos para
explorar; en código real, siempre columnas nombradas.
Expresiones: la consulta como calculadora
SELECT nombre, apellido,
TIMESTAMPDIFF(YEAR, fecha_nacimiento, '2026-08-25') AS edad,
CONCAT(nombre, ' ', apellido) AS nombre_completo
FROM pacientes
LIMIT 5;Las expresiones crean columnas calculadas al vuelo: edad exacta con TIMESTAMPDIFF, texto concatenado con CONCAT. El alias (AS) nombra la columna resultante — sin él, la columna se llama feo y largo.
DISTINCT: los valores únicos
-- ¿qué estados de cita EXISTEN realmente en los datos?
SELECT DISTINCT estado FROM citas;
-- ¿qué combinaciones médico+estado han ocurrido?
SELECT DISTINCT medico_id, estado FROM citas ORDER BY medico_id;DISTINCT elimina duplicados del RESULTADO — opera sobre la fila completa seleccionada, no sobre una columna aislada (en la segunda consulta, la combinación es lo que se hace único).
El orden de ejecución que nadie te cuenta
Tú ESCRIBES SELECT...FROM...WHERE..., pero el motor EVALÚA en otro orden: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT. Parece pedantería hasta el día que intentas usar en el WHERE un alias que definiste en el SELECT — y falla, porque cuando el WHERE corre, el SELECT todavía no existió:
-- ERROR: Unknown column 'edad':
SELECT nombre, TIMESTAMPDIFF(YEAR, fecha_nacimiento, '2026-08-25') AS edad
FROM pacientes
WHERE edad > 40;
-- CORRECTO: repetir la expresión en el WHERE
SELECT nombre, TIMESTAMPDIFF(YEAR, fecha_nacimiento, '2026-08-25') AS edad
FROM pacientes
WHERE TIMESTAMPDIFF(YEAR, fecha_nacimiento, '2026-08-25') > 40;Puntos clave
- 7 cláusulas, orden fijo; solo SELECT y FROM son obligatorias.
- SELECT * solo para explorar; en código, columnas nombradas.
- AS nombra columnas calculadas (CONCAT, TIMESTAMPDIFF...).
- DISTINCT hace únicas las FILAS del resultado.
- Se escribe SELECT primero pero se ejecuta FROM/WHERE primero.
12 · WHERE, ORDER BY y LIMIT: filtrar, ordenar, acotar
Intermedio ~14 minEl 80% de las consultas reales son esto: qué filas (WHERE), en qué orden (ORDER BY) y cuántas (LIMIT). Dominar estas tres cláusulas con sus operadores te da el SQL del día a día completo.
- Filtrar con operadores de comparación, BETWEEN, IN y LIKE.
- Combinar condiciones con AND/OR/NOT sin ambigüedades.
- Ordenar por varias columnas con dirección mixta.
- Paginar resultados con LIMIT ... OFFSET.
WHERE: los operadores
-- comparación clásica:
SELECT * FROM citas WHERE estado = 'ATENDIDA';
-- rangos con BETWEEN (INCLUYE los extremos):
SELECT * FROM citas
WHERE fecha_hora BETWEEN '2026-07-01' AND '2026-07-31 23:59:59';
-- listas con IN:
SELECT * FROM citas
WHERE estado IN ('CANCELADA', 'NO_ASISTIO');
-- texto parcial con LIKE (% = cualquier cosa, _ = un carácter):
SELECT nombre, apellido FROM pacientes WHERE apellido LIKE 'QUIS%';
SELECT nombre, apellido FROM pacientes WHERE documento LIKE '_7__%';Ojo con BETWEEN en DATETIME: '2026-07-31' como límite superior solo llega a las 00:00:00 de ese día — por eso el ejemplo termina en '2026-07-31 23:59:59'. Es una de las trampas de fechas más comunes.
AND, OR y los paréntesis que salvan
-- ¿ambiguo? AND se evalúa antes que OR:
SELECT * FROM citas
WHERE estado = 'ATENDIDA' OR estado = 'PROGRAMADA'
AND medico_id = 2;
-- ¡NO es lo que parece! Esto incluye TODAS las ATENDIDAS de cualquier médico.
-- siempre paréntesis cuando mezclas:
SELECT * FROM citas
WHERE (estado = 'ATENDIDA' OR estado = 'PROGRAMADA')
AND medico_id = 2;Regla de la casa: si usas AND y OR en la misma condición, paréntesis. Siempre. Cuesta 5 caracteres y evita el bug más repetido del SQL cotidiano.
ORDER BY: orden profesional
-- varias columnas, direcciones mixtas:
SELECT apellido, nombre, fecha_nacimiento
FROM pacientes
ORDER BY apellido ASC, nombre ASC;
-- citas del más reciente al más viejo:
SELECT fecha_hora, estado, motivo
FROM citas
ORDER BY fecha_hora DESC
LIMIT 10;El orden por texto respeta el collation de la columna (el español que configuramos en cap 4 ordena bien las tildes). Y ORDER BY puede usar la posición de columna (ORDER BY 2) — no lo hagas: se rompe si editas el SELECT.
LIMIT y OFFSET: paginación
-- página 1 (primeros 10):
SELECT id, apellido, nombre FROM pacientes
ORDER BY apellido, nombre
LIMIT 10;
-- página 3 (filas 21 a 30):
SELECT id, apellido, nombre FROM pacientes
ORDER BY apellido, nombre
LIMIT 10 OFFSET 20;LIMIT sin ORDER BY es una lotería: el motor devuelve CUALQUIER 10 filas — hoy salen ordenadas por azar del almacenamiento, mañana no. LIMIT siempre acompañado de ORDER BY.
Ejercicio integrador del capítulo
-- La agenda del doctor id=2 para la primera semana de julio 2026,
-- solo PROGRAMADAS, de lo más viejo a lo más nuevo, máximo 5:
SELECT fecha_hora, motivo
FROM citas
WHERE medico_id = 2
AND estado = 'PROGRAMADA'
AND fecha_hora BETWEEN '2026-07-06' AND '2026-07-12 23:59:59'
ORDER BY fecha_hora
LIMIT 5;Puntos clave
- BETWEEN incluye extremos — y en DATETIME el día suelto miente.
- AND antes que OR: paréntesis siempre que convivan.
- LIKE: % cualquier cosa, _ un carácter exacto.
- LIMIT sin ORDER BY = resultado impredecible.
- ORDER BY posición (ORDER BY 2): prohibido en el curso.
13 · Funciones escalares: texto, números y fechas
Intermedio ~14 minLas funciones escalares transforman valor por valor: cada fila entra a la función y sale transformada. Son el "kit de herramientas" que convierte un dato crudo en información presentable.
- Limpiar y combinar texto: CONCAT, TRIM, UPPER, SUBSTRING.
- Redondear y calcular: ROUND, CEILING, FLOOR, MOD.
- Manipular fechas: DATE_ADD, DATEDIFF, DATE_FORMAT.
- Manejar ausencias: COALESCE e IFNULL con datos reales.
Texto
SELECT
CONCAT(nombre, ' ', apellido) AS completo,
UPPER(LEFT(apellido, 3)) AS iniciales_mayus,
CHAR_LENGTH(documento) AS largo_documento,
TRIM(' Maria ') AS limpio,
REPLACE(telefono, ' ', '') AS telefono_sin_espacios
FROM pacientes
LIMIT 3;Dos detalles finos: CHAR_LENGTH cuenta CARACTERES (útil con utf8mb4, donde una ñ ocupa más bytes) mientras LENGTH cuenta bytes; y TRIM quita espacios de los extremos — el antídoto de la trampa del cap 10.
Números
SELECT
monto,
ROUND(monto * 0.18, 2) AS igv, -- 2 decimales exactos
CEILING(monto / 50) AS lotes_50, -- hacia arriba
FLOOR(monto / 50) AS completos -- hacia abajo
FROM pagos
LIMIT 5;
SELECT MOD(10, 3) AS resto; -- 1: útil para alternar filas, lotes, etc.Fechas: el grupo más usado en la clínica
SELECT
fecha_hora,
DATE(fecha_hora) AS solo_dia,
DATE_FORMAT(fecha_hora, '%d/%m/%Y %H:%i') AS formato_pe,
DATEDIFF('2026-08-25', DATE(fecha_hora)) AS dias_transcurridos,
DATE_ADD(fecha_hora, INTERVAL 30 MINUTE) AS fin_estimado
FROM citas
LIMIT 5;
-- ¿cuándo vence una receta de 7 días?
SELECT medicamento,
DATE_ADD('2026-08-25', INTERVAL duracion_dias DAY) AS vence
FROM recetas
LIMIT 5;DATE_FORMAT usa especificadores: %d día, %m mes, %Y año, %H:%i hora:minuto. Ojo: la fecha se FORMATEA al mostrar — para filtrar y comparar, la columna sigue siendo DATETIME cruda (formatear rompe índices, cap 33).
COALESCE: el traductor de NULL
La función más práctica del SQL: devuelve el PRIMER valor no nulo de su lista. Es el antídoto oficial contra los NULL del cap 10:
-- Rosa no tiene teléfono: mostramos un texto digno en vez de NULL
SELECT CONCAT(nombre, ' ', apellido) AS paciente,
COALESCE(telefono, 'sin registrar') AS telefono,
IFNULL(tipo_sangre, '?') AS sangre
FROM pacientes
LIMIT 8;COALESCE(a, b, c) evalúa en orden: si a existe, a; si no, b; si tampoco, c. IFNULL(x, y) es su versión de dos argumentos (específica de MySQL/MariaDB — en PostgreSQL y SQL Server existe pero la estándar es COALESCE: acostúmbrate a la portable).
IF(): decisión por fila
SELECT CONCAT(nombre, ' ', apellido) AS paciente,
IF(activo, 'activo', 'inactivo') AS estado
FROM pacientes
LIMIT 5;IF(condición, si_verdadero, si_falso) responde decisiones simples por fila. Para decisiones complejas o reutilizables, el camino es CASE (cap 16) o mejor aún, que la lógica viva en la aplicación — la frontera de siempre.
Puntos clave
- CHAR_LENGTH cuenta caracteres; LENGTH cuenta bytes.
- Dinero: ROUND siempre a 2 decimales sobre DECIMAL.
- Formatear fechas es SOLO para mostrar; filtrar con el dato crudo.
- COALESCE: primer no-nulo — portable entre los 4 motores.
- IF() para decisiones simples; lo complejo, fuera de la consulta.
14 · Agregados: COUNT, SUM y GROUP BY
Intermedio ~15 minLas funciones de agregado colapsan MUCHAS filas en UNA respuesta: "cuántas citas", "cuánto facturado", "el monto máximo". Combinadas con GROUP BY responden las preguntas de la gerencia de la clínica.
- Usar las 5 agregadas básicas y saber qué ignora cada una.
- Agrupar con GROUP BY y filtrar grupos con HAVING.
- Entender la regla de oro: toda columna fuera del agregado va en GROUP BY.
- Construir el reporte de productividad por especialidad.
Las cinco agregadas
SELECT
COUNT(*) AS total_citas, -- cuenta FILAS
COUNT(diagnostico) AS con_diagnostico, -- cuenta VALORES (excluye NULL)
SUM(monto) AS monto_total, -- ojo: solo tiene sentido con pagos
AVG(monto) AS promedio,
MIN(monto) AS minimo,
MAX(monto) AS maximo
FROM pagos;La diferencia COUNT(*) vs COUNT(columna) del cap 10 ahora cobra sentido financiero: COUNT(diagnostico) NO cuenta las citas atendidas sin diagnóstico escrito. Cada agregada responde una pregunta distinta — elige la tuya a conciencia.
GROUP BY: un resumen por cada grupo
-- ¿cuántas citas hay en cada estado?
SELECT estado, COUNT(*) AS total
FROM citas
GROUP BY estado;
-- ¿cuánto se pagó por método?
SELECT metodo, COUNT(*) AS operaciones, SUM(monto) AS recaudado
FROM pagos
WHERE estado = 'PAGADO'
GROUP BY metodo;El motor forma "bolsas" de filas con el mismo valor de la columna agrupada y aplica la agregada a cada bolsa. El WHERE filtra filas ANTES de las bolsas; por eso puede convivir con GROUP BY sin problema.
HAVING: el filtro de los grupos
-- especialidades con MÁS de 2 médicos:
SELECT e.nombre, COUNT(m.id) AS medicos
FROM medicos m
JOIN especialidades e ON e.id = m.especialidad_id
GROUP BY e.nombre
HAVING COUNT(m.id) > 2;
-- métodos de pago con recaudación mayor a 1000:
SELECT metodo, SUM(monto) AS recaudado
FROM pagos
WHERE estado = 'PAGADO'
GROUP BY metodo
HAVING SUM(monto) > 1000;WHERE filtra filas, HAVING filtra grupos. No puedes poner SUM(monto) > 1000 en el WHERE porque cuando el WHERE corre, la suma todavía no existe (el orden de ejecución del cap 11). HAVING corre DESPUÉS del GROUP BY: ahí sí existen los agregados.
La regla de oro del GROUP BY
-- ERROR (o basura) en modo estricto:
SELECT metodo, fecha, SUM(monto) FROM pagos GROUP BY metodo;
-- fecha no está agrupada ni agregada: ¿CUÁL fecha mostraría?
-- CORRECTO: toda columna del SELECT está en GROUP BY o dentro de un agregado:
SELECT metodo, COUNT(*), SUM(monto) FROM pagos GROUP BY metodo;MariaDB moderna aplica only_full_group_by: si una columna del SELECT ni se agrupa ni se agrega, hay ERROR. Los tutoriales viejos te enseñan a apagarlo — nosotros aprendemos a escribirlo bien.
GROUP_CONCAT: la lista dentro de la fila
-- todos los médicos de cada especialidad, en una línea:
SELECT e.nombre AS especialidad,
GROUP_CONCAT(CONCAT(m.apellido, ' (', m.colegiatura, ')')
ORDER BY m.apellido SEPARATOR ', ') AS medicos
FROM medicos m
JOIN especialidades e ON e.id = m.especialidad_id
GROUP BY e.nombre;GROUP_CONCAT agrega TEXTO: junta los valores del grupo en una lista. Específica de MySQL/MariaDB (en PostgreSQL es STRING_AGG, en SQL Server STRING_AGG también) — apúntala en tu tabla de dialectos.
Puntos clave
- COUNT(*) filas vs COUNT(col) valores — eligen antes de contar.
- WHERE filtra filas; HAVING filtra grupos ya agregados.
- Regla de oro: todo lo del SELECT va en GROUP BY o en un agregado.
- GROUP_CONCAT junta texto del grupo (STRING_AGG en los otros).
- only_full_group_by no se apaga: se obedece.
15 · JOINs: INNER, LEFT y el anti-join
Intermedio ~16 minLos datos normalizados viven en tablas separadas — los JOINs los vuelven a juntar en el momento de consultar. Es EL capítulo donde la normalización del cap 6 paga sus dividendos.
- Dominar INNER JOIN con su sintaxis moderna.
- Usar LEFT JOIN para incluir filas SIN pareja.
- Encadenar 3 tablas en una consulta real.
- Construir el anti-join: los que NO tienen (deudores, sin citas).
INNER JOIN: solo los que tienen pareja
SELECT c.fecha_hora, p.nombre, p.apellido,
m.apellido AS medico, c.estado
FROM citas c
JOIN pacientes p ON p.id = c.paciente_id
JOIN medicos m ON m.id = c.medico_id
ORDER BY c.fecha_hora
LIMIT 10;Tres tablas encadenadas en una consulta legible: la cita, su paciente y su médico. Los alias cortos (c, p, m) son estándar de industria — sin ellos la consulta se vuelve ilegible a la tercera tabla. INNER JOIN devuelve SOLO las citas cuyo paciente Y médico existen (en nuestra base, todos: las FK del cap 8 lo garantizan — buen momento para apreciarlas).
LEFT JOIN: todos los de la izquierda, con o sin pareja
-- TODOS los pacientes, con sus citas si las tienen:
SELECT p.apellido, p.nombre, c.fecha_hora, c.estado
FROM pacientes p
LEFT JOIN citas c ON c.paciente_id = p.id
ORDER BY p.apellido;Con INNER, el paciente sin citas desaparecería del resultado. Con LEFT, aparece TODOS los pacientes; los que no tienen citas traen NULL en las columnas de citas. Ese NULL es información: "este paciente existe pero nunca ha venido".
El anti-join: encontrar a los que NO tienen
-- pacientes SIN ninguna cita:
SELECT p.id, p.apellido, p.nombre
FROM pacientes p
LEFT JOIN citas c ON c.paciente_id = p.id
WHERE c.id IS NULL;
-- DEUDORES: citas ATENDIDAS sin su pago PAGADO:
SELECT p.apellido, p.nombre, c.fecha_hora, c.motivo
FROM citas c
JOIN pacientes p ON p.id = c.paciente_id
LEFT JOIN pagos pg ON pg.cita_id = c.id AND pg.estado = 'PAGADO'
WHERE c.estado = 'ATENDIDA'
AND pg.id IS NULL
ORDER BY c.fecha_hora;El patrón es siempre el mismo: LEFT JOIN hacia la tabla "hija" y WHERE hijo.id IS NULL. El segundo ejemplo tiene un detalle fino: la condición pg.estado = 'PAGADO' vive DENTRO del ON, no en el WHERE — así el LEFT JOIN busca específicamente "su pago pagado" y si no lo encuentra trae NULL. Si la hubiéramos puesto en el WHERE, convertiríamos el LEFT en un INNER sin querer (los NULL serían eliminados).
INNER vs LEFT: la decisión en una frase
| Pregunta de negocio | JOIN |
|---|---|
| "Las citas de julio con sus pacientes" | INNER — toda cita tiene paciente |
| "Pacientes que han tenido citas" | INNER (o DISTINCT sobre el anterior) |
| "TODOS los pacientes, con citas si las tienen" | LEFT |
| "Pacientes que NUNCA han tenido citas" | LEFT + IS NULL (anti-join) |
| "Citas atendidas que aún deben pago" | JOIN + LEFT + IS NULL |
Los JOIN viejos que no debes escribir
-- sintaxis antigua (join implícito en el WHERE) — NO usar:
SELECT * FROM citas c, pacientes p WHERE c.paciente_id = p.id;
-- el mismo riesgo sin filtro: producto cartesiano gigante
-- (cada cita x cada paciente). MariaDB la ejecuta feliz.Puntos clave
- INNER: solo parejas · LEFT: todos los de la izquierda.
- Anti-join = LEFT JOIN + WHERE hijo.id IS NULL.
- Condiciones del hijo van en el ON, no en el WHERE.
- Alias cortos desde la segunda tabla — estándar de industria.
- JOIN implícito con comas: prohibido (riesgo de cartesianos).
16 · Subconsultas y CASE
Intermedio ~15 minUna consulta DENTRO de otra: para comparar contra un cálculo, filtrar por un conjunto o derivar una tabla al vuelo. Y de regalo, CASE: el condicional de columnas que todo reporte necesita.
- Usar subconsultas escalares en SELECT y WHERE.
- Filtrar con IN y NOT IN — y su trampa fatal con NULL.
- Preferir EXISTS/NOT EXISTS cuando la fila importa más que el valor.
- Derivar tablas en FROM y clasificar con CASE.
Subconsulta escalar: un valor que se compara
-- pagos por ENCIMA del promedio:
SELECT fecha, monto, metodo
FROM pagos
WHERE monto > (SELECT AVG(monto) FROM pagos WHERE estado = 'PAGADO')
ORDER BY monto DESC;
-- el médico con más citas:
SELECT id, nombre, apellido
FROM medicos
WHERE id = (SELECT medico_id
FROM citas
GROUP BY medico_id
ORDER BY COUNT(*) DESC
LIMIT 1);La subconsulta corre PRIMERO, produce UN valor, y la externa lo usa como una constante. Legible y perfecta cuando el interior devuelve un solo dato.
IN con subconsulta — y su trampa fatal
-- pacientes que SÍ han tenido citas:
SELECT apellido, nombre FROM pacientes
WHERE id IN (SELECT paciente_id FROM citas);
-- pacientes que NUNCA han tenido citas — ¡CUIDADO!
SELECT apellido, nombre FROM pacientes
WHERE id NOT IN (SELECT paciente_id FROM citas); -- puede devolver VACÍOSi la subconsulta de NOT IN devuelve UN SOLO NULL (y en nuestra base paciente_id es NOT NULL, pero imagina una columna nullable), la comparación id NOT IN (..., NULL) nunca es verdadera — NULL envenena toda la lista y el resultado es vacío. Recuerda el cap 10: NULL no es igual a nada.
EXISTS: el profesional
-- los que NUNCA han venido (versión robusta):
SELECT p.apellido, p.nombre
FROM pacientes p
WHERE NOT EXISTS (SELECT 1 FROM citas c WHERE c.paciente_id = p.id);
-- los que han tenido cita con cardiología:
SELECT p.apellido, p.nombre
FROM pacientes p
WHERE EXISTS (SELECT 1
FROM citas c
JOIN medicos m ON m.id = c.medico_id
WHERE c.paciente_id = p.id
AND m.especialidad_id = 2);EXISTS no mira valores: pregunta "¿EXISTE al menos una fila que cumpla?" y se detiene en la primera que encuentra. Inmune al NULL, legible y eficiente. SELECT 1 es convención: a EXISTS solo le importa que la fila exista, no qué trae. NOT EXISTS reemplaza a NOT IN en el 100% de los casos serios.
Tabla derivada: la subconsulta como FROM
-- total pagado por cita, y sobre ESO, solo las que superan 50:
SELECT cita_id, total_pagado
FROM (SELECT cita_id, SUM(monto) AS total_pagado
FROM pagos
WHERE estado = 'PAGADO'
GROUP BY cita_id) AS totales
WHERE total_pagado > 50;La subconsulta produce una tabla temporal con alias (obligatorio) y la externa la consulta como si fuera una tabla más. Es el escalón previo natural a los CTEs del cap 17.
CASE: el condicional de columnas
SELECT fecha_hora, estado,
CASE estado
WHEN 'ATENDIDA' THEN '✓ completada'
WHEN 'CANCELADA' THEN '✗ cancelada'
WHEN 'NO_ASISTIO' THEN '⚠ inasistencia'
ELSE '… pendiente'
END AS lectura
FROM citas
LIMIT 10;
-- CASE con rangos: clasificación por monto
SELECT id, monto,
CASE
WHEN monto >= 100 THEN 'consulta especializada'
WHEN monto >= 50 THEN 'consulta general'
ELSE 'trámite menor'
END AS categoria
FROM pagos;CASE evalúa condiciones EN ORDEN y devuelve el primer WHEN verdadero; el ELSE es el default (sin él, devuelve NULL). A diferencia de IF() (MariaDB only), CASE es estándar SQL: funciona idéntico en los cuatro motores.
Puntos clave
- Escalar: corre primero, produce UN valor, se usa como constante.
- NOT IN + NULL = lista envenenada, resultado vacío.
- EXISTS/NOT EXISTS: inmunes a NULL, se detienen en la primera fila.
- Tabla derivada en FROM exige alias.
- CASE es estándar (los 4 motores); IF() es MariaDB-only.
17 · CTEs: consultas legibles con WITH
Intermedio ~14 minLas CTEs (Common Table Expressions) son subconsultas con NOMBRE que viven al inicio de tu consulta. Mismo poder que la tabla derivada, pero con legibilidad humana: primero los pasos, después el resultado.
- Escribir una CTE con WITH y usarla en el SELECT.
- Encadenar varias CTEs como pasos de un cálculo.
- Reescribir la tabla derivada del cap 16 como CTE.
- Saber que las CTEs recursivas existen (y cuándo llegarán a ellas).
La sintaxis
WITH nombre_cte AS (
SELECT ... -- cualquier consulta válida
)
SELECT ... FROM nombre_cte;El WITH define un "resultado temporal con nombre" que SOLO vive durante esa sentencia. Reescribamos el reporte de pagos del cap 16:
-- ANTES (tabla derivada): la lógica va al revés, de adentro hacia afuera
SELECT cita_id, total_pagado
FROM (SELECT cita_id, SUM(monto) AS total_pagado
FROM pagos WHERE estado = 'PAGADO'
GROUP BY cita_id) AS totales
WHERE total_pagado > 50;
-- AHORA (CTE): se lee de arriba hacia abajo, como una receta
WITH totales AS (
SELECT cita_id, SUM(monto) AS total_pagado
FROM pagos
WHERE estado = 'PAGADO'
GROUP BY cita_id
)
SELECT cita_id, total_pagado
FROM totales
WHERE total_pagado > 50;Idéntico resultado, otra experiencia de lectura. En consultas de 3+ pasos la diferencia es brutal.
CTEs encadenadas: un pipeline de pasos
WITH citas_2026 AS (
SELECT c.*, p.apellido, p.nombre, m.apellido AS medico
FROM citas c
JOIN pacientes p ON p.id = c.paciente_id
JOIN medicos m ON m.id = c.medico_id
WHERE c.fecha_hora >= '2026-01-01'
),
atendidas AS (
SELECT * FROM citas_2026 WHERE estado = 'ATENDIDA'
),
por_medico AS (
SELECT medico, COUNT(*) AS atendidas
FROM atendidas
GROUP BY medico
)
SELECT * FROM por_medico
ORDER BY atendidas DESC;Cada CTE puede usar la anterior — es un pipeline: primero el universo (citas 2026 con nombres), luego el filtro (atendidas), luego el resumen (por médico), y al final el SELECT final consume el último paso. Cada paso tiene nombre y propósito: el reporte se EXPLICA solo.
CTE que reemplaza la subconsulta repetida
-- sin CTE: el promedio se repite dos veces (cap 16):
SELECT * FROM pagos
WHERE monto > (SELECT AVG(monto) FROM pagos WHERE estado = 'PAGADO')
AND estado = 'PAGADO';
-- con CTE: se calcula UNA vez y se nombra:
WITH promedio AS (
SELECT AVG(monto) AS promedio FROM pagos WHERE estado = 'PAGADO'
)
SELECT pg.fecha, pg.monto
FROM pagos pg, promedio pr
WHERE pg.estado = 'PAGADO'
AND pg.monto > pr.promedio;Lo que NO es una CTE
| Mito | Realidad |
|---|---|
| "Una CTE es una vista temporal en el servidor" | no: vive SOLO durante la sentencia; nada queda creado |
| "Una CTE siempre es más rápida" | no: el motor puede ejecutarla igual que la derivada equivalente; gana en LEGIBILIDAD, no mágicamente en velocidad |
| "Puedo reusarla en la siguiente consulta" | no: para eso existen las VISTAS (cap 26) |
MariaDB también soporta CTEs recursivas (WITH RECURSIVE): un CTE que se referencia a sí mismo para recorrer jerarquías (organigrama, categorías con subcategorías). Nuestro modelo clínica no tiene jerarquías — lo veremos con detalle en el manual de PostgreSQL, que es donde más se usan.
Puntos clave
- WITH nombre AS (...): subconsulta con nombre, se lee de arriba abajo.
- Las CTEs se encadenan: cada paso usa el anterior.
- Vive solo en su sentencia — no es una vista.
- Gana legibilidad, no velocidad mágica.
- 2+ niveles de anidación → conviértelo en CTE.
18 · Ejercicios de afianzamiento: los reportes de la clínica
Intermedio ~18 minCierre de Parte III. Sin teoría nueva: cinco reportes REALES que la administración de una clínica pide cada semana, resueltos con todo lo aprendido. Intenta cada uno ANTES de leer la solución.
- Reporte de deudores con monto adeudado.
- Productividad por especialidad (agregado + JOIN).
- Actividad mensual 2026 (agrupación por mes).
- Pacientes inactivos (anti-join).
- El médico más ocupado por mes (CTE + subconsulta).
1 · Deudores: citas atendidas con saldo
WITH pagado AS (
SELECT cita_id, SUM(monto) AS abonado
FROM pagos
WHERE estado = 'PAGADO'
GROUP BY cita_id
)
SELECT p.apellido, p.nombre, c.fecha_hora, c.motivo,
COALESCE(pg.monto, 0) - COALESCE(pd.abonado, 0) AS saldo
FROM citas c
JOIN pacientes p ON p.id = c.paciente_id
LEFT JOIN pagos pg ON pg.cita_id = c.id AND pg.estado = 'PENDIENTE'
LEFT JOIN pagado pd ON pd.cita_id = c.id
WHERE c.estado = 'ATENDIDA'
AND (pg.id IS NOT NULL OR COALESCE(pd.abonado, 0) = 0)
ORDER BY saldo DESC, c.fecha_hora;Todo el arsenal de la parte: dos LEFT JOIN (uno directo, una CTE), COALESCE para los ceros, aritmética de columnas y orden por severidad. Este reporte justifica solo el curso completo.
2 · Productividad por especialidad
SELECT e.nombre AS especialidad,
COUNT(DISTINCT m.id) AS medicos,
COUNT(c.id) AS citas,
ROUND(COUNT(c.id) / COUNT(DISTINCT m.id), 1) AS citas_por_medico
FROM especialidades e
JOIN medicos m ON m.especialidad_id = e.id AND m.activo
LEFT JOIN citas c ON c.medico_id = m.id AND c.estado = 'ATENDIDA'
GROUP BY e.nombre
ORDER BY citas DESC;Fíjate en los detalles: COUNT(DISTINCT m.id) para no contar médicos dos veces, la condición de citas ATENDIDAS dentro del ON del LEFT (si estuviera en el WHERE eliminaría especialidades sin citas atendidas — la trampa del cap 15).
3 · Actividad mensual 2026
SELECT DATE_FORMAT(fecha_hora, '%Y-%m') AS mes,
COUNT(*) AS total,
SUM(estado = 'ATENDIDA') AS atendidas,
SUM(estado = 'CANCELADA') AS canceladas,
SUM(estado = 'NO_ASISTIO') AS inasistencias
FROM citas
WHERE fecha_hora >= '2026-01-01'
GROUP BY mes
ORDER BY mes;El truco SUM(estado = 'X') es idiomático de MySQL/MariaDB: la
comparación devuelve 1 o 0, y SUM las suma — un mini-pivote en una línea.
(Equivalente portable: SUM(CASE WHEN ... THEN 1 ELSE 0 END), cap 16.)
4 · Pacientes que nunca han venido
SELECT p.apellido, p.nombre, p.telefono
FROM pacientes p
WHERE NOT EXISTS (SELECT 1 FROM citas c WHERE c.paciente_id = p.id)
ORDER BY p.apellido;5 · El médico más ocupado de cada mes
WITH por_mes AS (
SELECT DATE_FORMAT(fecha_hora, '%Y-%m') AS mes,
medico_id,
COUNT(*) AS atendidas
FROM citas
WHERE estado = 'ATENDIDA'
GROUP BY mes, medico_id
),
ranking AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY mes ORDER BY atendidas DESC) AS puesto
FROM por_mes
)
SELECT r.mes, CONCAT(m.nombre, ' ', m.apellido) AS medico, r.atendidas
FROM ranking r
JOIN medicos m ON m.id = r.medico_id
WHERE r.puesto = 1
ORDER BY r.mes;El más ambicioso: dos CTEs encadenadas y ROW_NUMBER() OVER — tu primer gusto de funciones de ventana (PARTITION BY = "por cada grupo"). Las profundizaremos en la Parte V; si esta consulta te pareció natural, ya estás listo para ellas.
Puntos clave
- Los reportes reales combinan 3-4 conceptos: por eso los practicamos juntos.
- LEFT JOIN + IS NULL y NOT EXISTS: los dos anti-joins, mismo resultado.
- SUM(condición) = mini-pivote idiomático de MariaDB.
- ROW_NUMBER() OVER (PARTITION BY ...): ranking por grupo — adelanto de ventanas.
- Un reporte legible (CTEs nombrados) se mantiene sin miedo.
19 · UPDATE y DELETE con FK en mente
Intermedio ~14 minArranca Parte IV: modificar datos. Leer es seguro; escribir no. Un UPDATE sin WHERE o un DELETE mal pensado borran una clínica entera en un segundo — hoy aprendemos a modificar con los cinturones puestos.
- UPDATE con WHERE obligatorio y el ritual de verificación previa.
- Borrado lógico vs físico — y por qué la clínica prefiere el primero.
- Cómo las FK del cap 8 protegen (y frenan) los borrados.
- UPDATE con JOIN y LIMIT en modificaciones.
El ritual del UPDATE seguro
-- PASO 1: escribe el SELECT de lo que vas a cambiar y MÍRALO:
SELECT id, telefono FROM pacientes WHERE documento = '45781234';
-- PASO 2: convierte el SELECT en UPDATE (mismo WHERE, intacto):
UPDATE pacientes
SET telefono = '987111999'
WHERE documento = '45781234';
-- PASO 3: verifica el cambio:
SELECT id, telefono FROM pacientes WHERE documento = '45781234';El WHERE del SELECT y el del UPDATE deben ser EL MISMO. Copiar-pegar el WHERE, nunca reescribirlo de memoria. El UPDATE sin WHERE cambia TODA la tabla — y MariaDB no pregunta "¿estás seguro?".
Borrado lógico: la clínica no borra pacientes
-- NO se hace (borrado físico):
DELETE FROM pacientes WHERE id = 4;
-- SE HACE (borrado lógico — el modelo lo previó con la columna activo):
UPDATE pacientes SET activo = FALSE WHERE id = 4;
-- y todas las consultas de la app filtran:
SELECT * FROM pacientes WHERE activo;¿Por qué? Historia clínica: los datos de un paciente atendido NO se eliminan jamás (razones legales y de salud — su tipo de sangre puede salvarle la vida en una emergencia futura). El DELETE físico queda reservado para datos verdaderamente desechables.
Las FK frenan lo que no debe ocurrir
-- intenta borrar un médico CON citas:
DELETE FROM medicos WHERE id = 2;
-- ERROR 1451: Cannot delete or update a parent row:
-- a foreign key constraint fails (fk_citas_medico)El error 1451 es el hermano del 1452 del cap 8: ahora la FK te protege en la dirección contraria — no puedes dejar citas huérfanas. Opciones reales: borrado lógico (lo correcto aquí), borrar antes las citas (CASCADE lo haría automático — por eso NO lo configuramos), o reasignar las citas a otro médico con un UPDATE:
UPDATE citas SET medico_id = 3 WHERE medico_id = 2; -- reasignar
DELETE FROM medicos WHERE id = 2; -- ahora síUPDATE con JOIN: cambios en lote
-- desactivar a todos los médicos de una especialidad:
UPDATE medicos m
JOIN especialidades e ON e.id = m.especialidad_id
SET m.activo = FALSE
WHERE e.nombre = 'Oftalmología';MariaDB permite UPDATE ... JOIN para modificar una tabla según datos de otra. Y también acepta ORDER BY y LIMIT en UPDATE/DELETE (extensión propia — no existe en PostgreSQL ni SQL Server; apúntala en tu tabla de dialectos):
-- liberar las 5 citas PROGRAMADAS más antiguas sin respuesta:
UPDATE citas SET estado = 'CANCELADA'
WHERE estado = 'PROGRAMADA'
ORDER BY fecha_hora
LIMIT 5;La prueba de fuego: ¿qué pasa si...?
| Acción | Resultado | Quién lo frena |
|---|---|---|
| UPDATE sin WHERE | cambia TODA la tabla | NADIE — tu ritual |
| UPDATE con valor de tipo erróneo | error | strict mode (cap 10) |
| UPDATE viola CHECK | error | chk_monto_positivo (cap 8) |
| UPDATE rompe UNIQUE | error | uq_medico_fecha (cap 8) |
| DELETE de padre con hijos | error 1451 | FOREIGN KEY (cap 8) |
Mira la tabla: de las 5 catástrofes posibles, la base frena 4. La única que no puede frenar es el UPDATE sin WHERE — por eso existe el ritual, y por eso en el cap 31 activaremos el safe updates del cliente.
Puntos clave
- Ritual: SELECT → UPDATE (mismo WHERE) → SELECT de verificación.
- Borrado lógico (activo=FALSE) para datos con historia.
- Error 1451: la FK frenando huérfanos — buena señal, otra vez.
- UPDATE ... JOIN y ORDER BY/LIMIT: extensiones MariaDB.
- El UPDATE sin WHERE es la única catástrofe sin red: ritual SIEMPRE.
20 · UPSERT: INSERT que se convierte en UPDATE
Intermedio ~13 min"Si no existe, créalo; si existe, actualízalo" — la operación más común del mundo real (sincronizar catálogos, actualizar stock, refrescar sesiones). MariaDB le da tres caminos, uno de ellos peligroso.
- INSERT IGNORE: qué hace y qué calla.
- REPLACE: por qué es una trampa (borra y recrea).
- ON DUPLICATE KEY UPDATE: el camino profesional.
El problema: el choque de UNIQUE
-- la especialidad 'Cardiología' ya existe (cap 9): UNIQUE nombre choca
INSERT INTO especialidades (nombre, descripcion)
VALUES ('Cardiología', 'Actualización de descripción');
-- ERROR 1062: Duplicate entry 'Cardiología' for key 'nombre'Camino 1: INSERT IGNORE — silencioso y peligroso
INSERT IGNORE INTO especialidades (nombre, descripcion)
VALUES ('Cardiología', 'Actualización de descripción');
-- Query OK, 0 rows affected... ¡y NO actualizó NADA!INSERT IGNORE convierte el error en warning y sigue: la fila NO se creó NI se actualizó — la descripción nueva se perdió en silencio. Sirve para un caso muy específico ("carga masiva donde los duplicados se descartan"), pero como UPSERT es un espejismo: parece que funcionó y no hizo nada.
Camino 2: REPLACE — funciona... destruyendo
REPLACE INTO especialidades (nombre, descripcion)
VALUES ('Cardiología', 'Actualización de descripción');REPLACE sí "funciona", pero su mecánica interna es: borra la fila que choca e inserta una NUEVA. Consecuencias: el id cambia (¡nueva AUTO_INCREMENT!), las FK que apuntaban al viejo id o se rompen o cascadan, y los triggers de DELETE se disparan como si fuera un borrado. La doc misma lo marca como sensible a índices y FK. Es una bomba con relojito en tablas referenciadas.
Camino 3: ON DUPLICATE KEY UPDATE — el profesional
INSERT INTO especialidades (nombre, descripcion)
VALUES ('Cardiología', 'Actualización de descripción')
ON DUPLICATE KEY UPDATE
descripcion = VALUES(descripcion);
-- 2 rows affected la primera vez que actualiza (borra-nada, actualiza-todo)Si NO choca: INSERT normal. Si CHOCA (por cualquier UNIQUE/PK): ejecuta el
UPDATE indicado, fila existente intacta — mismo id, mismas FK, sin borrados.
La función VALUES(columna) recupera el valor que INTENTABAS
insertar, para asignarlo a la columna existente.
El caso real: sincronizar el catálogo
-- un archivo de especialidades que llega de la sede central:
INSERT INTO especialidades (nombre, descripcion) VALUES
('Cardiología', 'Corazón y sistema circulatorio — v2'),
('Pediatría', 'Atención de niños y adolescentes'),
('Nutrición', 'Planes alimentarios y metabolismo')
ON DUPLICATE KEY UPDATE
descripcion = VALUES(descripcion);
-- 1 insertada (Nutrición), 2 actualizadas: una sola sentenciaLos tres caminos, comparados
| INSERT IGNORE | REPLACE | ON DUP KEY UPDATE | |
|---|---|---|---|
| Si NO choca | inserta | inserta | inserta |
| Si choca | ignora (pierde datos) | BORRA + reinserta | UPDATE limpio |
| id de la fila | se conserva | ¡CAMBIA! | se conserva |
| FKs que apuntan | intactas | rotas o en cascada | intactas |
| Veredicto | casos de descarte masivo | EVITAR en tablas referenciadas | el camino por defecto |
Dialectos (apúntalo)
| Motor | UPSERT |
|---|---|
| MariaDB | INSERT ... ON DUPLICATE KEY UPDATE |
| PostgreSQL | INSERT ... ON CONFLICT (clave) DO UPDATE |
| SQL Server | MERGE (con sus advertencias de concurrencia) |
| SQLite | INSERT ... ON CONFLICT DO UPDATE (desde 3.24) |
Puntos clave
- INSERT IGNORE: parece UPSERT, no hace nada — pierdes el dato.
- REPLACE borra y recrea: cambia el id y rompe FKs.
- ON DUPLICATE KEY UPDATE + VALUES(col): el camino profesional.
- Cada motor tiene su sintaxis: la tabla de dialectos crece.
- El choque lo define cualquier UNIQUE/PK, no solo el id.
21 · Transacciones y niveles de aislamiento
Intermedio ~16 minUna transferencia bancaria son DOS operaciones: quitar a uno, dar al otro. Si el sistema se apaga entre ambas, el dinero desaparece. Las transacciones existen para que eso sea IMPOSIBLE: o todo ocurre, o nada ocurrió.
- Usar START TRANSACTION / COMMIT / ROLLBACK.
- Memorizar las propiedades ACID.
- Reproducir los 3 problemas de concurrencia clásicos.
- Elegir nivel de aislamiento con criterio (y saber el default).
La mecánica
START TRANSACTION;
UPDATE pagos SET estado = 'PAGADO' WHERE cita_id = 5;
INSERT INTO auditoria (tabla_afectada, operacion, usuario_app,
usuario_bd, registro_id)
VALUES ('pagos', 'UPDATE', 'rrosa', CURRENT_USER(), 5);
COMMIT; -- todo queda, atómico
-- o ROLLBACK; -- nada ocurrió, como si no hubieras escritoTodo lo que hagas entre START y COMMIT es una unidad. ROLLBACK deshace HASTA EL ÚLTIMO COMMIT — por eso el ritual del cap 19 es tan importante: un UPDATE sin WHERE dentro de una transacción se puede salvar con ROLLBACK, pero fuera de ella, no.
ACID: las 4 promesas
| Propiedad | Significado | Quién la cumple |
|---|---|---|
| Atomicidad | todo o nada | el motor (InnoDB) |
| Consistencia | de un estado válido a otro válido | tus constraints + transacciones |
| Isolación | las transacciones no se estorban | niveles de aislamiento (hoy) |
| Durabilidad | el COMMIT sobrevive al apagón | el motor (redo log) |
Nota que suele sorprender: la Consistencia es COMPARTIDA — el motor aporta la mecánica, pero las reglas son TUS constraints del cap 8. Sin FK ni CHECK, una transacción perfectamente "ACID" puede dejar datos inválidos.
Los 3 problemas de concurrencia
Dos recepcionistas trabajando a la vez sobre la misma cita:
| Problema | Escenario en la clínica |
|---|---|
| Lectura sucia (dirty read) | Rosa lee un pago PENDIENTE que Carlos acaba de insertar PERO aún no confirmó; Carlos hace ROLLBACK — Rosa cobró un pago que nunca existió |
| Lectura no repetible | Rosa consulta el total del día ($500); Carlos registra 3 pagos; Rosa vuelve a consultar para su cierre ($800) — la MISMA consulta, dos respuestas |
| Lectura fantasma | Rosa lista "citas de las 10:00" (5 filas); Carlos agenda una nueva cita a las 10:00; Rosa repite el listado: 6 filas — apareció un fantasma |
Los niveles de aislamiento
| Nivel | Sucio | No repetible | Fantasma | Costo |
|---|---|---|---|---|
| READ UNCOMMITTED | posible | posible | posible | mínimo |
| READ COMMITTED | no | posible | posible | bajo |
| REPEATABLE READ (default MariaDB) | no | no | posible* | medio |
| SERIALIZABLE | no | no | no | alto |
SELECT @@transaction_isolation; -- ¿dónde estoy?
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE; -- subir (con costo)*En MariaDB/InnoDB, REPEATABLE READ mitiga también los fantasmas con bloqueo de rango (next-key locking) en la mayoría de los casos — matiz documentado que lo hace aún más sólido que la tabla estándar SQL sugiere.
La regla práctica
Puntos clave
- START/COMMIT/ROLLBACK: unidad atómica de trabajo.
- ACID: la C es compartida — tus constraints son parte.
- 3 problemas: lectura sucia, no repetible, fantasma.
- MariaDB: REPEATABLE READ por defecto — no lo bajes nunca.
- Transacción corta: abrir-hacer-cerrar, sin pasear por medio.
22 · Tablas temporales y CREATE TABLE AS SELECT
Intermedio ~14 minA veces necesitas un espacio de trabajo: copiar datos para procesar, guardar un resultado intermedio, preparar una carga. MariaDB da dos herramientas para eso — y una regla para elegir entre ellas y las CTEs.
- Crear tablas TEMPORARY y entender su ciclo de vida.
- Duplicar estructura y datos con CREATE TABLE LIKE y AS SELECT.
- Respaldar una tabla antes de una operación riesgosa (patrón real).
- Decidir entre tabla temporal, CTE y vista.
TEMPORARY: vive en tu sesión, muere con ella
CREATE TEMPORARY TABLE tmp_deudores AS
SELECT c.id AS cita_id, p.apellido, p.nombre, c.motivo
FROM citas c
JOIN pacientes p ON p.id = c.paciente_id
WHERE c.estado = 'ATENDIDA';
SELECT * FROM tmp_deudores; -- trabajo normal sobre ella
CREATE INDEX idx_tmp ON tmp_deudores(cita_id); -- hasta indexable
-- al hacer EXIT o cerrar la conexión: DESAPARECE sola.Tres propiedades que la definen: es por conexión (Rosa y
Carlos pueden crear la MISMA tmp_deudores sin chocar — cada uno ve la suya),
no replica con el binlog por defecto, y no necesita limpieza: el motor la
suelta al terminar tu sesión. Si quieres soltarla antes:
DROP TEMPORARY TABLE tmp_deudores;
Las tres formas de crear desde otra tabla
-- 1) COPIA EXACTA (estructura + índices, sin datos):
CREATE TEMPORARY TABLE tmp_pacientes LIKE pacientes;
-- 2) ESTRUCTURA + DATOS de una consulta (CTAS):
CREATE TABLE respaldo_citas_2026 AS
SELECT * FROM citas WHERE YEAR(fecha_hora) = 2026;
-- 3) SOLO ESTRUCTURA de una consulta (sin datos):
CREATE TEMPORARY TABLE tmp_vacia AS
SELECT * FROM pagos WHERE FALSE;Matices que la doc marca: CREATE TABLE ... AS SELECT copia columnas y datos pero no copia índices, PK ni FK (solo la estructura básica) — si necesitas los índices, LIKE o créalos después. Y la tabla nueva no es TEMPORARY salvo que lo declares.
El patrón real: respaldo antes de operar
-- antes de una migración riesgosa de pagos:
CREATE TABLE pagos_bkp_20260825 AS
SELECT * FROM pagos;
SELECT COUNT(*) FROM pagos; -- 42
SELECT COUNT(*) FROM pagos_bkp_20260825; -- 42 — verificado
-- ... operación riesgosa ...
-- si salió bien y sobra el respaldo:
DROP TABLE pagos_bkp_20260825;Patrón de la vida diaria del DBA: copia con fecha en el nombre, verifica conteos, opera, y suelta el respaldo cuando confirmaste que todo está bien. En producción esto convive con los backups formales — es la red de seguridad de la operación PUNTUAL.
¿Tabla temporal, CTE o vista?
| TEMPORARY | CTE (cap 17) | VISTA (cap 26) | |
|---|---|---|---|
| Vive | tu conexión | una sentencia | para siempre (hasta DROP) |
| Puede indexarse/editarse | sí, es una tabla real | no | según la vista |
| Se comparte entre usuarios | no (por conexión) | no | sí |
| Úsala para | procesos multi-paso, lotes | consultas legibles | consultas reutilizadas por todos |
Puntos clave
- TEMPORARY: por conexión, se autodestruye, no choca entre usuarios.
- LIKE copia estructura+índices; AS SELECT no copia índices.
- Patrón respaldo: copia con fecha → verificar conteos → operar.
- Temp = proceso multi-paso · CTE = una consulta · Vista = todos.
- CTAS no es TEMPORARY salvo que lo declares.
23 · Ejercicio: la atención completa, atómica
Intermedio ~15 minCierre de Parte IV. El escenario real: el doctor termina la consulta y de un golpe se registran TRES cosas — el diagnóstico en la cita, la receta y el pago. Si algo falla a la mitad, la clínica queda con datos a medias. Transacción de verdad, con todo lo aprendido.
- Escribir la transacción completa de atención.
- Probar el ROLLBACK con un error provocado.
- Entender el rol de LAST_INSERT_ID dentro de la transacción.
- Cerrar Parte IV con el patrón transaccional de la app.
La transacción completa
START TRANSACTION;
-- 1) la cita queda ATENDIDA con su diagnóstico:
UPDATE citas
SET estado = 'ATENDIDA',
diagnostico = 'Hipertensión controlada. Continuar tratamiento.'
WHERE id = 5
AND estado = 'PROGRAMADA'; -- guarda: solo si sigue programada
-- 2) la receta nace ligada a esa cita:
INSERT INTO recetas (cita_id, medicamento, dosis, indicaciones, duracion_dias)
VALUES (LAST_INSERT_ID() * 0 + 5, -- el id de la cita, explícito
'Enalapril 10mg', '1 tableta cada 24h',
'Después del desayuno', 30);
-- 3) el pago queda PENDIENTE (el cobro va aparte):
INSERT INTO pagos (cita_id, monto, metodo, estado, fecha)
VALUES (5, 80.00, 'EFECTIVO', 'PENDIENTE', NOW());
COMMIT;Tres escrituras, una unidad. Si el INSERT de la receta falla (por ejemplo, ya existía una receta para esa cita — la UNIQUE del cap 8), el ROLLBACK devuelve la cita a PROGRAMADA y el pago fantasma desaparece. Estado válido garantizado.
Provocando el ROLLBACK (así se aprende)
START TRANSACTION;
UPDATE citas SET estado = 'ATENDIDA', diagnostico = 'prueba'
WHERE id = 6;
INSERT INTO recetas (cita_id, medicamento, dosis, duracion_dias)
VALUES (6, 'Paracetamol', '1 c/8h', 5);
-- segunda receta para la MISMA cita: viola uq_receta_cita
INSERT INTO recetas (cita_id, medicamento, dosis, duracion_dias)
VALUES (6, 'Ibuprofeno', '1 c/12h', 5);
-- ERROR 1062: Duplicate entry '6' for key 'uq_receta_cita'
ROLLBACK; -- la cita 6 vuelve a PROGRAMADA, sin diagnóstico, sin receta
SELECT estado, diagnostico FROM citas WHERE id = 6; -- verifícaloImportante: el ERROR no hace ROLLBACK solo — la transacción queda abierta y rota. TÚ decides: corriges la sentencia y sigues, o ROLLBACK y reinicias. En PHP (cap 31) será el bloque catch quien lance el rollback automático.
El detalle del LAST_INSERT_ID
En el paso 2 usé el id de la cita explícito (5) porque la cita YA existía. LAST_INSERT_ID() es para el id recién GENERADO — si la transacción fuera "nuevo paciente + su primera cita", el patrón sería:
START TRANSACTION;
INSERT INTO pacientes (documento, nombre, apellido, fecha_nacimiento)
VALUES ('48887766', 'Luis', 'Ccahuana', '1995-05-12');
INSERT INTO citas (paciente_id, medico_id, fecha_hora, estado, motivo)
VALUES (LAST_INSERT_ID(), -- el id del paciente recién creado
2, '2026-09-01 09:00:00', 'PROGRAMADA', 'Primera consulta');
COMMIT;Y como LAST_INSERT_ID() es por conexión (cap 9), funciona perfecto dentro de transacciones concurrentes: cada sesión ve SU id.
El patrón transaccional que usará la app
| Paso | En el cap 23 (SQL puro) | En el cap 31 (PHP/PDO) |
|---|---|---|
| 1. abrir | START TRANSACTION | $pdo->beginTransaction() |
| 2. operar | UPDATE + INSERTs | prepare + execute con parámetros |
| 3. confirmar | COMMIT | $pdo->commit() |
| 4. si explota | ROLLBACK manual | catch → rollBack() automático |
| 5. auditoría | — (aún sin triggers) | SET @usuario_app + triggers (cap 30) |
Puntos clave
- Tres escrituras, una unidad: cita + receta + pago.
- El error NO hace rollback solo: tú decides (o el catch, en PHP).
- LAST_INSERT_ID() por conexión: seguro dentro de transacciones.
- WHERE con guard (estado='PROGRAMADA') evita dobles atenciones.
- Este MISMO patrón reaparece en PDO con beginTransaction.
24 · Vistas: consultas con nombre
Intermedio ~13 minArranca Parte V: los objetos que viven DENTRO del motor. La vista es el más simple: una consulta guardada con nombre que se usa como si fuera una tabla. El reporte de deudores del cap 18 deja de ser un copy-paste y pasa a ser parte de la base.
- Crear vistas con CREATE VIEW y usarlas como tablas.
- Convertir los reportes del curso en vistas reutilizables.
- Actualizar vistas con CREATE OR REPLACE.
- Entender qué resuelve una vista (y qué NO).
La primera vista
CREATE VIEW citas_hoy AS
SELECT c.id, c.fecha_hora, c.estado,
CONCAT(p.apellido, ', ', p.nombre) AS paciente,
CONCAT(m.apellido, ', ', m.nombre) AS medico,
c.motivo
FROM citas c
JOIN pacientes p ON p.id = c.paciente_id
JOIN medicos m ON m.id = c.medico_id
WHERE c.fecha_hora >= CURDATE()
AND c.fecha_hora < CURDATE() + INTERVAL 1 DAY;
-- y se consulta como una tabla más:
SELECT * FROM citas_hoy;
SELECT paciente FROM citas_hoy WHERE estado = 'PROGRAMADA';Los JOINs del cap 15 se escribieron UNA vez. Desde hoy, cualquier persona de la clínica (o cualquier programa) consulta citas_hoy sin saber que detrás hay dos JOINs. La vista NO copia datos: es una consulta almacenada que el motor ejecuta cuando la llamas — siempre fresca.
El catálogo de vistas de la clínica
-- el reporte de deudores del cap 18, ahora institucional:
CREATE VIEW pacientes_deudores AS
WITH pagado AS (
SELECT cita_id, SUM(monto) AS abonado
FROM pagos WHERE estado = 'PAGADO'
GROUP BY cita_id
)
SELECT c.id AS cita_id, p.apellido, p.nombre, c.fecha_hora,
COALESCE(pg.monto, 0) - COALESCE(pd.abonado, 0) AS saldo
FROM citas c
JOIN pacientes p ON p.id = c.paciente_id
LEFT JOIN pagos pg ON pg.cita_id = c.id AND pg.estado = 'PENDIENTE'
LEFT JOIN pagado pd ON pd.cita_id = c.id
WHERE c.estado = 'ATENDIDA';
-- una vista puede apoyarse en otra:
CREATE VIEW deudores_graves AS
SELECT * FROM pacientes_deudores WHERE saldo >= 100;
SELECT * FROM deudores_graves ORDER BY saldo DESC;Mantenerlas: CREATE OR REPLACE
-- cambiar la definición sin DROP (no pierde permisos asociados):
CREATE OR REPLACE VIEW citas_hoy AS
SELECT c.id, c.fecha_hora, c.estado,
CONCAT(p.apellido, ', ', p.nombre) AS paciente,
CONCAT(m.apellido, ', ', m.nombre) AS medico,
c.motivo, c.diagnostico -- columna nueva
FROM citas c
JOIN pacientes p ON p.id = c.paciente_id
JOIN medicos m ON m.id = c.medico_id
WHERE c.fecha_hora >= CURDATE()
AND c.fecha_hora < CURDATE() + INTERVAL 1 DAY;
SHOW CREATE VIEW citas_hoy\G -- ver su definición guardadaQué resuelve una vista (y qué no)
| Resuelve | No resuelve | |
|---|---|---|
| Reutilización | una consulta compleja, escrita una vez | — |
| Simplicidad | la app consulta "deudores", no 4 JOINs | — |
| Seguridad | permisos sobre la VISTA sin tocar las tablas (cap 29) | — |
| Velocidad | — | NO es más rápida que su consulta (no hay cache mágico) |
| Parámetros | — | una vista NO recibe argumentos — para eso: procedures (cap 25) |
Puntos clave
- CREATE VIEW: consulta guardada, siempre fresca, sin copia de datos.
- Las vistas se apoyan entre sí (deudores → deudores_graves).
- CREATE OR REPLACE actualiza sin perder permisos.
- No son más rápidas ni aceptan parámetros.
- Seguridad: puedes dar acceso a la vista sin dar las tablas.
25 · Procedures: la lógica que vive en el motor
Intermedio ~16 minEl procedure es un PROGRAMA guardado en la base: recibe parámetros, valida reglas de negocio y ejecuta escrituras — todo junto, atómico y con permisos propios. Hoy construimos agendar_cita, la operación más delicada de la clínica.
- Entender DELIMITER (y por qué es un comando del CLIENTE).
- Crear agendar_cita con validaciones y SIGNAL para errores.
- Usar parámetros IN y OUT con CALL.
- Conocer las reglas del cuerpo: DECLARE, BEGIN-END, transacciones.
DELIMITER: del cliente, no del servidor
El cuerpo de un procedure está lleno de punto y coma — pero el cliente
mariadb interpreta el primer ; como "envía la sentencia" y
mandaría la definición a medias (ERROR 1064). Solución del CLI: cambiar el
terminador temporalmente:
DELIMITER //
CREATE PROCEDURE p() BEGIN SELECT 1; END //
DELIMITER ;Detalle verificado en la doc: DELIMITER es un comando del CLIENTE — el servidor jamás lo recibe. Por eso en HeidiSQL/DBeaver existe un campo "delimiter" y en PHP no lo necesitas (envías el CREATE completo de una vez).
agendar_cita: la operación completa
DELIMITER //
CREATE PROCEDURE agendar_cita(
IN p_paciente_id INT,
IN p_medico_id INT,
IN p_fecha_hora DATETIME,
IN p_motivo VARCHAR(255)
)
BEGIN
DECLARE v_count INT;
-- el paciente existe y está activo:
SELECT COUNT(*) INTO v_count
FROM pacientes WHERE id = p_paciente_id AND activo;
IF v_count = 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Paciente inexistente o inactivo';
END IF;
-- el médico existe y está activo:
SELECT COUNT(*) INTO v_count
FROM medicos WHERE id = p_medico_id AND activo;
IF v_count = 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Medico inexistente o inactivo';
END IF;
-- regla de la doble agenda (uq_medico_fecha, cap 8):
SELECT COUNT(*) INTO v_count
FROM citas
WHERE medico_id = p_medico_id AND fecha_hora = p_fecha_hora;
IF v_count > 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'El medico ya tiene una cita en ese horario';
END IF;
INSERT INTO citas (paciente_id, medico_id, fecha_hora, estado, motivo)
VALUES (p_paciente_id, p_medico_id, p_fecha_hora, 'PROGRAMADA', p_motivo);
END //
DELIMITER ;Usarlo
CALL agendar_cita(1, 2, '2026-09-10 09:00:00', 'Control mensual');
-- y el caso de error, con el mensaje NUESTRO:
CALL agendar_cita(1, 2, '2026-09-10 09:00:00', 'Doble agenda');
-- ERROR 1644 (45000): El medico ya tiene una cita en ese horarioSIGNAL SQLSTATE '45000' es el mecanismo estándar para lanzar errores propios: el cliente recibe ERROR 1644 (45000) con TU mensaje (hasta 512 caracteres) — y en PHP será una excepción en el try/catch. La validación vive EN la base: cualquier camino que agende citas (CLI, PHP, otro procedure) pasa por las mismas reglas.
Parámetros OUT: respuestas del procedure
DELIMITER //
CREATE PROCEDURE contar_citas_paciente(
IN p_paciente_id INT,
OUT p_total INT,
OUT p_atendidas INT
)
BEGIN
SELECT COUNT(*),
SUM(estado = 'ATENDIDA')
INTO p_total, p_atendidas
FROM citas
WHERE paciente_id = p_paciente_id;
END //
DELIMITER ;
CALL contar_citas_paciente(1, @total, @atendidas);
SELECT @total AS total, @atendidas AS atendidas;Los OUT se reciben en VARIABLES DE USUARIO (@total) — que son exactamente las protagonistas del próximo capítulo. Notas de doc: IN es el default; el DEFAULT en parámetros existe solo desde MariaDB 11.8; y el orden dentro del cuerpo es obligatorio: primero DECLARE variables, luego condiciones, cursores y handlers.
Reglas del cuerpo que ya sabes usar
| Constructo | Nota del curso |
|---|---|
| IF ... THEN ... ELSEIF ... END IF; | decisiones del negocio |
| SELECT ... INTO variable | capturar valores sin devolver resultset |
| START TRANSACTION / COMMIT | permitido EN procedures (la función del cap 26 NO puede) |
| BEGIN ... END anidados | bloques internos; un BEGIN suelto NO inicia transacción |
| LOOP / WHILE / REPEAT | existen; el 95% de procedures correctos no los necesita (SQL piensa en conjuntos) |
Puntos clave
- DELIMITER: comando del cliente para no partir el cuerpo en dos.
- SIGNAL SQLSTATE '45000' + MESSAGE_TEXT: errores con TU mensaje (1644).
- SELECT ... INTO para capturar; IN default; OUT llega a @variables.
- DECLARE en orden: variables → conditions → cursores → handlers.
- Integridad de negocio en la base; flujo de la app, en la app.
26 · Funciones almacenadas
Intermedio ~13 minLa función almacenada es el procedure que DEVUELVE un valor y se usa DENTRO de una expresión — como las funciones del cap 13, pero hechas por ti. Hoy: la edad exacta del paciente y el saldo del deudor, como funciones de la clínica.
- Crear funciones con RETURNS y el requisito DETERMINISTIC/READS SQL DATA.
- Entender el error 1418 (binlog) y qué significa realmente.
- Usarlas en SELECT y WHERE como funciones nativas.
- Elegir entre función y procedure con criterio.
fn_edad: la edad que nunca miente
DELIMITER //
CREATE FUNCTION fn_edad(p_nacimiento DATE)
RETURNS INT
READS SQL DATA
BEGIN
RETURN TIMESTAMPDIFF(YEAR, p_nacimiento, CURDATE());
END //
DELIMITER ;
-- uso: exactamente como una función nativa:
SELECT nombre, apellido, fn_edad(fecha_nacimiento) AS edad
FROM pacientes
LIMIT 5;
-- y en el WHERE:
SELECT nombre, apellido, fn_edad(fecha_nacimiento) AS edad
FROM pacientes
WHERE fn_edad(fecha_nacimiento) >= 65
ORDER BY edad DESC;El requisito DETERMINISTIC y el error 1418
Si tu servidor tiene el binlog activado (común en producción), CREATE FUNCTION exige declarar una característica — y falla con el error 1418 si no la lleva. Las opciones, según la doc:
| Característica | Significa | Para fn_edad |
|---|---|---|
| DETERMINISTIC | misma entrada → misma salida, siempre | NO (depende de CURDATE()) |
| NOT DETERMINISTIC | puede variar | ✓ este es |
| NO SQL | no toca datos | no (lee fecha_nacimiento... aquí ni lo lee: usa el parámetro) |
| READS SQL DATA | lee tablas pero no modifica | si accediera a tablas |
| MODIFIES SQL DATA | modifica datos | prohibido en funciones |
fn_edad usa CURDATE(): hoy devuelve 36 y mañana 37 con la MISMA entrada — es NOT DETERMINISTIC por naturaleza. Declararlo mal no rompe nada HOY, pero sí en replicación: la doc es honesta — MariaDB no verifica que seas verdad, confía en tu declaración. Declárala con conciencia.
fn_saldo: la función que lee la base
DELIMITER //
CREATE FUNCTION fn_saldo_paciente(p_paciente_id INT)
RETURNS DECIMAL(10,2)
READS SQL DATA
BEGIN
DECLARE v_saldo DECIMAL(10,2) DEFAULT 0;
SELECT COALESCE(SUM(CASE pg.estado
WHEN 'PENDIENTE' THEN pg.monto
ELSE 0 END), 0)
INTO v_saldo
FROM pagos pg
JOIN citas c ON c.id = pg.cita_id
WHERE c.paciente_id = p_paciente_id;
RETURN v_saldo;
END //
DELIMITER ;
-- los deudores, en una línea:
SELECT apellido, nombre, fn_saldo_paciente(id) AS saldo
FROM pacientes
HAVING saldo > 0
ORDER BY saldo DESC;Nota las reglas del cuerpo de una función (doc literal): NO puede devolver un resultset (un SELECT suelto está prohibido — pero SELECT ... INTO es válido) y NO puede hacer COMMIT/ROLLBACK. Si necesitas eso, es un procedure.
Función vs procedure: la decisión
| FUNCTION | PROCEDURE | |
|---|---|---|
| Devuelve | UN valor (RETURNS) | resultsets, OUTs, nada |
| Se invoca | dentro de expresiones (SELECT, WHERE) | CALL, como sentencia |
| Puede modificar datos | NO | sí |
| Puede hacer COMMIT | NO | sí |
| En la clínica | fn_edad, fn_saldo (cálculos) | agendar_cita (operaciones) |
Puntos clave
- RETURNS + RETURN: un valor, usable en expresiones.
- Con binlog: DETERMINISTIC/READS SQL DATA o error 1418.
- Declara características con conciencia: el motor confía en ti.
- Función: calcula y no modifica · Procedure: opera y modifica.
- Sin resultset ni COMMIT dentro de una función.
27 · Variables de sesión: el canal hacia la auditoría
Avanzado ~14 minEl capítulo que resuelve la pregunta fundacional del curso: la aplicación sabe QUIÉN es Rosa (usuarios_app), pero el motor solo ve la cuenta de conexión (curso). ¿Cómo le pasamos ese dato? Variables de sesión: el canal oficial entre tu aplicación y los objetos del motor.
- Asignar y leer variables de usuario (@variable).
- Entender su alcance: viven en TU sesión, mueren al cerrarla.
- Confirmar que procedures y triggers pueden leerlas.
- Montar el canal usuario_app que el trigger del cap 28 consumirá.
La mecánica
SET @usuario_app = 'rrosa';
SELECT @usuario_app; -- 'rrosa'
SELECT @inexistente; -- NULL (nunca asignada en esta sesión)
-- también con := dentro de un SELECT:
SELECT @total := COUNT(*) FROM citas;
-- y con SELECT ... INTO (cap 25):
SELECT COUNT(*) INTO @atendidas FROM citas WHERE estado = 'ATENDIDA';La doc fija el contrato: las variables de usuario existen en la sesión y expiran cuando la sesión se cierra. No se declaran con tipo (empiezan NULL), no se comparten entre conexiones, y no requieren permisos especiales.
El alcance, demostrado
-- Terminal de Rosa:
SET @usuario_app = 'rrosa';
SELECT @usuario_app; -- 'rrosa'
-- Terminal de Carlos (OTRA conexión, al mismo tiempo):
SELECT @usuario_app; -- NULL — ¡su sesión es otro mundo!
-- Rosa cierra su cliente y vuelve a entrar:
SELECT @usuario_app; -- NULL — murió con la sesiónExactamente el comportamiento que la auditoría necesita: el valor vive solo mientras vive la conexión que lo declaró, y nadie más lo ve ni lo pisa.
El canal completo, ya operativo
-- 1) la app abre conexión y DECLARA quién opera (siempre, primero):
SET @usuario_app = 'rrosa';
-- 2) cualquier operación posterior es "firmada":
UPDATE pacientes
SET telefono = '987111999'
WHERE id = 1;
-- 3) y un procedure puede FIRMAR en tu nombre, leyendo la variable:
DELIMITER //
CREATE PROCEDURE registrar_cambio(
IN p_tabla VARCHAR(64),
IN p_operacion VARCHAR(10),
IN p_registro INT
)
BEGIN
INSERT INTO auditoria (tabla_afectada, operacion,
usuario_app, usuario_bd, registro_id, fecha_hora)
VALUES (p_tabla, p_operacion,
@usuario_app, -- la persona (canal de sesión)
CURRENT_USER(), -- la cuenta de conexión (la aporta el motor)
p_registro, NOW());
END //
DELIMITER ;
CALL registrar_cambio('pacientes', 'UPDATE', 1);
SELECT usuario_app, usuario_bd, operacion, fecha_hora
FROM auditoria
ORDER BY id DESC LIMIT 1;
-- rrosa | curso@localhost | UPDATE | ... ← LA DUPLA COMPLETADoc literal verificada: las variables de usuario se comparten entre varias consultas y programas almacenados — procedures y triggers las leen sin trucos. El procedure de arriba es la prueba: @usuario_app llegó desde tu sesión hasta la fila de auditoría.
CURRENT_USER() vs USER(): el matiz fino
-- conectado como curso@localhost:
SELECT USER(), CURRENT_USER();
-- curso@localhost | curso@localhost (coinciden... casi siempre)
-- la diferencia aparece con proxy/roles: USER() es quien DICEN ser;
-- CURRENT_USER() es la cuenta con la que el motor te autenticó.
-- Para auditoría: CURRENT_USER() — es la cuenta con permisos reales.La advertencia documentada
Puntos clave
- SET @var: canal por sesión — privado y efímero.
- Procedures y triggers la leen (doc literal: shared con stored programs).
- Patrón: abrir conexión → SET @usuario_app → operar firmado.
- Para auditoría: CURRENT_USER() (cuenta real, no la declarada).
- Nunca leer y asignar @var en la misma sentencia (doc advierte).
28 · Triggers: la auditoría que se firma sola
Avanzado ~16 minEl capítulo que la serie venía prometiendo. Hoy los triggers conectan TODO: cualquier UPDATE sobre pacientes queda firmado automáticamente con la dupla usuario_app + usuario_bd — sin que el programador recuerde llamar a nada.
- Crear triggers AFTER para auditar INSERT/UPDATE/DELETE.
- Usar NEW y OLD según el evento.
- Cancelar operaciones inválidas con BEFORE + SIGNAL.
- Probar la auditoría dual completa de punta a punta.
La anatomía
CREATE TRIGGER nombre
{BEFORE | AFTER} {INSERT | UPDATE | DELETE}
ON tabla FOR EACH ROW
BEGIN
...cuerpo...
ENDTres decisiones de diseño en la propia cabecera: CUÁNDO (BEFORE: antes de que la fila se escriba — puede cancelar; AFTER: después — la fila ya es final), QUÉ EVENTO (uno por trigger) y FOR EACH ROW (obligatorio en MariaDB: el trigger corre por cada fila afectada, la doc no soporta FOR EACH STATEMENT).
La auditoría dual de pacientes
DELIMITER //
CREATE TRIGGER trg_pacientes_upd
AFTER UPDATE ON pacientes
FOR EACH ROW
BEGIN
INSERT INTO auditoria (tabla_afectada, operacion,
usuario_app, usuario_bd, registro_id,
fecha_hora, datos_anteriores, datos_nuevos)
VALUES ('pacientes', 'UPDATE',
COALESCE(@usuario_app, '(sin app)'), CURRENT_USER(),
NEW.id, NOW(),
CONCAT('tel=', OLD.telefono, '; activo=', OLD.activo),
CONCAT('tel=', NEW.telefono, '; activo=', NEW.activo));
END //
CREATE TRIGGER trg_pacientes_ins
AFTER INSERT ON pacientes
FOR EACH ROW
BEGIN
INSERT INTO auditoria (tabla_afectada, operacion,
usuario_app, usuario_bd, registro_id, fecha_hora)
VALUES ('pacientes', 'INSERT',
COALESCE(@usuario_app, '(sin app)'), CURRENT_USER(),
NEW.id, NOW());
END //
DELIMITER ;Las reglas de NEW/OLD, doc literal: en INSERT solo existe NEW (la fila nueva); en DELETE solo OLD (la que se va); en UPDATE existen AMBOS — OLD es el antes, NEW el después. Por eso el trigger de UPDATE puede registrar qué cambió exactamente.
La prueba de fuego
SET @usuario_app = 'rrosa'; -- la app declara quién opera
UPDATE pacientes SET telefono = '987999888' WHERE id = 1;
SELECT usuario_app, usuario_bd, operacion,
datos_anteriores, datos_nuevos, fecha_hora
FROM auditoria ORDER BY id DESC LIMIT 1;
-- rrosa | curso@localhost | UPDATE
-- tel=987111999; activo=1 | tel=987999888; activo=1 | ... ← ¡AUTOMÁTICO!Compara con el cap 19: allá el UPDATE manual no dejaba rastro y había que llamar a registrar_cambio a mano. Ahora el motor firma SOLO, leyendo @usuario_app del canal del cap 27. Ni un cambio sin testigo.
BEFORE + SIGNAL: el guardián que cancela
DELIMITER //
CREATE TRIGGER trg_pacientes_valida
BEFORE INSERT ON pacientes
FOR EACH ROW
BEGIN
IF NEW.sexo IS NOT NULL AND NEW.sexo NOT IN ('M', 'F') THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'sexo debe ser M o F';
END IF;
IF NEW.fecha_nacimiento > CURDATE() THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'fecha_nacimiento no puede ser futura';
END IF;
END //
DELIMITER ;
-- el guardián en acción:
INSERT INTO pacientes (documento, nombre, apellido,
fecha_nacimiento, sexo)
VALUES ('41112222', 'Prueba', 'Futura', '2030-01-01', 'F');
-- ERROR 1644 (45000): fecha_nacimiento no puede ser futuraDoc literal: si un trigger BEFORE contiene un error (o lanza SIGNAL), la sentencia original se cancela — la fila jamás se inserta. Es la capa de validación más fuerte que existe: ni el programador más distraído puede saltársela, porque no vive en ningún programa.
Reglas de convivencia
| Regla | Detalle |
|---|---|
| Múltiples triggers | desde 10.2.3 puedes tener VARIOS por timing/evento, ordenados con FOLLOWS/PRECEDES |
| BEFORE puede modificar | SET NEW.columna = valor (con permiso UPDATE); en AFTER no tiene efecto |
| OLD es read-only | siempre, en todos los eventos |
| Drop seguro | DROP TRIGGER IF EXISTS nombre; |
| @usuario_app en triggers | funciona (el trigger corre en la sesión del invocador); la doc no lo documenta explícitamente para triggers — probado en este curso, úsalo consciente |
Puntos clave
- AFTER para auditar (fila final); BEFORE para validar y cancelar.
- INSERT: solo NEW · DELETE: solo OLD · UPDATE: ambos.
- FOR EACH ROW obligatorio; SIGNAL en BEFORE cancela la sentencia.
- SET NEW.col en BEFORE: normalización automática de datos.
- Auditoría dual automática: @usuario_app + CURRENT_USER().
29 · Roles y permisos: mínimo privilegio
Avanzado ~15 minLa última pieza de seguridad del servidor. El principio es simple y radical: cada cuenta puede EXACTAMENTE lo que necesita, y nada más. Hoy la conexión de la app pierde el acceso directo a las tablas — y gana el mundo a través de vistas y procedures.
- Crear roles y asignarlos a usuarios.
- Conceder permisos quirúrgicos (GRANT sobre vistas y procedures).
- Auditar permisos con SHOW GRANTS.
- Cerrar el modelo de seguridad completo de la clínica.
Roles: permisos con nombre
CREATE ROLE rol_recepcion, rol_medico, rol_admin;
-- recepción: agenda y consulta, no toca diagnósticos:
GRANT SELECT ON clinica.pacientes_deudores TO rol_recepcion;
GRANT SELECT ON clinica.citas_hoy TO rol_recepcion;
GRANT EXECUTE ON PROCEDURE clinica.agendar_cita TO rol_recepcion;
-- médico: consulta todo, atiende y receta:
GRANT SELECT ON clinica.* TO rol_medico;
GRANT EXECUTE ON PROCEDURE clinica.agendar_cita TO rol_medico;
-- el rol se asigna al usuario y se activa:
GRANT rol_recepcion TO 'curso'@'localhost';
SET DEFAULT ROLE rol_recepcion TO 'curso'@'localhost';El patrón es de gestión de personas: los permisos se diseñan por ROL (el puesto de trabajo) y los usuarios heredan. Cuando Rosa cambia de área, le quitas el rol — cero revisión de permisos uno por uno.
El movimiento maestro: quitar las tablas a la app
-- la conexión de la aplicación NO necesita leer tablas directas:
REVOKE ALL PRIVILEGES ON clinica.* FROM 'curso'@'localhost';
-- solo lo que la app usa:
GRANT SELECT ON clinica.citas_hoy TO 'curso'@'localhost';
GRANT SELECT ON clinica.pacientes_deudores TO 'curso'@'localhost';
GRANT EXECUTE ON PROCEDURE clinica.agendar_cita TO 'curso'@'localhost';
GRANT EXECUTE ON PROCEDURE clinica.registrar_cambio TO 'curso'@'localhost';
SHOW GRANTS FOR 'curso'@'localhost';Resultado: si mañana inyectan código a tu app (cap 30), el atacante NO
puede hacer DROP TABLE pacientes ni leer la tabla de usuarios —
su cuenta no tiene esos permisos. Solo puede llamar los procedures que TÚ
expusiste, que ya validan todo (cap 25). El daño posible queda encerrado en
jaula que tú diseñaste.
El mapa de permisos de la clínica
| Cuenta | Puede | No puede |
|---|---|---|
| root | todo (solo administración, jamás en la app) | — |
| curso (app) | SELECT en vistas + EXECUTE en procedures | leer tablas directas, DDL, borrar |
| rol_recepcion | agenda, consulta reportes | diagnósticos, borrar pacientes |
| rol_medico | consulta total, atender, recetar | administrar usuarios |
| rol_admin | todo el esquema clinica | tocar bases del sistema |
Verificación y orden de salida
-- ¿qué puede esta cuenta exactamente?
SHOW GRANTS FOR 'curso'@'localhost';
-- ¿qué roles tiene activos mi sesión actual?
SELECT CURRENT_USER();
-- al reconectar, el default role ya está activo
-- quitar un rol (sin borrar al usuario):
REVOKE rol_recepcion FROM 'curso'@'localhost';
-- borrar un rol completo:
DROP ROLE rol_recepcion;El modelo de seguridad completo, revisado
| Capa | Herramienta | Capítulo |
|---|---|---|
| Quién usa la app | usuarios_app + hash bcrypt | 6, 9, 31 |
| Quién se conecta al motor | cuenta mínima + roles | 4, hoy |
| Qué puede tocar esa cuenta | GRANT quirúrgico: vistas + EXECUTE | hoy |
| Quién hizo cada cambio | @usuario_app + triggers + CURRENT_USER() | 27, 28 |
| Qué datos son válidos | constraints + CHECK + ENUM | 8 |
Puntos clave
- Permisos por ROL (puesto), no por persona.
- La cuenta de la app: sin tablas directas — solo vistas + EXECUTE.
- SHOW GRANTS: la radiografía de cualquier cuenta.
- Atacante con cuenta mínima = daño encerrado en tu jaula.
- Cinco capas de seguridad trabajando juntas.
30 · Inyección SQL: el enemigo y su antídoto
Avanzado ~15 minArranca Parte VI. La inyección SQL lleva décadas encabezando las listas de vulnerabilidades web, y sigue funcionando contra sistemas nuevos cada semana. Hoy la ejecutamos contra una versión mal hecha de nuestra propia clínica — y la desactivamos para siempre.
- Ejecutar un ataque de inyección contra código concatenado.
- Entender por qué escapar comillas NO basta.
- Conocer PREPARE/EXECUTE: el antídoto a nivel SQL.
- Fijar la regla de oro que el cap 31 llevará a PHP.
El código vulnerable (no lo escribas nunca)
<?php
// VULNERABLE — el login de la clínica, versión ingenua:
$sql = "SELECT * FROM usuarios_app
WHERE usuario = '$usuario' AND hash_clave = '$clave'";
// el usuario escribe: admin' --
// y la clave lo que sea. El SQL resultante:
// SELECT * FROM usuarios_app
// WHERE usuario = 'admin' -- ' AND hash_clave = 'loquesea'
// El -- COMENTA el resto: validó el usuario y tiró la clave a la basura.La entrada del usuario se convirtió en CÓDIGO SQL. El atacante no adivinó
la clave: la eliminó de la ecuación. Y las variantes van de lo molesto a lo
criminal: '; DROP TABLE pacientes; -- con permisos de borrado,
o ' OR '1'='1 para volcar tablas enteras.
La demo en SQL puro
-- así "piensa" el atacante su entrada:
SELECT * FROM usuarios_app WHERE usuario = 'admin' -- ' AND clave='x';
-- y la versión que voltea el login completo:
SELECT * FROM usuarios_app
WHERE usuario = 'x' OR '1'='1' AND clave = 'y' OR '1'='1';
-- '1'='1' es siempre verdadero: devuelve TODOS los usuarios¿Y si escapo las comillas? No basta
| Defensa ingenua | Por qué falla |
|---|---|
| str_replace("'", "") | rompe apellidos legítimos (O'Brien) y tiene bypasses con codificación |
| addslashes / escapado manual | depende del charset: hay secuencias que eluden el escape (trampas utf8 clásicas) |
| listas negras de palabras (DROP, --) | el SQL tiene mil formas de decir lo mismo; pierdes siempre |
El problema de fondo no son las comillas: es que los datos viajan por el mismo canal que el código. La solución no es limpiar mejor el texto — es separar los canales.
El antídoto a nivel SQL: PREPARE
-- la consulta se envía en dos piezas SEPARADAS:
PREPARE stmt FROM
'SELECT id, usuario, rol FROM usuarios_app WHERE usuario = ?';
SET @u = 'admin'' --'; -- intento de ataque como DATO
EXECUTE stmt USING @u;
-- resultado: 0 filas. Buscó un usuario llamado literalmente admin' --
-- (que no existe). El ataque se convirtió en un nombre de usuario raro.
DEALLOCATE PREPARE stmt;El ? es un marcador: el motor recibe PRIMERO la estructura de
la consulta (ya compilada, con su plan) y DESPUÉS los valores — que llegan
como datos puros, sin capacidad de alterar la estructura. Un valor inyectado
puede, como mucho, no encontrar nada. La separación de canales, literal.
Las tres capas del curso, en orden
| Capa | Herramienta | Estado |
|---|---|---|
| Daño limitado si algo pasa | cuenta de app con mínimo privilegio (cap 29) | ✓ lista |
| Validación de entrada | constraints, ENUM, CHECK (cap 8) + validar en la app | ✓ lista |
| La defensa real | consultas preparadas SIEMPRE | cap 31 en PHP |
Puntos clave
- Concatenar entrada = el usuario escribe tu SQL.
- -- comenta lo que sigue: el login clásico roto.
- Escapar a mano NO basta: el problema es el canal compartido.
- PREPARE/EXECUTE: estructura y datos viajan separados.
- Defensa en profundidad: mínimo privilegio + validación + prepare.
31 · PHP + PDO: la conexión completa
Avanzado ~16 minEl encuentro final. Todo lo construido en 30 capítulos — modelo, constraints, procedures, triggers, roles, canal de sesión — se consume ahora desde PHP con PDO, el estándar de conexión que ya conoces de la serie (tienda_orm, MVC).
- Conectar con PDO y las tres opciones no negociables.
- Autenticar contra usuarios_app con password_verify.
- Declarar @usuario_app al abrir cada conexión.
- Ejecutar el flujo completo: login → agendar → auditoría.
La conexión
<?php
// app/Database.php — conexión única de la clínica
$pdo = new PDO(
'mysql:host=localhost;dbname=clinica;charset=utf8mb4',
'curso', // la cuenta de mínimo privilegio (cap 29)
'Clinica.2026',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);Tres opciones explicadas (verificadas contra php.net): ERRMODE_EXCEPTION
convierte errores en excepciones — sin ella, los fallos pasan en silencio;
FETCH_ASSOC devuelve arreglos asociativos como los que la serie ya usa; y
EMULATE_PREPARES = false es crítico: PDO emula los prepares
en el cliente POR DEFECTO — apagándolo, los preparados son reales del
servidor y la separación código/datos del cap 30 es física, no simulada.
Nota de doc: el prefijo del DSN es mysql: — no existe driver
mariadb: en PHP; el protocolo es el mismo y funciona directo.
El canal de sesión, desde PHP
<?php
// tras el login, CADA conexión declara quién opera:
$stmt = $pdo->prepare('SET @usuario_app = ?');
$stmt->execute([$_SESSION['usuario_app']]);
// blindaje alternativo si prefieres cero dudas documentales:
$pdo->exec('SET @usuario_app = ' . $pdo->quote($usuario));El prepare con placeholder funciona en la práctica; la doc de MySQL/MariaDB no muestra ese caso explícito, por eso el curso te da también la vía con quote() — ambas dejan el canal armado para que los triggers del cap 28 firmen.
El login contra usuarios_app
<?php
$stmt = $pdo->prepare(
'SELECT id, usuario, nombre, rol, hash_clave
FROM usuarios_app WHERE usuario = ? AND activo'
);
$stmt->execute([$_POST['usuario']]);
$usuario = $stmt->fetch();
if ($usuario && password_verify($_POST['clave'], $usuario['hash_clave'])) {
$_SESSION['usuario_app'] = $usuario['usuario'];
// y ARMAR el canal de la conexión:
$pdo->prepare('SET @usuario_app = ?')
->execute([$usuario['usuario']]);
}Preparada (cap 30), con hash bcrypt verificado (cap 9) y consultando solo usuarios activos. La clave jamás se compara en SQL: password_verify la contrasta en PHP contra el hash.
El flujo completo: login → agendar → auditoría
<?php
// Rosa ya hizo login; agenda una cita usando el PROCEDURE del cap 25:
try {
$stmt = $pdo->prepare('CALL agendar_cita(?, ?, ?, ?)');
$stmt->execute([1, 2, '2026-09-10 09:00:00', 'Control mensual']);
echo "Cita agendada\n";
} catch (PDOException $e) {
// el SIGNAL del procedure llega aquí como excepción:
echo "No se pudo agendar: ", $e->getMessage(), "\n";
}
// la auditoría ya registró TODO, sin que este código lo pidiera:
foreach ($pdo->query(
'SELECT usuario_app, usuario_bd, operacion
FROM auditoria ORDER BY id DESC LIMIT 1') as $fila) {
echo $fila['usuario_app'], ' / ', $fila['usuario_bd'], ' / ',
$fila['operacion'], "\n";
}
// rrosa / curso@localhost / INSERTTransacciones y procedures con OUT
<?php
// la transacción del cap 23, versión PDO (tabla pendiente de ese cap):
$pdo->beginTransaction(); // START TRANSACTION
try {
$pdo->prepare('UPDATE citas SET estado=?, diagnostico=? WHERE id=?')
->execute(['ATENDIDA', 'Control OK', 5]);
$pdo->prepare('INSERT INTO recetas (cita_id, medicamento, dosis, duracion_dias)
VALUES (?, ?, ?, ?)')
->execute([5, 'Enalapril 10mg', '1 c/24h', 30]);
$pdo->commit(); // todo queda
} catch (Throwable $e) {
$pdo->rollBack(); // nada ocurrió
throw $e;
}
// procedure con OUT: el driver no soporta bind OUT — patrón @variable:
$pdo->prepare('CALL contar_citas_paciente(?, @total, @atendidas)')
->execute([1]);
$total = $pdo->query('SELECT @total')->fetchColumn();
$atendidas = $pdo->query('SELECT @atendidas')->fetchColumn();Verificado contra php.net: commit()/rollBack() sin transacción activa lanzan PDOException (por eso el try/catch envuelve TODO, no solo los INSERTs), y el driver MySQL no soporta parámetros OUT bindeados — el patrón oficial es el @variable + relectura que ya dominas.
Puntos clave
- DSN mysql: (no existe mariadb:) + charset=utf8mb4 en el DSN.
- EMULATE_PREPARES=false: prepares REALES — apágalo siempre.
- Login: prepare + password_verify, nunca clave en el SQL.
- SET @usuario_app al abrir conexión = triggers firman solos.
- OUT de procedures vía @variable + SELECT (limitación del driver).
32 · Índices y EXPLAIN: consultas rápidas
Avanzado ~15 minCon 20 pacientes todo vuela; con 2 millones, cada consulta mal apoyada es un drama. El índice es la estructura que convierte una búsqueda exhaustiva en un salto directo — y EXPLAIN es la lupa que revela si el motor lo está usando.
- Entender qué es un índice (y su costo en escritura).
- Saber qué índices ya tienes gratis (PK, UNIQUE, FK).
- Aplicar la regla del prefijo izquierdo en índices compuestos.
- Leer EXPLAIN y cazar el temido type=ALL.
Qué es un índice
Un índice es una estructura ordenada (B-tree) que apunta a las filas: como el índice de un libro, te lleva directo a "Quispe" sin leer página por página. El costo: ocupa espacio y CADA INSERT/UPDATE/DELETE debe actualizarlo. No se indexa por indexar — se indexa lo que se CONSULTA.
Los que ya tienes gratis
| Fuente | Índice creado | Doc |
|---|---|---|
| PRIMARY KEY | sí, por definición | — |
| UNIQUE | índice único | "specifying a column as a unique key creates a unique index" |
| FOREIGN KEY | InnoDB lo crea SOLO si no existe | verificado en doc de FK |
Por eso las FK del cap 8 no solo protegen: ACELERAN. Cada c.paciente_id de los JOINs del cap 15 ya tiene su índice automático.
Índice compuesto y el prefijo izquierdo
-- el índice de la doble agenda es COMPUESTO:
-- uq_medico_fecha (medico_id, fecha_hora)
-- aprovecha el índice (prefijo izquierdo: medico_id está primero):
SELECT * FROM citas WHERE medico_id = 2;
SELECT * FROM citas WHERE medico_id = 2
AND fecha_hora >= '2026-08-01';
-- NO lo aprovecha (falta la primera columna):
SELECT * FROM citas WHERE fecha_hora >= '2026-08-01';Es como el directorio telefónico ordenado por apellido, nombre: encuentras por apellido, o por apellido+nombre — pero buscar solo por nombre requiere leerlo completo. El ORDEN de las columnas en un índice compuesto es una decisión de diseño.
EXPLAIN: preguntarle al motor su plan
EXPLAIN SELECT * FROM citas WHERE medico_id = 2\G
-- id: 1
-- select_type: SIMPLE
-- table: citas
-- type: ref ← el índice se usa
-- possible_keys: uq_medico_fecha
-- key: uq_medico_fecha
-- key_len: 4
-- ref: const
-- rows: 8 ← filas estimadas a revisar
-- Extra: Using index conditionLa columna type cuenta la historia. De peor a mejor (la
doc describe cada valor; la escala ordenada es convención de la industria):
ALL — "a full table scan is done... bad if the table is large" —,
luego index ("better than ALL but still bad"), range,
ref y const (una sola fila posible). Tu objetivo en
consultas calientes: NUNCA ALL sobre tablas grandes.
-- el mismo EXPLAIN con estadísticas REALES (ejecuta la consulta):
ANALYZE FORMAT=JSON SELECT * FROM citas WHERE medico_id = 2\G
-- añade r_rows (filas realmente leídas) vs rows (estimadas)Cuándo NO indexar
Puntos clave
- Índice = salto directo; costo = espacio + escrituras más lentas.
- PK, UNIQUE y FK ya te regalan índices.
- Compuesto: el orden importa — prefijo izquierdo o nada.
- EXPLAIN type=ALL en tabla grande = alarma roja.
- ANALYZE FORMAT=JSON: estimaciones vs realidad.
33 · Backup y restore
Avanzado ~14 minLa regla del oficio: un administrador se mide por sus respaldos, no por sus instalaciones. Hoy aprendemos a volcar la clínica completa — con sus procedures y triggers — y a restaurarla, con la herramienta moderna del ecosistema.
- Volcar la base completa con mariadb-dump.
- Entender --single-transaction, --routines y los triggers por defecto.
- Restaurar en una base limpia y verificar.
- Diseñar la estrategia de respaldos de la clínica.
El nombre moderno: mariadb-dump
Dato verificado en doc: desde MariaDB 11.0 el histórico
mysqldump quedó obsoleto — la herramienta se llama
mariadb-dump (el alias viejo fue eliminado incluso de la
imagen oficial de Docker). Los tutoriales antiguos dicen mysqldump; tú ya
sabes el nombre actual.
El volcado completo de la clínica
mariadb-dump -u curso -p \
--single-transaction \
--routines \
clinica > clinica_completa.sqlCada flag, con lo que la doc garantiza:
| Flag | Qué hace |
|---|---|
| --single-transaction | envía START TRANSACTION WITH CONSISTENT SNAPSHOT: con InnoDB vuelca un estado COHERENTE "sin bloquear ninguna aplicación" — la clínica sigue operando durante el dump |
| --routines | incluye procedures y funciones — NO es default: sin este flag pierdes agendar_cita |
| (triggers) | SÍ van por defecto ("dumps trigger along with tables") — se desactivan con --skip-triggers |
Otros útiles: --no-data (solo estructura — el esquema sin filas)
y --where (volcar solo filas que cumplan una condición). El dump
generado incluye los DELIMITER necesarios para recrear los procedures — el
problema del cap 25, resuelto por la herramienta.
Restaurar
# base limpia:
mariadb -u root -p -e "CREATE DATABASE clinica_restaurada
CHARACTER SET utf8mb4 COLLATE utf8mb4_spanish_ci"
# volcado dentro:
mariadb -u root -p clinica_restaurada < clinica_completa.sql
# verificación OBLIGATORIA (un backup sin prueba no es un backup):
mariadb -u curso -p clinica_restaurada \\
-e "SELECT COUNT(*) FROM pacientes; CALL agendar_cita(1,2,'2027-01-05 09:00:00','prueba');"La verificación no es opcional: consulta conteos y EJECUTA un procedure — así confirmas que tablas, datos Y rutinas llegaron completos. El backup que nunca se probó restaurando es una suposición, no un respaldo.
La estrategia de la clínica
| Capa | Frecuencia | Herramienta |
|---|---|---|
| Volcado lógico completo | diario (madrugada) | mariadb-dump --single-transaction --routines |
| Respaldos incrementales | continuo | binary log del servidor (registra cada cambio desde el último dump) |
| Copia fuera del servidor | diario | el .sql se COPIA a otro equipo — el backup en el mismo disco que los datos no es backup |
| Prueba de restauración | mensual | restaurar en una base de prueba y verificar |
Puntos clave
- mariadb-dump: el nombre moderno (mysqldump obsoleto desde 11.0).
- --single-transaction: coherencia InnoDB sin bloquear a nadie.
- --routines NO es default; los triggers SÍ van por defecto.
- Restaurar y VERIFICAR (conteos + procedure) es parte del backup.
- Copia fuera del servidor, siempre.
34 · Producción: el checklist del administrador
Avanzado ~13 minEl último capítulo técnico reúne los hábitos que separan una base de práctica de una base en producción: el modo seguro del cliente, el mantenimiento periódico y la mirada al servidor en vivo.
- Activar el modo seguro del cliente (SQL_SAFE_UPDATES).
- Mirar el servidor en vivo: SHOW PROCESSLIST y estado.
- Mantenimiento: ANALYZE TABLE y OPTIMIZE TABLE.
- Recorrer el checklist final de producción.
El cinturón de seguridad del cliente
# el modo seguro del administrador:
mariadb -u root -p -U clinica
# -U = --safe-updates (la doc lo llama también --i-am-dummy)Con el flag -U, el servidor rechaza UPDATE/DELETE sin clave en el WHERE o sin LIMIT (ERROR 1175). Y de regalo activa select-limit (SELECT acotado a 1000 filas sin LIMIT explícito) y max-join-size (JOINs gigantes abortados). Es literalmente un cinturón contra el UPDATE sin WHERE del cap 19 — el único desastre que la base no puede frenar por sí sola.
-- también como variable de sesión:
SET SQL_SAFE_UPDATES = 1;
-- y ahora esto es IMPOSIBLE:
UPDATE pacientes SET activo = FALSE;
-- ERROR 1175: You are using safe update mode...Mirar el servidor en vivo
-- ¿quién está conectado y qué está haciendo AHORA?
SHOW PROCESSLIST;
-- contadores del servidor desde el arranque:
SHOW GLOBAL STATUS LIKE 'Threads_connected';
SHOW GLOBAL STATUS LIKE 'Questions';
-- variables de configuración:
SHOW VARIABLES LIKE 'character_set_server';
SHOW VARIABLES LIKE 'max_connections';SHOW PROCESSLIST es el primer instrumento ante un "el sistema está
lento": muestra cada conexión, su estado (Query, Waiting for lock...) y
cuánto lleva. Si ves una consulta de hace 300 segundos, ahí está el
culpable — y puedes matarla con KILL id;.
Mantenimiento periódico
| Comando | Qué hace | Cuándo |
|---|---|---|
| ANALYZE TABLE citas | actualiza estadísticas de índices para el optimizador (desde 10.6, no bloquea InnoDB) | tras cargas masivas |
| OPTIMIZE TABLE pagos | defragmenta y recupera espacio tras muchos borrados/updates | mantenimiento mensual |
| SHOW TABLE STATUS | tamaño, motor, filas aproximadas por tabla | revisión puntual |
EL CHECKLIST DE PRODUCCIÓN
- [ ] Modo seguro activo en las sesiones administrativas (-U).
- [ ] Cuenta de la app con mínimo privilegio (cap 29) — root jamás en la app.
- [ ] utf8mb4 verificado en base, tablas y conexión PHP (caps 4, 31).
- [ ] Backup diario --single-transaction --routines + copia FUERA del servidor (cap 33).
- [ ] Restauración probada al menos una vez (y repetida cada mes).
- [ ] Triggers de auditoría activos y la tabla auditoria creciendo (cap 28).
- [ ] EXPLAIN revisado en las consultas calientes de la app (cap 32).
- [ ] strict mode activo (cap 10) — nadie lo apagó "para que funcione".
- [ ] SHOW PROCESSLIST revisado en horas pico, sin consultas zombis.
- [ ] El script bd_mysql_clinica.sql versionado en el repositorio.
Diez casillas. Una base que las cumple las diez puede fallar — pero jamás de forma silenciosa ni irreversible. Esa es la diferencia entre "tiene una base de datos" y "administra una base de datos".
Puntos clave
- -U (--safe-updates): el cinturón contra el UPDATE sin WHERE.
- SHOW PROCESSLIST: el primer diagnóstico ante la lentitud.
- ANALYZE tras cargas masivas; OPTIMIZE en mantenimiento mensual.
- Checklist de 10 puntos: producción sin sorpresas silenciosas.
- Todo lo del checklist ya lo construiste en este curso.
35 · Graduación + anexo: equivalencias entre motores
Meta ~15 minMeta del manual. Mapa final, examen de graduación y el ANEXO de consulta permanente: los tipos y sintaxis de MariaDB frente a los otros tres motores de la categoría — con esta columna resaltada.
- Repasar las 6 partes como un solo sistema.
- Aprobar el examen de graduación.
- Consultar el anexo de equivalencias (MariaDB resaltada).
- Saber el camino: PostgreSQL → SQL Server → SQLite.
El mapa final
| Parte | Logro |
|---|---|
| I · El motor (1-5) | por qué una BD, los 4 motores comparados, instalación Win/Ubuntu, CLI y gráficas |
| II · El modelo (6-10) | 8 tablas con propósito, tipos correctos, constraints como leyes, datos deterministas, NULL y strict mode |
| III · Consultas (11-18) | del primer SELECT a CTEs y los 5 reportes reales de la clínica |
| IV · Modificar (19-23) | ritual UPDATE, upsert profesional, transacciones ACID, temporales, la atención atómica |
| V · Objetos (24-29) | vistas, procedures, funciones, @usuario_app, triggers de auditoría dual, roles |
| VI · Producción (30-34) | inyección SQL derrotada, PDO completo, índices y EXPLAIN, backup, checklist |
EXAMEN DE GRADUACIÓN
- [ ] La base clinica creada desde cero: 8 tablas, constraints, ENUM, CHECK.
- [ ] Datos deterministas cargados y las 5 consultas del cap 18 dando los mismos resultados.
- [ ] agendar_cita rechazando doble agenda con TU mensaje de error.
- [ ] fn_edad y fn_saldo_paciente funcionando en SELECT y WHERE.
- [ ] Triggers de auditoría firmando la dupla @usuario_app + CURRENT_USER().
- [ ] La cuenta de la app sin acceso a tablas — solo vistas y EXECUTE.
- [ ] Un script PHP con PDO: login, SET @usuario_app, transacción completa.
- [ ] EXPLAIN sin type=ALL en las consultas calientes.
- [ ] Backup --single-transaction --routines restaurado y verificado.
ANEXO · Equivalencias entre motores (MariaDB resaltada)
| Concepto | MariaDB | PostgreSQL | SQL Server | SQLite |
|---|---|---|---|---|
| Entero | TINYINT / INT / BIGINT | SMALLINT / INT / BIGINT | TINYINT / INT / BIGINT | INTEGER |
| Booleano | TINYINT(1) / BOOLEAN | BOOLEAN real | BIT | INTEGER 0/1 |
| Dinero exacto | DECIMAL(p,s) máx 65/38 | NUMERIC / DECIMAL | DECIMAL / MONEY | NUMERIC |
| Texto corto | VARCHAR(n) | VARCHAR(n) | VARCHAR(n) | TEXT (afinidad) |
| Texto largo | TEXT | TEXT | VARCHAR(MAX) | TEXT |
| Fecha | DATE | DATE | DATE | TEXT ISO-8601 |
| Fecha+hora | DATETIME / TIMESTAMP (UTC, ≥11.5 hasta 2106) | TIMESTAMP / TIMESTAMPTZ | DATETIME2 | TEXT ISO-8601 |
| Binario | BLOB | BYTEA | VARBINARY(MAX) | BLOB |
| Auto-numérico | AUTO_INCREMENT | IDENTITY / SERIAL | IDENTITY(1,1) | rowid / AUTOINCREMENT |
| Primeras N filas | LIMIT n | LIMIT / FETCH FIRST | SELECT TOP n | LIMIT n |
| Concatenar | CONCAT(a, b) | a || b | a + b / CONCAT | a || b |
| Hoy | CURDATE() / NOW() | CURRENT_DATE / NOW() | GETDATE() | date('now') |
| IF por fila | IF(c, a, b) | CASE | CASE / IIF | CASE / IIF |
| UPSERT | ON DUPLICATE KEY UPDATE | ON CONFLICT DO UPDATE | MERGE | ON CONFLICT DO UPDATE |
| Tablas temporales | CREATE TEMPORARY TABLE | CREATE TEMP TABLE | #temp / SELECT INTO | CREATE TEMP TABLE |
| Procedures / triggers | sí (DELIMITER cliente) | sí (plpgsql, $$) | sí (T-SQL) | NO |
| Variables de sesión | @var | set_config / current_setting | SESSION_CONTEXT | NO (capa app) |
Guarda este anexo: es la chuleta oficial de la categoría. Cuando abras el manual de PostgreSQL, la misma tabla volverá con su columna resaltada y las verificaciones de esa doc.
Puntos clave finales
- 6 partes, un sistema: modelo → consultas → objetos → producción.
- Examen: 9 entregables verificables en tu propia base clinica.
- El anexo de tipos: tu chuleta para toda la categoría.
- El 90% del SQL que aprendiste es común a los 4 motores.
- Serie Bases de datos: MariaDB ✓ → PostgreSQL → SQL Server → SQLite.