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.

35 capítulos Bootstrap 5.3 Modo claro / oscuro Optimizado para móvil MariaDB 12.3 LTS
35
Capítulos
150+
Ejemplos de código
3
Niveles: básico a experto
1
Prerrequisito: PHP básico
Cómo usar este tutorial: sigue los capítulos en orden (el índice está en el menú si lees desde el móvil). Cada concepto se practica sobre la base clínica: mismo modelo que los manuales de PostgreSQL, SQL Server y SQLite, para comparar dialectos saltando entre cursos.

1 · Por qué una base de datos bien diseñada

Básico ~12 min

Antes 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ónHoja de cálculoMotor de BD
Dos recepcionistas agendan al mismo tiempouna pisa a la otraconcurrencia controlada
Paciente con documento repetidonadie se enterarestricción UNIQUE lo rechaza
Cita sin paciente registradoposible y silenciosallave foránea lo impide
"¿Cuánto facturó julio por especialidad?"fórmulas frágilesuna consulta SQL
"¿Quién borró esta receta?"imposible saberloauditorí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érminoEs...En el curso
Base de datosel conjunto organizado de datosclinica
Motor (SGBD)el software que la gestionaMariaDB 12.3
Modeloel diseño: tablas y relaciones8 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.

SQL es declarativo: le pides "los pacientes con cita hoy" y el motor encuentra el camino. En la hoja de cálculo tú eres el que busca; aquí tú especificas y el motor ejecuta. Ese cambio de rol es el primer salto mental del curso.

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 min

Este 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

MariaDBPostgreSQLSQL ServerSQLite
LicenciaGPL (libre)PostgreSQL (libre)comercial (Developer gratis)dominio público
Arquitecturacliente-servidorcliente-servidorcliente-servidorembebida (un archivo)
Fortalezaweb, simple y rápidael estándar de cumplimiento SQLempresas Windows/.NETembebido, cero administración
Procedures/triggerssí (potentes)sí (T-SQL)NO
Usuarios/rolesNO (archivo = permisos del SO)
Ideal para...sitios web, arranquesintegridad seria, datos complejosentornos corporativos Microsoftapps locales, móviles, IoT
En el cursobase de la comparacióndialecto "académico"dialecto T-SQLcontrapunto 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.

El 90% del SQL es común. SELECT, JOIN, GROUP BY, transacciones: idénticos en los cuatro. Lo que cambia son los tipos de dato, el UPSERT, los procedures y las variables de sesión. El curso enseña lo común una vez y se concentra en las diferencias donde de verdad importan.

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 min

MariaDB 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

  1. 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.
  2. Ejecuta el asistente: acepta la ruta por defecto (C:\Program Files\MariaDB {versión}).
  3. 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).
  4. Deja marcado "Install as Service" con el nombre sugerido (MariaDB) y el puerto 3306.
  5. 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_installation

El 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.

Nota de versiones: los repos de Ubuntu pueden traer una serie LTS algo anterior a la última. Si quieres la 12.3 exacta, el repositorio oficial de mariadb.org tiene instrucciones generadas por país y distribución (repo configurador en su web). Para APRENDER, la serie del repo de Ubuntu está perfecta: el 90% del curso es SQL que no cambia entre series.

Verificación en ambos sistemas

TareaWindows (PowerShell admin)Ubuntu
Versión del clientemariadb --versionmariadb --version
Estado del servicioGet-Service MariaDBsystemctl status mariadb
Arrancarnet start MariaDBsudo systemctl start mariadb
Detenernet stop MariaDBsudo 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.

Checklist de instalación: servicio corriendo ✓ · clave de root definida ✓ · mariadb --version responde ✓ · puerto 3306 libre ✓. Si los cuatro pasan, estás listo para el cliente.

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 min

El 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 mariadb

El 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 parado

utf8mb4 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.

Herramientas gráficas: HeidiSQL (viene con el instalador de Windows y ya tiene versiones para Linux y macOS), DBeaver y DBGate (ambos multi-motor y multiplataforma) y Adminer (gestión web completa en UN solo archivo PHP) son excelentes acompañantes — el cap 5 las compara. Úsalas para EXPLORAR; para APRENDER, el CLI no tiene reemplazo: te obliga a escribir el SQL de verdad.

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 min

Cierre 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

HerramientaQué esPunto fuerte
HeidiSQLmultiplataforma: Windows, Linux y macOSla histórica de MariaDB/MySQL; instaladores .exe/.deb/.rpm/App bundle; viene con el instalador de MariaDB en Windows
DBeaver Communitygráfica multiplataforma (Java)habla con LOS CUATRO motores del curso: una sola herramienta
DBGateescritorio 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
Adminerweb — UN solo archivo PHPgestión completa desde un adminer.php: tablas, vistas, procedures, triggers y export SQL/CSV; alternativa minimalista a phpMyAdmin
phpMyAdminweb (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

  1. Nueva conexión → elegir MariaDB (o MySQL — mismo protocolo).
  2. Servidor: localhost · Puerto: 3306.
  3. Usuario: curso · Clave: la que definiste en el cap 4.
  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?

TareaHerramienta idealPor qué
Aprender SQL (este curso)CLIte obliga a escribir; el dedo aprende
Explorar estructura, datos de una tablagráficavisual y con filtros instantáneos
Diseñar/editar tablas puntualmentegráficagenera el DDL por ti (luego LÉELO)
Depurar una consulta lentaambasgráfica para ver planes, CLI para repetir
Scripts del curso y de producciónCLI / archivosversionables y repetibles
Seguridad con Adminer: su propia documentación lo advierte — no permitas acceso público sin protección. Su autor recomienda restringirlo por IP, protegerlo con clave a nivel de web server o BORRARLO cuando no se use: es un solo archivo, se vuelve a subir en un minuto. Un adminer.php abierto al mundo es una puerta giratoria para cualquiera que intente claves.
Advertencia de aprendizaje: las gráficas editan tablas con clics y te generan el SQL. Está bien para explorar, pero si dejas que la herramienta piense por ti, no aprendes el idioma. Regla del curso: todo lo que el capítulo pida crear, ESCRÍBELO en SQL; usa la gráfica para VER el resultado.
Cierre de Parte I: ya sabes por qué existe un motor, cómo se compara MariaDB con los otros tres, lo tienes instalado en Windows o Ubuntu, dominas el CLI y tienes tu gráfica conectada. Parte II: conocemos el modelo clinica tabla por tabla.

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 min

Arranca 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

FamiliaTablasFunción
Catálogoespecialidadeslistas cortas que otras tablas referencian
Personasmedicos, pacientes, usuarios_applos actores del sistema
Operacióncitas, recetas, pagoslo que ocurre todos los días
Controlauditoriaquién hizo qué, con qué cuenta
Diagrama Entidad-Relación (ERD) · Clínica (8 tablas) Abrir SVG completo
Modelo Entidad-Relación · Base de Datos Clínica (8 tablas) Dominio canónico compartido: MySQL, PostgreSQL, SQL Server y SQLite Catálogo Personas Operación Control / Auditoría 1 N 1 N 1 N 1 1 (UQ) 1 N especialidades PK id SERIAL nombre VARCHAR(80) descripcion TEXT medicos PK id SERIAL nombre VARCHAR(60) apellido VARCHAR(60) colegiatura VARCHAR(20) FK especialidad_id INTEGER email VARCHAR(120) telefono VARCHAR(20) pacientes PK id SERIAL UQ documento VARCHAR(15) nombre VARCHAR(60) apellido VARCHAR(60) fecha_nacimiento DATE telefono / email VARCHAR tipo_sangre / alergias CHAR(3)/TEXT usuarios_app (seguridad) PK id SERIAL UQ usuario VARCHAR(40) nombre VARCHAR(100) rol VARCHAR(20) hash_clave VARCHAR(255) citas PK id SERIAL FK paciente_id INTEGER FK medico_id INTEGER UQ fecha_hora TIMESTAMP estado ENUM motivo / diag. VARCHAR/TEXT recetas PK id SERIAL 1:1 cita_id INTEGER (UQ) medicamento VARCHAR(120) dosis / indic. VARCHAR/TEXT duracion_dias SMALLINT pagos PK id SERIAL FK cita_id INTEGER monto (CHK >= 0) NUMERIC(10,2) metodo / estado ENUM fecha TIMESTAMP auditoria (control transversal) PK id SERIAL tabla_afectada VARCHAR(60) operacion VARCHAR(10) usuario_app / bd VARCHAR registro_id INTEGER fecha_hora / datos TIMESTAMP/TEXT

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_nuevos

Así 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 documentoUNIQUE (cap 8)
No existe cita sin paciente ni sin médicoFOREIGN KEY (cap 8)
Un médico no tiene dos citas a la misma horaUNIQUE compuesta (cap 8)
Un estado de cita solo puede ser uno de la listaENUM (cap 9)
Agendar valida disponibilidad antes de insertarprocedure (cap 29)
Diseñar es decidir dónde viven las reglas: en la base (sobreviven a cualquier programa) o en la aplicación (más flexible, más riesgo). El modelo clinica pone en la base todo lo que es INNEGOCIABLE.

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 min

Elegir 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

TipoRango con signoUso típico
TINYINT-128 a 127banderas, edades pequeñas
SMALLINT-32 768 a 32 767conteos moderados
INT-2 147 millones a 2 147 millonesids 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
Longituddeclarada (hasta ~65 532 chars según charset)sin declarar
Índicecompletosolo por prefijo
Almacenamientoen la página de la filafuera de página (puntero)
Úsalo paranombres, correos, documentostextos 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.

Nota de versión: desde MariaDB 11.8 LTS, el collation por defecto para texto nuevo es utf8mb4_uca1400_ai_ci (antes utf8mb4_general_ci). En el cap 4 lo declaramos explícito al crear la base — declarar SIEMPRE evita sorpresas entre servidores.

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 min

Las 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

ConstraintLey que imponeEjemplo del modelo
NOT NULLla columna exige valortoda cita tiene fecha_hora
DEFAULTvalor si nadie lo pasaestado = 'PROGRAMADA'
UNIQUEno se repite (o combinación)uq_medico_fecha: sin doble agenda
PRIMARY KEYidentidad de la filaid en todas
FOREIGN KEYla referencia EXISTEcita con paciente real
CHECKexpresión booleana obligatoriamonto >= 0 (desde 10.2.1 se aplica)
ENUMsolo valores de la listaestados 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ónEfecto al borrar el padreEn la clínica
RESTRICT (default)rechaza el borradono borras un paciente con citas ✓
CASCADEborra también los hijospeligroso: borrado en cadena
SET NULLdeja la referencia vacíarequiere 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 row

Si 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 min

Las 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 INSERT

LAST_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 sembradoPara ejercitar...
20 pacientes, 8 médicos, 6 especialidadesJOINs 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_ASISTIOfiltros por estado
Un paciente sin NINGUNA citaanti-joins, NOT EXISTS
Recetas solo en citas ATENDIDASintegridad de negocio
Pagos en 3 métodos y 3 estadosagregados 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.

Descarga: el script 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.
Descargar script

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 min

Cierre 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:

ValorSignificadoConsulta que lo encuentra
NULLdesconocido / no aplicaIS 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)

TrampaSíntomaAntídoto
Comparar con = NULLla consulta devuelve SIEMPRE vacíoIS NULL / IS NOT NULL
'2026-02-30' (fecha inexistente)error en strict / NULL con warningdeja que strict te proteja
WHERE telefono = '987...' con espacio finalno encuentra la filaTRIM() al cargar datos
COUNT(columna) esperando todas las filascuenta menos (excluye NULLs)COUNT(*) cuenta filas; COUNT(col) cuenta valores
División entera 7/2esperabas 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'
Cierre de Parte II: modelo entendido (cap 6), tablas creadas con tipos correctos (cap 7), leyes armadas (cap 8), datos deterministas cargados (cap 9) y el servidor en modo estricto (hoy). La base clinica está LISTA para consultarse. Parte III: SQL de consulta, del primer SELECT a los CTEs.

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 min

Arranca 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;
Hábito del curso: cada SELECT que veas desde hoy, ejecútalo y MODIFÍCALO — cambia un filtro, quita una columna, invierte un orden. El SQL se aprende en el teclado, no en la lectura.

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 min

El 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;
Reto antes de seguir: escribe la consulta "pacientes con tipo de sangre O+ u O-, ordenados por apellido, mostrando solo los primeros 3". La respuesta usa IN, LIKE y LIMIT — todo de este capítulo.

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 min

Las 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.

Portabilidad: CONCAT, COALESCE, ROUND y las funciones de fecha básicas existen en los cuatro motores, pero los FORMATOS cambian (DATE_FORMAT es de MariaDB; PostgreSQL usa TO_CHAR, SQL Server CONVERT/FORMAT). Cuando recorramos los otros manuales, este capítulo es el que más difiere.

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 min

Las 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.

Ejercicio de la clínica: construye el reporte "por mes de 2026: cantidad de citas atendidas" con GROUP BY YEAR(fecha_hora), MONTH(fecha_hora) + WHERE estado='ATENDIDA'. Cuando lo tengas, ya sabes hacer el 70% de los reportes gerenciales del mundo.

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 min

Los 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 negocioJOIN
"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.
Ejercicio de deudores v2: agrega al reporte de deudores el MONTO de la cita usando la tabla pagos con estado PENDIENTE (LEFT JOIN + COALESCE(pg.monto, 0)). Es exactamente el reporte que la administración pide cada lunes.

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 min

Una 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ÍO

Si 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.

Regla de elección: valor a comparar → subconsulta escalar · conjunto → IN (si garantizas no-NULL) o EXISTS (siempre) · tabla temporal para agrupar → derivada · clasificar por filas → CASE. Con esto cierras el vocabulario de consulta individual; falta combinar consultas enteras: CTEs, cap 17.

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 min

Las 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

MitoRealidad
"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.

Regla de estilo del curso: a partir de dos niveles de anidación, una subconsulta se convierte en CTE. La consulta que se lee de un tirón es la que se mantiene sin miedo.

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 min

Cierre 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.

Cierre de Parte III: del primer SELECT (cap 11) a reportes con CTEs y funciones de ventana (hoy). Si los cinco reportes te salieron sin mirar las soluciones, el SQL de consulta ya es tuyo — y es el 90% del SQL que un desarrollador escribe en su vida. Parte IV: modificar datos con responsabilidad.

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 min

Arranca 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ónResultadoQuién lo frena
UPDATE sin WHEREcambia TODA la tablaNADIE — tu ritual
UPDATE con valor de tipo erróneoerrorstrict mode (cap 10)
UPDATE viola CHECKerrorchk_monto_positivo (cap 8)
UPDATE rompe UNIQUEerroruq_medico_fecha (cap 8)
DELETE de padre con hijoserror 1451FOREIGN 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.

Y la auditoría: ¿notaste que estos UPDATE no dejaron rastro en la tabla auditoria? Correcto — todavía. Los triggers que la llenan llegan en el cap 30. Cada cambio manual de hoy es "sin testigo": otra razón para trabajar en un entorno de práctica y no en producción.

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 sentencia

Los tres caminos, comparados

INSERT IGNOREREPLACEON DUP KEY UPDATE
Si NO chocainsertainsertainserta
Si chocaignora (pierde datos)BORRA + reinsertaUPDATE limpio
id de la filase conserva¡CAMBIA!se conserva
FKs que apuntanintactasrotas o en cascadaintactas
Veredictocasos de descarte masivoEVITAR en tablas referenciadasel camino por defecto

Dialectos (apúntalo)

MotorUPSERT
MariaDBINSERT ... ON DUPLICATE KEY UPDATE
PostgreSQLINSERT ... ON CONFLICT (clave) DO UPDATE
SQL ServerMERGE (con sus advertencias de concurrencia)
SQLiteINSERT ... ON CONFLICT DO UPDATE (desde 3.24)
Regla del curso: ON DUPLICATE KEY UPDATE por defecto, INSERT IGNORE solo si perder el dato es EL objetivo, REPLACE jamás en tablas con FK. Y en el cap 30 verás que desde PHP este patrón es idéntico — solo cambian los parámetros.

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 min

Una 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 escrito

Todo 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

PropiedadSignificadoQuién la cumple
Atomicidadtodo o nadael motor (InnoDB)
Consistenciade un estado válido a otro válidotus constraints + transacciones
Isolaciónlas transacciones no se estorbanniveles de aislamiento (hoy)
Durabilidadel COMMIT sobrevive al apagónel 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:

ProblemaEscenario 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 repetibleRosa 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 fantasmaRosa 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

NivelSucioNo repetibleFantasmaCosto
READ UNCOMMITTEDposibleposibleposiblemínimo
READ COMMITTEDnoposibleposiblebajo
REPEATABLE READ (default MariaDB)nonoposible*medio
SERIALIZABLEnononoalto
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

Default y a correr: REPEATABLE READ cubre el 99% de los casos de una clínica. Sube a SERIALIZABLE solo si detectas un problema REAL de concurrencia (y mide: el costo es bloqueos y esperas). Bajar de nivel, jamás. Y la transacción debe ser CORTA: abrir transacción, hacer lo necesario, cerrar — una transacción larga bloquea a todos.

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 min

A 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?

TEMPORARYCTE (cap 17)VISTA (cap 26)
Vivetu conexiónuna sentenciapara siempre (hasta DROP)
Puede indexarse/editarsesí, es una tabla realnosegún la vista
Se comparte entre usuariosno (por conexión)no
Úsala paraprocesos multi-paso, lotesconsultas legiblesconsultas reutilizadas por todos
Dialecto: PostgreSQL usa CREATE TEMP TABLE (igual), SQL Server usa #nombre (los famosos #temporales de T-SQL, creados con SELECT ... INTO) y SQLite tiene CREATE TEMP TABLE también. El concepto es universal; la sintaxis varía — tabla de dialectos en cada manual.

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 min

Cierre 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ícalo

Importante: 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

PasoEn el cap 23 (SQL puro)En el cap 31 (PHP/PDO)
1. abrirSTART TRANSACTION$pdo->beginTransaction()
2. operarUPDATE + INSERTsprepare + execute con parámetros
3. confirmarCOMMIT$pdo->commit()
4. si explotaROLLBACK manualcatch → rollBack() automático
5. auditoría— (aún sin triggers)SET @usuario_app + triggers (cap 30)
Cierre de Parte IV: modificar con ritual (19), upsert profesional (20), atomicidad y aislamiento (21), espacios de trabajo (22) y el patrón transaccional completo (hoy). Parte V: los objetos que viven EN el motor — vistas, procedures, triggers, roles — donde la auditoría dual deja de ser una promesa.

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 min

Arranca 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 guardada

Qué resuelve una vista (y qué no)

ResuelveNo resuelve
Reutilizaciónuna consulta compleja, escrita una vez
Simplicidadla app consulta "deudores", no 4 JOINs
Seguridadpermisos sobre la VISTA sin tocar las tablas (cap 29)
VelocidadNO es más rápida que su consulta (no hay cache mágico)
Parámetrosuna vista NO recibe argumentos — para eso: procedures (cap 25)
Recordatorio de la tabla del cap 22: vista = para SIEMPRE y para todos; CTE = una sentencia; TEMPORARY = tu conexión. La vista es el objeto permanente de consulta; los procedures (cap 25) serán el objeto permanente de LÓGICA.

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 min

El 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 horario

SIGNAL 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

ConstructoNota del curso
IF ... THEN ... ELSEIF ... END IF;decisiones del negocio
SELECT ... INTO variablecapturar valores sin devolver resultset
START TRANSACTION / COMMITpermitido EN procedures (la función del cap 26 NO puede)
BEGIN ... END anidadosbloques internos; un BEGIN suelto NO inicia transacción
LOOP / WHILE / REPEATexisten; el 95% de procedures correctos no los necesita (SQL piensa en conjuntos)
¿Lógica en la base o en la app? La regla de la casa (lógica fuera de las vistas) tiene una excepción razonada: las reglas que DEBEN cumplirse aunque el programa sea otro — disponibilidad, validaciones de integridad de negocio — viven en procedures/triggers. La lógica de flujo (qué pantalla sigue, qué notificar) sigue en la app. Frontera: integridad adentro, experiencia afuera.

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 min

La 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ísticaSignificaPara fn_edad
DETERMINISTICmisma entrada → misma salida, siempreNO (depende de CURDATE())
NOT DETERMINISTICpuede variar✓ este es
NO SQLno toca datosno (lee fecha_nacimiento... aquí ni lo lee: usa el parámetro)
READS SQL DATAlee tablas pero no modificasi accediera a tablas
MODIFIES SQL DATAmodifica datosprohibido 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

FUNCTIONPROCEDURE
DevuelveUN valor (RETURNS)resultsets, OUTs, nada
Se invocadentro de expresiones (SELECT, WHERE)CALL, como sentencia
Puede modificar datosNO
Puede hacer COMMITNO
En la clínicafn_edad, fn_saldo (cálculos)agendar_cita (operaciones)
Dialecto: el concepto existe en los 4 motores, pero la sintaxis de características varía (PostgreSQL usa LANGUAGE plpgsql y $$ como delimitador; SQL Server ni siquiera distingue con esta palabra — sus funciones se crean con CREATE FUNCTION y esquema propio). El manual de cada motor detallará su variante.

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 min

El 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ón

Exactamente 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 COMPLETA

Doc 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

Doc literal: es inseguro LEER una variable de usuario y ASIGNARLA en la misma sentencia (salvo con SET). Ejemplo prohibido: SELECT @x := @x + 1 ... en consultas con ORDER BY/GROUP BY — el orden de evaluación de filas no está garantizado. Asigna con SET, lee en otra sentencia o dentro de procedures: cero ambigüedad.
Lo que viene: llamar registrar_cambio a mano después de cada UPDATE es... olvidable. El cap 28 conecta los TRIGGERS: el motor firmará SOLO, leyendo @usuario_app, en cada INSERT/UPDATE/DELETE de las tablas sensibles. La auditoría dual automática, por fin.

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 min

El 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...
END

Tres 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 futura

Doc 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

ReglaDetalle
Múltiples triggersdesde 10.2.3 puedes tener VARIOS por timing/evento, ordenados con FOLLOWS/PRECEDES
BEFORE puede modificarSET NEW.columna = valor (con permiso UPDATE); en AFTER no tiene efecto
OLD es read-onlysiempre, en todos los eventos
Drop seguroDROP TRIGGER IF EXISTS nombre;
@usuario_app en triggersfunciona (el trigger corre en la sesión del invocador); la doc no lo documenta explícitamente para triggers — probado en este curso, úsalo consciente
El círculo se cierra: usuarios_app (cap 6) → canal de sesión @usuario_app (cap 27) → triggers que firman (hoy). La pregunta fundacional — "¿quién cambió la historia clínica y con qué cuenta?" — ahora la responde la base SOLA, con una consulta a auditoria. Esto era la Parte V.

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 min

La ú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

CuentaPuedeNo puede
roottodo (solo administración, jamás en la app)
curso (app)SELECT en vistas + EXECUTE en proceduresleer tablas directas, DDL, borrar
rol_recepcionagenda, consulta reportesdiagnósticos, borrar pacientes
rol_medicoconsulta total, atender, recetaradministrar usuarios
rol_admintodo el esquema clinicatocar 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

CapaHerramientaCapítulo
Quién usa la appusuarios_app + hash bcrypt6, 9, 31
Quién se conecta al motorcuenta mínima + roles4, hoy
Qué puede tocar esa cuentaGRANT quirúrgico: vistas + EXECUTEhoy
Quién hizo cada cambio@usuario_app + triggers + CURRENT_USER()27, 28
Qué datos son válidosconstraints + CHECK + ENUM8
Cierre de Parte V: vistas, procedures, funciones, variables de sesión, triggers y roles — los objetos que viven en el motor trabajando en equipo. La base ya no es un almacén pasivo: defiende, valida, firma y decide. Parte VI: el encuentro final con PHP, donde la app aprende a conversar con todo esto.

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 min

Arranca 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 ingenuaPor qué falla
str_replace("'", "")rompe apellidos legítimos (O'Brien) y tiene bypasses con codificación
addslashes / escapado manualdepende 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

CapaHerramientaEstado
Daño limitado si algo pasacuenta de app con mínimo privilegio (cap 29)✓ lista
Validación de entradaconstraints, ENUM, CHECK (cap 8) + validar en la app✓ lista
La defensa realconsultas preparadas SIEMPREcap 31 en PHP
La regla de oro, sin excepciones: el valor del usuario NUNCA se concatena al SQL — ni en PHP, ni en un script, ni "solo esta vez por ser interno". Siempre marcador (?) y valor aparte. En el cap 31 lo convertimos en código PDO diario. Las excepciones no existen: un solo concatenado olvida a todos los demás.

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 min

El 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 / INSERT

Transacciones 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.

LIKE con marcador: el % va en el VALOR, no en el SQL: execute(["%$busqueda%"]) sobre WHERE apellido LIKE ? — el marcador solo ocupa posiciones de dato.

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 min

Con 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 creadoDoc
PRIMARY KEYsí, por definición
UNIQUEíndice único"specifying a column as a unique key creates a unique index"
FOREIGN KEYInnoDB lo crea SOLO si no existeverificado 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 condition

La 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

El costo oculto: cada índice se actualiza en CADA escritura. Indexar todo convierte los INSERT en lentos y llena disco. Indexa columnas que aparecen en WHERE/JOIN/ORDER BY frecuentes; deja de lado las que solo se muestran. Y mide con EXPLAIN antes y después: el índice inútil es puro costo.

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 min

La 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.sql

Cada flag, con lo que la doc garantiza:

FlagQué hace
--single-transactionenví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
--routinesincluye 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

CapaFrecuenciaHerramienta
Volcado lógico completodiario (madrugada)mariadb-dump --single-transaction --routines
Respaldos incrementalescontinuobinary log del servidor (registra cada cambio desde el último dump)
Copia fuera del servidordiarioel .sql se COPIA a otro equipo — el backup en el mismo disco que los datos no es backup
Prueba de restauraciónmensualrestaurar en una base de prueba y verificar
Los dos pecados capitales: (1) backup sin --single-transaction en una base viva: datos a medias entre tablas; (2) el dump que vive en el mismo servidor que la base: si el disco muere, muere con él todo. Copia FUERA, siempre.

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 min

El ú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

ComandoQué haceCuándo
ANALYZE TABLE citasactualiza estadísticas de índices para el optimizador (desde 10.6, no bloquea InnoDB)tras cargas masivas
OPTIMIZE TABLE pagosdefragmenta y recupera espacio tras muchos borrados/updatesmantenimiento mensual
SHOW TABLE STATUStamaño, motor, filas aproximadas por tablarevisió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 min

Meta 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

ParteLogro
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)

ConceptoMariaDBPostgreSQLSQL ServerSQLite
EnteroTINYINT / INT / BIGINTSMALLINT / INT / BIGINTTINYINT / INT / BIGINTINTEGER
BooleanoTINYINT(1) / BOOLEANBOOLEAN realBITINTEGER 0/1
Dinero exactoDECIMAL(p,s) máx 65/38NUMERIC / DECIMALDECIMAL / MONEYNUMERIC
Texto cortoVARCHAR(n)VARCHAR(n)VARCHAR(n)TEXT (afinidad)
Texto largoTEXTTEXTVARCHAR(MAX)TEXT
FechaDATEDATEDATETEXT ISO-8601
Fecha+horaDATETIME / TIMESTAMP (UTC, ≥11.5 hasta 2106)TIMESTAMP / TIMESTAMPTZDATETIME2TEXT ISO-8601
BinarioBLOBBYTEAVARBINARY(MAX)BLOB
Auto-numéricoAUTO_INCREMENTIDENTITY / SERIALIDENTITY(1,1)rowid / AUTOINCREMENT
Primeras N filasLIMIT nLIMIT / FETCH FIRSTSELECT TOP nLIMIT n
ConcatenarCONCAT(a, b)a || ba + b / CONCATa || b
HoyCURDATE() / NOW()CURRENT_DATE / NOW()GETDATE()date('now')
IF por filaIF(c, a, b)CASECASE / IIFCASE / IIF
UPSERTON DUPLICATE KEY UPDATEON CONFLICT DO UPDATEMERGEON CONFLICT DO UPDATE
Tablas temporalesCREATE TEMPORARY TABLECREATE TEMP TABLE#temp / SELECT INTOCREATE TEMP TABLE
Procedures / triggerssí (DELIMITER cliente)sí (plpgsql, $$)sí (T-SQL)NO
Variables de sesión@varset_config / current_settingSESSION_CONTEXTNO (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.

Felicidades: administras MariaDB de verdad — modelaste, consultaste, modificaste con responsabilidad y dejaste una base que valida, firma y se defiende sola. Siguiente estación: bd_postgresql.html, el mismo modelo de clínica con el motor más fiel al estándar SQL. Nos vemos ahí.

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.