SQL Server · bases de datos de cero a experto

Instalación en Windows y Ubuntu, modelado de la base clinica, T-SQL completo, transacciones, vistas, procedures, triggers de auditoría dual y conexión PHP con PDO. El dialecto corporativo de la serie de Bases de datos.

35 capítulos Bootstrap 5.3 Modo claro / oscuro Optimizado para móvil SQL Server 2022
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 MySQL, PostgreSQL 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 gestionaSQL Server 2022
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.

  • Entender la historia y filosofía de SQL Server.
  • Comparar los 4 motores: licencia, modelo y caso ideal.
  • Saber por qué SQL Server Developer es gratuita.
  • Conocer la versión objetivo del curso: SQL Server 2022.

SQL Server: la historia en 30 segundos

SQL Server nació en 1989 como cooperación entre Microsoft y Sybase. En 1993 Sybase se separó y Microsoft se quedó con el nombre. Su filosofía siempre fue integración con el ecosistema Windows y .NET, pero desde 2016 también corre en Linux. Su lenguaje procedural es T-SQL (Transact-SQL), una extensión propietaria del estándar SQL con variables, control de flujo y procedimientos almacenados extremadamente potentes.

Versión objetivo del curso: SQL Server 2022 (16.x, estable, soporte hasta 2028). SQL Server 2025 (17.x) está disponible pero es la serie "current" — el curso funciona idéntico en ambas.

La comparativa honesta

AspectoSQL ServerMariaDBPostgreSQLSQLite
LicenciaDeveloper gratis, Enterprise comercialGPL (libre)PostgreSQL (libre)Dominio público
Arquitecturacliente-servidorcliente-servidorcliente-servidorembebida (un archivo)
Fortalezaempresas Windows/.NET, BI, análisisweb, simple y rápidaestándar SQL, datos complejosembebido, cero administración
Lenguaje proceduralT-SQL (potente, extenso)SQL/MED (propios)PL/pgSQL (estándar)NO tiene
Variables de sesiónsp_set_session_context@var por sesiónset_config / current_settingNO tiene (capa app)
Ideal para...entornos corporativos Microsoft, BIsitios web, arranquesintegridad seria, datos complejosapps locales, móviles, IoT
En el cursodialecto T-SQLbase de la comparaciónmotor protagonistacontrapunto embebido

SQL Server Developer: gratuita con todas las funciones

Microsoft ofrece la edición Developer de forma gratuita: incluye TODAS las funciones de la edición Enterprise (particionamiento, compresión, Always On, etc.) pero NO se puede usar en producción. Es perfecta para aprender y desarrollar. La diferencia con Express (también gratuita) es que Express tiene límites: 10 GB por base, 1 GB de RAM, sin clr, sin SQL Agent.

Para este curso, Developer es la elección correcta: sin límites de aprendizaje, sin costo, y lo que aprendes en Developer funciona idéntico en Enterprise.

El orden del curso y por qué

SQL Server tercero (después de MySQL y PostgreSQL) porque su dialecto T-SQL es el más diferente de los tres: variables con @, GO como separador, TOP en vez de LIMIT, MERGE en vez de UPSERT simple. Al llegar después de los otros dos, reconocerás el 90% del SQL común y te concentrarás en las 10 diferencias que realmente importan.

El 90% del SQL es común. SELECT, JOIN, GROUP BY, transacciones: idénticos en los cuatro motores. 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

  • SQL Server: Microsoft, T-SQL, ediciones Developer/Express gratuitas.
  • Versión del curso: SQL Server 2022 (soporte hasta 2028).
  • Developer = Enterprise sin producción; Express = gratuita con límites.
  • MariaDB: fork comunitario de MySQL · PostgreSQL: el más fiel al estándar.
  • 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 ~16 min

SQL Server en dos sistemas operativos, con verificación incluida. Al terminar tendrás el servidor corriendo como servicio y la clave de SA puesta — listo para el capítulo siguiente.

  • Instalar en Windows con el instalador MSI oficial (Developer).
  • Instalar en Ubuntu desde los repositorios de Microsoft.
  • Configurar Mixed Mode y la contraseña de SA.
  • Verificar versión y servicio en ambos sistemas.

Windows: el instalador MSI

  1. Descarga SQL Server 2022 Developer desde microsoft.com/sql-server/sql-server-downloads — elige la edición Developer (gratuita) y el instalador x64.
  2. Ejecuta el asistente: acepta la ruta por defecto (C:\Program Files\Microsoft SQL Server).
  3. En el paso "Authentication Mode" elige Mixed Mode y DEFINE la contraseña del usuario SA — guárdala: la necesitarás en cada conexión.
  4. Deja marcado "Add current user" como administrador de SQL Server.
  5. Opcional: instala SQL Server Management Studio (SSMS) desde learn.microsoft.com/sql/ssms/download — es el cliente gráfico oficial para Windows.

Ubuntu: desde los repositorios de Microsoft

SQL Server 2022 se instala en Ubuntu 20.04 y 22.04 desde los repos oficiales de Microsoft. En Ubuntu 24.04 aún no hay soporte oficial (usar Docker o el repo de 22.04 con dependencias manuales).

# 1) Importar la clave GPG de Microsoft:
curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | \
  sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/microsoft.gpg

# 2) Agregar el repositorio de SQL Server 2022:
curl -fsSL https://packages.microsoft.com/config/ubuntu/22.04/mssql-server-2022.list | \
  sudo tee /etc/apt/sources.list.d/mssql-server-2022.list

# 3) Instalar SQL Server:
sudo apt update
sudo apt install -y mssql-server

# 4) Configurar (elegir Developer, definir clave SA):
sudo /opt/mssql/bin/mssql-conf setup

# 5) Verificar:
systemctl status mssql-server --no-pager

El comando mssql-conf setup te preguntará la edición (elige 2 = Developer) y te pedirá una contraseña SA que cumpla con la política de complejidad: mínimo 8 caracteres, mayúscula, minúscula, número y símbolo.

Herramientas de cliente en Ubuntu

# sqlcmd (cliente CLI oficial):
curl -fsSL https://packages.microsoft.com/config/ubuntu/22.04/prod.list | \
  sudo tee /etc/apt/sources.list.d/mssql-release.list
sudo apt update
sudo ACCEPT_EULA=Y apt install -y mssql-tools18 unixodbc-dev

# Agregar al PATH:
echo 'export PATH="$PATH:/opt/mssql-tools18/bin"' >> ~/.bashrc
source ~/.bashrc

# Probar conexión:
sqlcmd -S localhost -U SA -P 'TuClaveCompleja.1' -C

sqlcmd es el equivalente de mariadb en MySQL o psql en PostgreSQL: el cliente de consola que habla directamente con el motor. El flag -C confía en el certificado del servidor (no necesario en desarrollo local).

Verificación en ambos sistemas

TareaWindows (PowerShell admin)Ubuntu
Versión del servidorSELECT @@VERSIONsqlcmd -S localhost -U SA -P '...' -Q "SELECT @@VERSION"
Estado del servicioGet-Service MSSQLSERVERsystemctl status mssql-server --no-pager
Arrancarnet start MSSQLSERVERsudo systemctl start mssql-server
Detenernet stop MSSQLSERVERsudo systemctl stop mssql-server
Puerto1433 (estándar)
Nota de versiones: SQL Server 2022 requiere Ubuntu 20.04 o 22.04. En Ubuntu 24.04, la forma más limpia es Docker: docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=..." -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest. Para aprender, la instalación nativa o Docker son equivalentes.

Puntos clave

  • Windows: MSI Developer + SSMS (cliente gráfico oficial).
  • Ubuntu: repo Microsoft + apt install mssql-server + mssql-conf setup.
  • Mixed Mode: habilita login SA con contraseña (obligatorio).
  • sqlcmd: el cliente CLI oficial, equivalente a mariadb/psql.
  • Puerto 1433 (estándar de SQL Server).
  • Ubuntu 24.04: usar Docker o repo de 22.04 con dependencias.

4 · El cliente CLI y primer contacto

Básico ~14 min

El cliente de consola (sqlcmd) 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 con SA y con Windows Authentication.
  • Leer el prompt y ejecutar los primeros comandos de orientación.
  • Crear la base clinica con UTF-8.
  • Crear un usuario de práctica con permisos SOLO sobre esa base.

Entrar al cliente

# Windows (Windows Authentication — usa tu cuenta de Windows):
sqlcmd -S localhost -E

# Windows (Mixed Mode — con usuario SA):
sqlcmd -S localhost -U SA -P 'TuClaveCompleja.1'

# Ubuntu (con usuario SA):
sqlcmd -S localhost -U SA -P 'TuClaveCompleja.1' -C

El prompt de sqlcmd no cambia como en mariadb o psql — simplemente muestra 1> esperando tu sentencia. Todo lo que escribas aquí termina en GO (el separador de lotes de SQL Server, equivalente al ; de otros motores, pero con diferencia: GO envía todo el lote de una vez, no sentencia por sentencia).

Orientarse: comandos de exploración

-- Versión del servidor y usuario actual:
SELECT @@VERSION, SYSTEM_USER, SUSER_NAME();

-- Listar bases de datos:
SELECT name FROM sys.databases;

-- Listar tablas de la base actual:
SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_TYPE = 'BASE TABLE';

-- Ayuda de un comando:
HELP SELECT;

Verás las bases del sistema: master (control), model (plantilla para nuevas), msdb (tareas programadas) y tempdb (trabajo temporal). Tu trabajo vivirá en una base propia.

Crear la base del curso

CREATE DATABASE clinica;
GO

USE clinica;
GO

SELECT DB_NAME();   -- confirma dónde estás parado
GO

SQL Server usa GO como separador de lotes: cada bloque de sentencias se envía al servidor de una pasada. A diferencia de MySQL que usa ; como sent terminator, SQL Server reconoce GO como comando del cliente (sqlcmd, SSMS) — no es T-SQL. En scripts de producción siempre verás GO entre CREATE DATABASE y USE.

UTF-8 es el estándar moderno. SQL Server 2022 usa UTF-16 por defecto en las columnas NVARCHAR, pero al crear la base puedes especificar collation con reglas del español:

-- Opcional: collation español (si quieres ordenamiento específco):
CREATE DATABASE clinica
  COLLATE SQL_Latin1_General_CP1_CI_AS;
GO

El usuario de práctica

-- Crear login a nivel servidor:
USE master;
GO
CREATE LOGIN curso WITH PASSWORD = 'ClaveSegura.2026';
GO

-- Crear usuario a nivel base y asignar permisos:
USE clinica;
GO
CREATE USER curso FOR LOGIN curso;
GO
ALTER ROLE db_datareader ADD MEMBER curso;
ALTER ROLE db_datawriter ADD MEMBER curso;
GO

-- Probar la conexión (salir y volver):
-- sqlcmd -S localhost -U curso -P 'ClaveSegura.2026' -d clinica
SELECT SYSTEM_USER, USER_NAME(), DB_NAME();
GO

En SQL Server hay DOS niveles de identidad: el login (a nivel servidor, en master) y el usuario (a nivel base de datos). Es diferente a MySQL donde el usuario y sus permisos se definen en una sola sentencia. Aquí el login "curso" se conecta al servidor; el usuario "curso" dentro de clinica define qué puede hacer AHÍ.

Windows Authentication vs Mixed Mode: en entornos corporativos se usa Windows Authentication (el usuario de Windows es el login — sin clave adicional). En nuestro curso usamos Mixed Mode con SA para aprender el camino completo: autenticación, permisos y auditoría.

sys.databases vs INFORMATION_SCHEMA

Vista catálogoQué muestraUso típico
sys.databasestodas las bases del servidorlistar bases, ver configuración
sys.tablestablas de la base actualexplorar estructura
INFORMATION_SCHEMA.TABLESestándar SQL: tablasportable entre motores
INFORMATION_SCHEMA.COLUMNScolumnas de una tabladocumentación automática

SQL Server tiene AMBOS: sus vistas propias (sys.*) y las vistas del estándar SQL (INFORMATION_SCHEMA). Para este curso usaremos las dos: sys.* para detalles específicos de SQL Server y INFORMATION_SCHEMA para consultas portables.

Puntos clave

  • sqlcmd -S localhost -U SA -P '...' -C: entrada estándar.
  • GO: separador de lotes (NO es T-SQL, es del cliente).
  • CREATE DATABASE clinica; USE clinica; — siempre con GO entre medio.
  • Dos niveles: login (servidor) + usuario (base de datos).
  • sys.* (catálogo propio) vs INFORMATION_SCHEMA (estándar SQL).
  • Windows Authentication (corporativo) vs Mixed Mode (nuestro curso).

5 · Herramientas gráficas y cierre de Parte I

Básico ~12 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 SSMS, Azure Data Studio y DBeaver 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: cuatro herramientas

HerramientaPlataformaPunto fuerte
SQL Server Management Studio (SSMS)Windowsel cliente oficial y completo de Microsoft; Explorer, editor, debugger de T-SQL, reportes, configuración del servidor
Azure Data StudioWindows, macOS, Linuxligero, moderno, notebooks, extensiones de SQL Server; la alternativa multiplataforma de Microsoft
DBeaver CommunityWindows, macOS, Linuxhabla con LOS CUATRO motores del curso: una sola herramienta para toda la serie
SQL Server Configuration ManagerWindowsgestiona servicios, protocolos y puertos del motor

Dato clave: SSMS solo corre en Windows, pero es la herramienta más completa para SQL Server. Si usas Linux o macOS, Azure Data Studio es tu equivalente nativo. DBeaver es multi-motor: cuando llegues a PostgreSQL y SQLite no cambiarás de herramienta, solo de conexión.

Conectar DBeaver a clinica

  1. Nueva conexión → elegir SQL Server.
  2. Servidor: localhost · Puerto: 1433.
  3. Autenticación: SQL Server · Usuario: SA · Clave: la que definiste en el cap 3.
  4. Base de datos: clinica.
  5. En la pestaña "SSL": marcar Force SSL = desactivado (desarrollo local). 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.

SSMS: el powerhouse de Windows

Si trabajas en Windows, SSMS es la experiencia completa:

  • Object Explorer: árbol de bases, tablas, vistas, procedures, seguridad, replicación.
  • Query Editor: IntelliSense (autocompletado), análisis de queries, planes de ejecución visuales.
  • Activity Monitor: queries activas, bloqueos, uso de CPU y memoria en tiempo real.
  • Database Tuning Advisor: analiza cargas de trabajo y sugiere índices y particiones.

Descárgalo desde learn.microsoft.com/sql/ssms/download-sql-server-management-studio — es gratuito y se actualiza con frecuencia independiente de SQL Server.

¿Gráfico o CLI?

TareaHerramienta idealPor qué
Aprender T-SQL (este curso)CLI (sqlcmd)te obliga a escribir; el dedo aprende
Explorar estructura, datosgrá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 planes, CLI para repetir
Scripts del curso y producciónCLI / archivosversionables y repetibles
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 SQL Server con los otros tres, lo tienes instalado en Windows o Ubuntu, dominas sqlcmd y tienes tu gráfica conectada. Parte II: conocemos el modelo clínica tabla por tabla.

Puntos clave

  • SSMS: completo, solo Windows. Azure Data Studio: multiplataforma.
  • DBeaver: multi-motor, una herramienta para toda la serie.
  • Conexión: localhost:1433 · SA · clinica.
  • Gráfica para explorar; CLI para aprender y para scripts.
  • 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 master 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'       <-- la cuenta de conexión (CURRENT_USER en SQL Server)
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 sp_set_session_context.

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 listaCHECK + tabla auxiliar (cap 8)
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 clínica 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 (CURRENT_USER).
  • Lo innegociable vive en la base, no en la app.

7 · Crear tablas: los tipos de dato

Básico ~16 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 NVARCHAR desperdicia el doble de espacio. Hoy, los tipos de SQL Server con sus reglas reales.

  • Dominar los enteros y IDENTITY (auto-numérico).
  • Usar DECIMAL para dinero — y saber por qué NUNCA MONEY para cálculos.
  • Elegir entre VARCHAR y NVARCHAR con criterio.
  • Crear las tres tablas de personas del modelo.

Enteros: de TINYINT a BIGINT

TipoRango con signoUso típico
TINYINT0 a 255 (sin signo) / -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

BIT es el equivalente a BOOLEAN en SQL Server: almacena 0, 1 o NULL. No existe TRUE/FALSE como literales — se usan enteros. WHERE activo = 1, no WHERE activo = TRUE.

IDENTITY: el auto-numérico de SQL Server

id INT IDENTITY(1,1) PRIMARY KEY  -- equivalente a AUTO_INCREMENT de MySQL
                                    -- y SERIAL de PostgreSQL

Los dos parámetros son: IDENTITY(semilla, incremento). IDENTITY(1,1) empieza en 1 y sube de 1 en 1. Para insertar un valor explícito, se habilita temporalmente:

SET IDENTITY_INSERT especialidades ON;
INSERT INTO especialidades (id, nombre, descripcion) VALUES (1, 'Cardiología', '...');
SET IDENTITY_INSERT especialidades OFF;

La diferencia con MySQL: IDENTITY es más explícito (debes declarar el incremento) y necesita permiso explícito para inserts manuales. Los valores borrados NUNCA se reutilizan.

Dinero: DECIMAL, no MONEY

monto DECIMAL(10,2)   -- hasta 99 999 999.99, exacto

-- MONEY es más rápido PERO tiene redondeo implícito y overflow silencioso.
-- DECIMAL es portable y transparente.

DECIMAL guarda exactamente los dígitos que declaras: DECIMAL(10,2) son 10 dígitos totales, 2 decimales. MONEY tiene un límite de ±922 billones y puede redondear sin avisar. Para un curso que enseña buenas prácticas, DECIMAL es la elección correcta.

Texto: VARCHAR vs NVARCHAR

VARCHAR(n)NVARCHAR(n)
Almacenamiento1 byte por carácter (Latin-1)2 bytes por carácter (UTF-16)
Espaciola mitadel doble
Soportesolo caracteres occidentalestodos los Unicode: ñ, á, chino, emoji
Uso recomendadosi NUNCA habrá caracteres especialescampos con texto humano: nombres, correos, observaciones

Regla práctica del curso: NVARCHAR para nombres, correos, direcciones y cualquier campo con texto humano. VARCHAR solo para códigos técnicos que garantizan ser ASCII puro (como un código de barras).

Fechas y horas

  • DATE: '0001-01-01' a '9999-12-31' — para fechas puras (fecha_nacimiento).
  • DATETIME2: fecha+hora con precisión configurable (7 decimales por defecto, nanosegundos). Reemplaza al antiguo DATETIME que solo llega a 3.33ms.
  • DATETIME: obsoleto, precisión de 3.33ms, rango hasta 9999. Úsalo solo si heredas código viejo.

Regla práctica: DATETIME2 para todo lo nuevo. El antiguo DATETIME es como MySQL DATETIME: funcional pero obsoleto.

Las tres tablas de personas

USE clinica;
GO

CREATE TABLE especialidades (
  id          INT IDENTITY(1,1) PRIMARY KEY,
  nombre      NVARCHAR(80)  NOT NULL,
  descripcion NVARCHAR(255)
);
GO

CREATE TABLE medicos (
  id             INT IDENTITY(1,1) PRIMARY KEY,
  nombre         NVARCHAR(60) NOT NULL,
  apellido       NVARCHAR(60) NOT NULL,
  colegiatura    NVARCHAR(20) NOT NULL,
  especialidad_id INT NOT NULL,
  email          NVARCHAR(120),
  telefono       NVARCHAR(20),
  activo         BIT NOT NULL DEFAULT 1
);
GO

CREATE TABLE pacientes (
  id              INT IDENTITY(1,1) PRIMARY KEY,
  documento       NVARCHAR(15) NOT NULL,
  nombre          NVARCHAR(60) NOT NULL,
  apellido        NVARCHAR(60) NOT NULL,
  fecha_nacimiento DATE NOT NULL,
  sexo            CHAR(1),
  telefono        NVARCHAR(20),
  email           NVARCHAR(120),
  direccion       NVARCHAR(160),
  tipo_sangre     CHAR(3),
  alergias        NVARCHAR(255),
  activo          BIT NOT NULL DEFAULT 1
);
GO
NVARCHAR vs VARCHAR: en MySQL/PostgreSQL usaste VARCHAR porque el charset UTF-8 ya cubre todo. En SQL Server, VARCHAR es Latin-1 — para soportar ñ y tildes sin preocupaciones, usa NVARCHAR. El costo es el doble de almacenamiento, pero hoy el disco es barato y la tranquilidad no tiene precio.

Puntos clave

  • BIT = 0/1/NULL (no TRUE/FALSE como literales).
  • IDENTITY(1,1) = AUTO_INCREMENT. SET IDENTITY_INSERT ON para valores manuales.
  • Dinero SIEMPRE DECIMAL(p,s) — MONEY redondea silenciosamente.
  • NVARCHAR para texto humano (Unicode); VARCHAR solo para ASCII garantizado.
  • DATETIME2 para todo lo nuevo (precisión configurable, sin límite 2038).

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.
  • Elegir ON DELETE con criterio: NO ACTION, CASCADE, SET NULL.
  • Verificar el resultado con sp_help.

La forma correcta de una FOREIGN KEY

-- FORMA CORRECTA (constraint de tabla, como en MySQL):
medico_id INT NOT NULL,
CONSTRAINT fk_citas_medico FOREIGN KEY (medico_id)
    REFERENCES medicos(id)

-- SQL Server SÍ acepta REFERENCES inline y SÍ lo aplica,
-- pero la forma nombrada es mejor práctica: nombre explícito en errores.

A diferencia de MySQL que ignora silenciosamente el REFERENCES inline, SQL Server sí lo crea. Pero la forma nombrada (CONSTRAINT fk_xxx) es mejor porque el error incluye el nombre — depurar "la constraint fk_citas_medico falló" es mucho más fácil que "una FK falló".

Las tablas de operación, con todas sus leyes

CREATE TABLE citas (
  id           INT IDENTITY(1,1) PRIMARY KEY,
  paciente_id  INT NOT NULL,
  medico_id    INT NOT NULL,
  fecha_hora   DATETIME2 NOT NULL,
  estado       NVARCHAR(20) NOT NULL DEFAULT 'PROGRAMADA',
  motivo       NVARCHAR(255) NOT NULL,
  diagnostico  NVARCHAR(500),
  CONSTRAINT uq_medico_fecha UNIQUE (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),
  CONSTRAINT chk_estado_cita CHECK (estado IN
      ('PROGRAMADA','ATENDIDA','CANCELADA','NO_ASISTIO'))
);
GO

CREATE TABLE recetas (
  id          INT IDENTITY(1,1) PRIMARY KEY,
  cita_id     INT NOT NULL,
  medicamento NVARCHAR(120) NOT NULL,
  dosis       NVARCHAR(80)  NOT NULL,
  indicaciones NVARCHAR(255),
  duracion_dias TINYINT NOT NULL,
  CONSTRAINT uq_receta_cita UNIQUE (cita_id),
  CONSTRAINT fk_recetas_cita FOREIGN KEY (cita_id)
      REFERENCES citas(id)
);
GO

CREATE TABLE pagos (
  id      INT IDENTITY(1,1) PRIMARY KEY,
  cita_id INT NOT NULL,
  monto   DECIMAL(10,2) NOT NULL,
  metodo  NVARCHAR(20) NOT NULL,
  estado  NVARCHAR(20) NOT NULL DEFAULT 'PENDIENTE',
  fecha   DATETIME2 NOT NULL,
  CONSTRAINT fk_pagos_cita FOREIGN KEY (cita_id)
      REFERENCES citas(id),
  CONSTRAINT chk_monto_positivo CHECK (monto >= 0),
  CONSTRAINT chk_metodo_pago CHECK (metodo IN
      ('EFECTIVO','TARJETA','TRANSFERENCIA')),
  CONSTRAINT chk_estado_pago CHECK (estado IN
      ('PENDIENTE','PAGADO','ANULADO'))
);
GO

Sin ENUM: CHECK + tabla auxiliar

SQL Server NO tiene tipo ENUM como MySQL. La alternativa es CHECK con una lista de valores permitidos — y si la lista crece, se puede migrar a una tabla de referencia. En el cap 12 veremos la versión exacta del curso con datos sembrados.

MySQL/MariaDBSQL Server
estado ENUM('A','B')NVARCHAR(20) CHECK (estado IN ('A','B'))
valores fijos en el tipovalores fijos en la constraint
ventaja: orden implícitoventaja: portable, sin tipo proprietary

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 fila (UNIQUE + NOT NULL)id en todas
FOREIGN KEYla referencia EXISTE en la tabla padrecita con paciente real
CHECKexpresión booleana obligatoriamonto >= 0, estado IN (...)

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
NO ACTION (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 NO ACTION es exactamente lo que una clínica necesita: nadie borra un paciente que tiene historial — primero se desactiva (activo = 0, borrado lógico).

Verificar lo construido

-- Listar tablas:
SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_TYPE = 'BASE TABLE';

-- Ver la estructura de citas:
sp_help 'citas';

-- 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');
GO
-- Msg 547, Level 16, State 0: The INSERT statement conflicted with
-- the FOREIGN KEY constraint "fk_citas_paciente".

Si el INSERT falló con el Msg 547, las leyes están vivas. Ese mensaje con el nombre de la constraint es el sonido de la base defendiéndose.

Puntos clave

  • CONSTRAINT nombrada > REFERENCES inline: errores descriptivos.
  • SQL Server no tiene ENUM — se usa CHECK con IN (...).
  • UNIQUE compuesta = reglas de combinación (doble agenda).
  • ON DELETE NO ACTION por defecto: borrado lógico, no físico.
  • sp_help 'tabla': el SHOW CREATE TABLE de SQL Server.
  • Msg 547 = la FK trabajando. Es buena señal.

9 · Datos deterministas y el script clínica

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.

  • Obtener el id generado con SCOPE_IDENTITY().
  • Cargar la clínica completa con patrones de negocio reales.
  • Entender por qué datos deterministas ayudan a aprender.
  • Descargar y ejecutar el script bd_sqlserver_clinica.sql.

INSERT: una fila y varias

USE clinica;
GO

INSERT INTO especialidades (nombre, descripcion)
VALUES ('Medicina General', 'Atención primaria y derivaciones');
GO

-- 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');
GO

Con las 6 especialidades cargadas, los médicos referencian por id — pero ¿qué ids les tocó? Para encadenar inserciones dentro de un script, SQL Server te responde:

INSERT INTO medicos (nombre, apellido, colegiatura,
                     especialidad_id, email, telefono, activo)
VALUES ('Ana', 'Torres', 'CT-12045', 2, 'atorres@clinica.pe', '987001001', 1);
GO

SELECT SCOPE_IDENTITY();  -- el id que ACABA de generarse para TU lote

SCOPE_IDENTITY() devuelve el último id generado dentro del mismo scope (no le afectan triggers con IDENTITY_INSERT). Es más seguro que @@IDENTITY que sí se contamina con triggers. Equivale a LAST_INSERT_ID() de MySQL.

SET IDENTITY_INSERT: inserts manuales

-- Para forzar un id específico (ej: migración de datos):
SET IDENTITY_INSERT especialidades ON;
INSERT INTO especialidades (id, nombre, descripcion)
VALUES (100, 'Oncología', 'Tratamiento del cáncer');
SET IDENTITY_INSERT especialidades OFF;
GO

Sin SET IDENTITY_INSERT ON, SQL Server rechaza el insert si incluyes la columna IDENTITY — el motor quiere controlar el generador. Solo se habilita para operaciones puntuales y siempre se apaga después.

Los datos deterministas del curso

El script bd_sqlserver_clinica.sql acompaña el manual y carga todo de una vez. Sus patrones NO son al azar:

Patrón sembradoPara ejercitar...
6 especialidades, 8 médicos, 20 pacientesJOINs 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+');
GO

INSERT INTO usuarios_app (usuario, nombre, rol, hash_clave, activo) VALUES
  ('admin',   'Administrador',   'ADMIN',      '$2y$10$...', 1),
  ('rrosa',   'Rosa Reyes',      'RECEPCION',  '$2y$10$...', 1),
  ('atorres', 'Dra. Ana Torres', 'MEDICO',     '$2y$10$...', 1);
GO

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');
GO

El hash $2y$10$... representa un hash bcrypt real generado desde PHP (password_hash) — las claves NUNCA viajan en texto plano.

¿Por qué deterministas?

Si las fechas fueran GETDATE() o los datos aleatorios, "los pacientes deudores" daría 5 en mi máquina y 7 en la tuya. Con datos fijos, cuando el cap 17 diga "esta consulta devuelve 4 filas", TÚ debes ver 4.

Descarga: el script bd_sqlserver_clinica.sql crea TODO (tablas + datos) de una pasada. Ejecútalo en una base limpia con sqlcmd -S localhost -U SA -P '...' -d clinica -i bd_sqlserver_clinica.sql y quedarás sincronizado con el resto del manual.
Descargar script

Puntos clave

  • SCOPE_IDENTITY(): el id de TU scope, más seguro que @@IDENTITY.
  • SET IDENTITY_INSERT ON/OFF para inserts manuales con id explícito.
  • 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 SET options

Básico ~14 min

Cierre de Parte II. SQL Server SIEMPRE es estricto (no tiene sql_mode configurable como MySQL) — eso es bueno. Hoy destapamos las trampas clásicas: NULL vs cadena vacía, las funciones que mienten, y las particularidades de T-SQL que difieren del SQL estándar.

  • Entender que SQL Server es SIEMPRE estricto (no hay sql_mode).
  • 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.

Sin sql_mode: SQL Server SIEMPRE es estricto

A diferencia de MySQL/MariaDB que tienen sql_mode configurable, SQL Server siempre rechaza:

TrampaMySQL indulgenteSQL Server (siempre estricto)
'abc' en columna INTconvierte a 0 con warningerror de conversión
'2026-02-30' (fecha inexistente)acepta con warning (modo indulgente)error de conversión
Texto que excede el anchotrunca con warningerror de desbordamiento
DIVISIÓN por ceroNULL con warning (sin ERROR_FOR_DIVISION_BY_ZERO)error aritmético

Esto es una ventaja: en SQL Server, si un INSERT funciona, los datos son correctos. No hay "funciona pero con warnings ocultos".

NULL: la ausencia que rompe lógicas

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;

NULL no es cero, no es cadena vacía: es "NO SABEMOS / NO APLICA". En el modelo, Rosa no tiene teléfono registrado (NULL). Las comparaciones = NULL NUNCA devuelven filas — siempre IS NULL.

ValorSignificadoConsulta que lo encuentra
NULLdesconocido / no aplicaIS NULL
'' (cadena vacía)se preguntó y el dato es "nada"= ''

Trampas clásicas (y su antidoto)

TrampaSíntomaAntídoto
Comparar con = NULLla consulta devuelve SIEMPRE vacíoIS NULL / IS NOT NULL
COUNT(columna) esperando todas las filascuenta menos (excluye NULLs)COUNT(*) cuenta filas; COUNT(col) cuenta valores
WHERE telefono = '987...' con espacio finalno encuentra la filaTRIM() al cargar datos
WHERE telefono LIKE '%987%'busca en NULL también (NULL se ignora)agregar AND telefono IS NOT NULL

Diferencias clave MySQL → SQL Server

ConceptoMySQL/MariaDBSQL Server
Concatenar textoCONCAT('a', ' ', 'b')'a' + ' ' + 'b' (operador +)
Fecha/hora actualNOW() / CURRENT_TIMESTAMPGETDATE() / SYSDATETIME()
NULLIF alternativaIFNULL(col, 'x')ISNULL(col, 'x')
Dividir enteros7 / 2 = 3.5 (decimal)7 / 2 = 3 (entero) — usa 7.0 / 2
Verificar modoSELECT @@sql_modeno existe — siempre estricto

Prueba de las trampas, en vivo

USE clinica;
GO

-- 1) NULL vs vacío en acción:
SELECT COUNT(*) AS total FROM pacientes;            -- 20
SELECT COUNT(telefono) AS con_tel FROM pacientes;   -- menos (excluye NULLs)
GO

-- 2) la comparación que no compara:
SELECT COUNT(*) AS deve0 FROM pacientes WHERE telefono = NULL;   -- 0 (¡siempre!)
SELECT COUNT(*) AS correcto FROM pacientes WHERE telefono IS NULL; -- la correcta
GO

-- 3) división entera (la trampa de SQL Server):
SELECT 7 / 2 AS entera;          -- 3 (¡no 3.5!)
SELECT 7.0 / 2 AS decimal;      -- 3.5 (uno de los dos debe ser decimal)
GO

-- 4) ISNULL vs IFNULL:
SELECT ISNULL(NULL, 'sin dato') AS resultado;  -- 'sin dato'
GO
Cierre de Parte II: modelo entendido (cap 6), tablas creadas con tipos correctos (cap 7), leyes armadas (cap 8), datos deterministas cargados (hoy) y el servidor siempre estricto. La base clínica está LISTA para consultarse. Parte III: SQL de consulta, del primer SELECT a los CTEs.

Puntos clave

  • SQL Server SIEMPRE es estricto — no hay sql_mode configurable.
  • NULL ≠ '' ≠ 0: tres ausencias distintas con distinto significado.
  • ISNULL(col, 'x') en vez de IFNULL (MySQL).
  • División entera 7/2 = 3: usar 7.0/2 para decimal.
  • COUNT(*) cuenta filas; COUNT(col) excluye NULLs.

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 TOP (n) columna1, columna2  -- 1. QUÉ columnas (y cuántas)
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

No todas las cláusulas aparecen siempre — SELECT y FROM son los únicos obligatorios — pero el orden NO es negociable: es sintaxis.

TOP: el LIMIT de SQL Server

-- los 5 primeros pacientes:
SELECT TOP (5) nombre, apellido, fecha_nacimiento
FROM pacientes;

-- con WITH TIES: incluye filas empatadas en el corte
SELECT TOP (5) WITH TIES nombre, apellido, tipo_sangre
FROM pacientes
ORDER BY tipo_sangre;
-- si el 5to y 6to tienen el mismo tipo_sangre, ambos aparecen

TOP (n) va después de SELECT y antes de las columnas. Con WITH TIES, si hay empates en la columna de ORDER BY, se incluyen filas extra. Es algo que LIMIT no ofrece directamente.

Primeros pasos sobre pacientes

USE clinica;
GO

-- todo (solo para explorar tablas pequeñas):
SELECT * FROM especialidades;
GO

-- columnas explícitas (la forma profesional):
SELECT nombre, apellido, fecha_nacimiento
FROM pacientes;
GO

-- con alias para leer mejor el resultado:
SELECT nombre    AS nombre_paciente,
       apellido  AS apellido_paciente,
       fecha_nacimiento AS nacimiento
FROM pacientes;
GO

SELECT * en producción es mala señal: traes columnas que no usas, y si mañana la tabla gana una columna pesada, tu consulta la arrastra sin que la pidieras.

Expresiones: la consulta como calculadora

SELECT TOP (5)
    nombre,
    apellido,
    DATEDIFF(YEAR, fecha_nacimiento, GETDATE()) AS edad,
    nombre + ' ' + apellido AS nombre_completo
FROM pacientes;
GO

Las expresiones crean columnas calculadas al vuelo. Las diferencias con MySQL:

ConceptoMySQLSQL Server
Diferencia de fechasTIMESTAMPDIFF(YEAR, f1, f2)DATEDIFF(YEAR, f1, f2)
ConcatenarCONCAT(a, ' ', b)a + ' ' + b (operador +)
Fecha actualNOW()GETDATE()

DISTINCT: los valores únicos

-- ¿qué estados de cita EXISTEN realmente?
SELECT DISTINCT estado FROM citas;

-- ¿qué combinaciones médico+estado han ocurrido?
SELECT DISTINCT medico_id, estado FROM citas ORDER BY medico_id;
GO

DISTINCT elimina duplicados del RESULTADO — opera sobre la fila completa seleccionada, no sobre una columna aislada.

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.

-- ERROR: la columna alias 'edad' no existe aún cuando corre el WHERE
SELECT nombre,
       DATEDIFF(YEAR, fecha_nacimiento, GETDATE()) AS edad
FROM pacientes
WHERE edad > 40;
GO

-- CORRECTO: repetir la expresión en el WHERE
SELECT nombre,
       DATEDIFF(YEAR, fecha_nacimiento, GETDATE()) AS edad
FROM pacientes
WHERE DATEDIFF(YEAR, fecha_nacimiento, GETDATE()) > 40;
GO
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

  • TOP (n) = LIMIT. WITH TIES incluye empates en el corte.
  • SELECT * solo para explorar; en código, columnas nombradas.
  • AS nombra columnas calculadas (DATEDIFF, concatenación con +).
  • DISTINCT hace únicas las FILAS del resultado.
  • Se escribe SELECT primero pero se ejecuta FROM/WHERE primero.

12 · WHERE, ORDER BY y paginación: filtrar, ordenar, acotar

Intermedio ~15 min

El 80% de las consultas reales son esto: qué filas (WHERE), en qué orden (ORDER BY) y cuántas (TOP / OFFSET-FETCH). Dominar estas 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 con TOP y OFFSET-FETCH (la forma moderna).

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__%';
GO

Ojo con BETWEEN en DATETIME2: '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;
GO

Regla de la casa: si usas AND y OR en la misma condición, paréntesis. Siempre.

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 TOP (10) fecha_hora, estado, motivo
FROM citas
ORDER BY fecha_hora DESC;
GO

ORDER BY puede usar la posición de columna (ORDER BY 2) — no lo hagas: se rompe si editas el SELECT. Siempre nombra la columna.

Paginación: TOP y OFFSET-FETCH

-- TOP: las 10 primeras filas
SELECT TOP (10) id, apellido, nombre
FROM pacientes
ORDER BY apellido, nombre;

-- OFFSET-FETCH: la forma moderna y estándar (página 1)
SELECT id, apellido, nombre
FROM pacientes
ORDER BY apellido, nombre
OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY;

-- página 3 (filas 21 a 30):
SELECT id, apellido, nombre
FROM pacientes
ORDER BY apellido, nombre
OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;
GO

OFFSET-FETCH es el equivalente moderno a LIMIT ... OFFSET de MySQL/PostgreSQL. Requiere ORDER BY (obligatorio en SQL Server para cualquier operación de filas). TOP es más rápido pero solo devuelve las primeras N filas — no sirve para saltar páginas.

NecesidadSintaxis SQL ServerEquivalente MySQL
Primeras N filasTOP (n)LIMIT n
Página N de MOFFSET (N-1)*M ROWS FETCH NEXT M ROWS ONLYLIMIT M OFFSET (N-1)*M
Top con empatesTOP (n) WITH TIESsin equivalente directo

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 TOP (5) 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;
GO
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 TOP — todo de este capítulo.

Puntos clave

  • BETWEEN incluye extremos — y en DATETIME2 el día suelto miente.
  • AND antes que OR: paréntesis siempre que convivan.
  • LIKE: % cualquier cosa, _ un carácter exacto.
  • TOP (n) = primeras N filas; OFFSET-FETCH = paginación moderna.
  • OFFSET-FETCH REQUIERE ORDER BY — sin él, error de sintaxis.

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: LEN, TRIM, REPLACE, +.
  • Redondear y calcular: ROUND, CEILING, FLOOR, %.
  • Manipular fechas: DATEADD, DATEDIFF, FORMAT.
  • Manejar ausencias: COALESCE e IIF con datos reales.

Texto

SELECT
  nombre + ' ' + apellido                    AS completo,
  UPPER(LEFT(apellido, 3))                   AS iniciales_mayus,
  LEN(documento)                             AS largo_documento,
  TRIM('  Maria  ')                          AS limpio,
  REPLACE(telefono, ' ', '')                 AS telefono_sin_espacios
FROM pacientes
WHERE id <= 3;
GO

LEN cuenta caracteres (equivalente a CHAR_LENGTH de MySQL). El operador + concatena en SQL Server (no CONCAT con comas). Para concatenación segura con NULL, usa CONCAT que los trata como cadena vacía:

-- '+' propaga NULL: si telefono es NULL, todo el resultado es NULL
SELECT nombre + ' ' + apellido + ' - ' + telefono FROM pacientes WHERE id = 3;
-- resultado: NULL (porque telefono es NULL)

-- CONCAT ignora NULL: lo trata como ''
SELECT CONCAT(nombre, ' - ', telefono) FROM pacientes WHERE id = 3;
-- resultado: 'Rosa Huamán - '

Números

SELECT TOP (5)
  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;

SELECT 10 % 3 AS resto;   -- 1: operador módulo en SQL Server

SQL Server usa % como operador módulo (no MOD() como MySQL). ROUND redondea bankers: 2.5 → 2 (no 3). Para redondeo "hacia arriba siempre", CEILING es más predecible.

Fechas: el grupo más usado en la clínica

SELECT TOP (5)
  fecha_hora,
  CAST(fecha_hora AS DATE)                          AS solo_dia,
  FORMAT(fecha_hora, 'dd/MM/yyyy HH:mm', 'es-PE')  AS formato_pe,
  DATEDIFF(DAY, CAST(fecha_hora AS DATE), GETDATE()) AS dias_transcurridos,
  DATEADD(MINUTE, 30, fecha_hora)                    AS fin_estimado
FROM citas;
GO

-- ¿cuándo vence una receta de 7 días?
SELECT TOP (5) medicamento,
       DATEADD(DAY, duracion_dias, GETDATE()) AS vence
FROM recetas;
GO

Las diferencias clave con MySQL:

ConceptoMySQLSQL Server
Sumar tiempoDATE_ADD(f, INTERVAL 30 MINUTE)DATEADD(MINUTE, 30, f)
DiferenciaDATEDIFF(f1, f2)DATEDIFF(DAY, f1, f2) — requiere el intervalo
FormatearDATE_FORMAT(f, '%d/%m/%Y')FORMAT(f, 'dd/MM/yyyy', 'es-PE')
Fecha actualNOW()GETDATE() o SYSDATETIME()

FORMAT es potente pero lento: usa .NET CLR internamente. Para filtrar y comparar, NUNCA formatees — usa la columna DATE/DATETIME2 cruda. FORMAT es SOLO para mostrar.

COALESCE: el traductor de NULL

-- Rosa no tiene teléfono: mostramos un texto digno en vez de NULL
SELECT nombre + ' ' + apellido AS paciente,
       COALESCE(telefono, 'sin registrar') AS telefono,
       ISNULL(tipo_sangre, '?')           AS sangre
FROM pacientes
WHERE id <= 8;
GO

COALESCE(a, b, c) es estándar: evalúa en orden, devuelve el primer no nulo. ISNULL(x, y) es la versión específica de SQL Server para dos argumentos. Ambos funcionan; COALESCE es más portable.

IIF: decisión por fila (SQL Server)

SELECT nombre + ' ' + apellido AS paciente,
       IIF(activo = 1, 'activo', 'inactivo') AS estado
FROM pacientes
WHERE id <= 5;
GO

IIF(condición, si_verdadero, si_falso) es la función abreviada de SQL Server (equivalente a IF() de MySQL). Para decisiones complejas, CASE es el estándar (cap 16).

Portabilidad: COALESCE, ROUND, LEN, TRIM y DATEDIFF básicos existen en los cuatro motores, pero los formatos y operadores de concatenación cambian mucho. Cuando recorramos los otros manuales, este capítulo es el que más difiere entre dialectos.

Puntos clave

  • LEN = caracteres; concatenación con + (o CONCAT para NULL-safe).
  • ROUND bankers (2.5→2); CEILING siempre hacia arriba.
  • DATEADD/DATEDIFF requieren el intervalo explícito.
  • FORMAT es solo para mostrar; filtrar con dato crudo.
  • COALESCE portable; ISNULL es SQL Server específico.

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_pagos,      -- cuenta FILAS
  COUNT(diagnostico) AS con_diagnostico,  -- cuenta VALORES (excluye NULL)
  SUM(monto)        AS monto_total,
  AVG(monto)        AS promedio,
  MIN(monto)        AS minimo,
  MAX(monto)        AS maximo
FROM pagos;
GO

La diferencia COUNT(*) vs COUNT(columna) del cap 10 ahora cobra sentido: COUNT(diagnostico) NO cuenta las citas sin diagnóstico escrito. Cada agregada responde una pregunta distinta.

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;
GO

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.

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;
GO

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. HAVING corre DESPUÉS del GROUP BY.

La regla de oro del GROUP BY

-- ERROR: columna no agrupada ni agregada
SELECT metodo, fecha_hora, SUM(monto) FROM pagos GROUP BY metodo;
-- Msg 8120: Column 'pagos.fecha_hora' is invalid in select list

-- CORRECTO: toda columna del SELECT está en GROUP BY o en un agregado
SELECT metodo, COUNT(*), SUM(monto) FROM pagos GROUP BY metodo;
GO

SQL Server SIEMPRE aplica esta regla (no hay modo indulgente como MySQL viejo). Si una columna del SELECT ni se agrupa ni se agrega, error.

STRING_AGG: la lista dentro de la fila

-- todos los médicos de cada especialidad, en una línea:
SELECT e.nombre AS especialidad,
       STRING_AGG(m.apellido + ' (' + m.colegiatura + ')', ', ')
           WITHIN GROUP (ORDER BY m.apellido) AS medicos
FROM medicos m
JOIN especialidades e ON e.id = m.especialidad_id
GROUP BY e.nombre;
GO

STRING_AGG es el equivalente de SQL Server a GROUP_CONCAT de MySQL y STRING_AGG de PostgreSQL. La cláusula WITHIN GROUP (ORDER BY) define el orden de los valores concatenados — algo que GROUP_CONCAT hace con una cláusula al final.

Ejercicio de la clínica: construye el reporte "por mes de 2026: cantidad de citas atendidas" con GROUP BY DATEPART(YEAR, fecha_hora), DATEPART(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.
  • STRING_AGG con WITHIN GROUP (ORDER BY) = GROUP_CONCAT de SQL Server.
  • SQL Server siempre exige GROUP BY completo (no hay modo indulgente).

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 TOP (10)
  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;
GO

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. INNER JOIN devuelve SOLO las citas cuyo paciente Y médico existen.

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;
GO

Con INNER, el paciente sin citas desaparecería del resultado. Con LEFT, aparecen TODOS; los que no tienen citas traen NULL en las columnas de citas.

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;
GO

El patrón es siempre el mismo: LEFT JOIN hacia la tabla "hija" y WHERE hijo.id IS NULL. La condición del hijo vive DENTRO del ON, no en el WHERE.

INNER vs LEFT: la decisión en una frase

Pregunta de negocioJOIN
"Las citas de julio con sus pacientes"INNER — toda cita tiene paciente
"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). SQL Server 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 TOP (5) 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 TOP (1) WITH TIES id, nombre, apellido
FROM medicos
WHERE id = (SELECT medico_id
            FROM citas
            GROUP BY medico_id
            ORDER BY COUNT(*) DESC);
GO

La subconsulta corre PRIMERO, produce UN valor, y la externa lo usa como una constante.

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
GO

Si la subconsulta de NOT IN devuelve UN SOLO NULL, la comparación id NOT IN (..., NULL) nunca es verdadera — NULL envenena toda la lista.

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);
GO

EXISTS no mira valores: pregunta "¿EXISTE al menos una fila?" y se detiene en la primera que encuentra. Inmune al NULL. 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;
GO

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 a los CTEs del cap 17.

CASE: el condicional de columnas

SELECT TOP (10) 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;

-- 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;
GO

CASE evalúa condiciones EN ORDEN y devuelve el primer WHEN verdadero; el ELSE es el default. A diferencia de IIF() (solo valor simple), CASE maneja rangos y condiciones complejas. Es estándar SQL: 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); IIF es abreviatura SQL Server.

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 tabla 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 en velocidad
"Puedo reusarla en la siguiente consulta"no: para eso existen las VISTAS (cap 26)

SQL Server soporta CTEs recursivas (WITH RECURSIVE no se escribe así aquí — solo WITH es suficiente): 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, pero las verás en cualquier empresa real.

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 tabla temporal.
  • 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 + window function).

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,
       CAST(COUNT(c.id) AS FLOAT) / COUNT(DISTINCT m.id) AS citas_por_medico
FROM especialidades e
JOIN medicos m ON m.especialidad_id = e.id AND m.activo = 1
LEFT JOIN citas c ON c.medico_id = m.id AND c.estado = 'ATENDIDA'
GROUP BY e.nombre
ORDER BY citas DESC;

Atención al detalle: COUNT(DISTINCT m.id) para no contar médicos dos veces. La condición de citas ATENDIDAS va dentro del ON del LEFT — si estuviera en el WHERE eliminaría especialidades sin citas atendidas (la trampa del cap 15). CAST a FLOAT para división decimal: en SQL Server 7/2 = 3 (división entera).

3 · Actividad mensual 2026

SELECT FORMAT(fecha_hora, 'yyyy-MM') AS mes,
       COUNT(*) AS total,
       SUM(CASE WHEN estado = 'ATENDIDA' THEN 1 ELSE 0 END) AS atendidas,
       SUM(CASE WHEN estado = 'CANCELADA' THEN 1 ELSE 0 END) AS canceladas,
       SUM(CASE WHEN estado = 'NO_ASISTIO' THEN 1 ELSE 0 END) AS inasistencias
FROM citas
WHERE fecha_hora >= '2026-01-01'
GROUP BY FORMAT(fecha_hora, 'yyyy-MM')
ORDER BY mes;

SQL Server no tiene el atajo SUM(condición) de MySQL. Se usa SUM(CASE WHEN ... THEN 1 ELSE 0 END) — portable a todos los motores, explícito y sin ambigüedades. FORMAT() devuelve NVARCHAR (display), no usarlo para agrupaciones masivas ( GROUP BY sobre FORMAT es más lento que GROUP BY sobre una expresión DATE).

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 FORMAT(fecha_hora, 'yyyy-MM') AS mes,
           medico_id,
           COUNT(*) AS atendidas
    FROM citas
    WHERE estado = 'ATENDIDA'
    GROUP BY FORMAT(fecha_hora, 'yyyy-MM'), 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(CASE WHEN ... THEN 1 ELSE 0 END): el mini-pivote portable (no hay SUM(condición)).
  • ROW_NUMBER() OVER (PARTITION BY ...): ranking por grupo — adelanto de ventanas.
  • CAST(... AS FLOAT) para división decimal: SQL Server hace división entera por defecto.

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...FROM y TOP para modificaciones parciales.

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 SQL Server 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 = 0 WHERE id = 4;

-- y todas las consultas de la app filtran:
SELECT * FROM pacientes WHERE activo = 1;

¿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. Nota: SQL Server usa BIT (0/1), no TRUE/FALSE.

Las FK frenan lo que no debe ocurrir

-- intenta borrar un médico CON citas:
DELETE FROM medicos WHERE id = 2;
-- Msg 547: The DELETE statement conflicted with the REFERENCE constraint
-- "fk_citas_medico". The conflict occurred in database "clinica",
-- table "dbo.citas", column 'medico_id'.

El error 547 es el equivalente al 1451 de MySQL: 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, 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...FROM: cambios en lote con otra tabla

-- desactivar a todos los médicos de una especialidad:
UPDATE m
SET m.activo = 0
FROM medicos m
JOIN especialidades e ON e.id = m.especialidad_id
WHERE e.nombre = 'Oftalmología';

SQL Server usa UPDATE alias SET ... FROM tabla alias JOIN ... en lugar de UPDATE...JOIN de MySQL. La sintaxis parece al revés: primero el alias del SET, después el FROM con los joins. Es la diferencia más fácil de equivocar al escribir.

UPDATE con TOP: limitar filas modificadas

-- liberar las 5 citas PROGRAMADAS más antiguas sin respuesta:
UPDATE TOP (5) citas
SET estado = 'CANCELADA'
WHERE estado = 'PROGRAMADA'
ORDER BY fecha_hora;
-- Error: ORDER BY no es válido en UPDATE con TOP... sí, así es en SQL Server

Truco: SQL Server no permite ORDER BY directo en UPDATE. Se resuelve con CTE o subconsulta:

-- solución correcta: CTE con TOP
WITH viejas AS (
    SELECT TOP (5) *
    FROM citas
    WHERE estado = 'PROGRAMADA'
    ORDER BY fecha_hora
)
UPDATE v
SET v.estado = 'CANCELADA'
FROM viejas v;

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óneoerror de conversiónstrict mode (siempre activo, cap 10)
UPDATE viola CHECKerror Msg 547chk_monto_positivo (cap 8)
UPDATE rompe UNIQUEerror Msg 2627uq_medico_fecha (cap 8)
DELETE de padre con hijoserror Msg 547FOREIGN 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 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 = 0) para datos con historia — BIT, no BOOLEAN.
  • Error Msg 547: la FK frenando huérfanos — buena señal, otra vez.
  • UPDATE...FROM: sintaxis inversa a MySQL; practícala en el laboratorio.
  • TOP (n) con CTE: el reemplazo a LIMIT para modificaciones parciales.

20 · MERGE: el UPSERT de SQL Server

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). SQL Server tiene UN camino profesional: MERGE. Sin atajos silenciosos ni bombas de relojería.

  • La sintaxis MERGE: INTO, USING, ON, WHEN MATCHED/NOT MATCHED.
  • Por qué no hay INSERT IGNORE ni REPLACE en SQL Server.
  • El caso real: sincronizar el catálogo de especialidades.
  • Advertencias de concurrencia: cuándo MERGE puede fallar en paralelo.

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');
-- Msg 2627: Violation of UNIQUE KEY constraint 'uq_esp_nombre'.
-- Cannot insert duplicate key in object 'dbo.especialidades'.

SQL Server no tiene INSERT IGNORE

MySQL tiene INSERT IGNORE (silencia el error y sigue) y REPLACE (borra y reinserta). SQL Server no tiene ninguno de los dos. ¿Por qué? Porque ambos son peligrosos: INSERT IGNORE oculta problemas de integridad, y REPLACE destruye filas con FKs y triggers. SQL Server exige que decidas conscientemente qué hacer con cada fila duplicada.

MERGE: el camino profesional

MERGE INTO especialidades AS target
USING (VALUES ('Cardiología', 'Actualización de descripción'))
       AS source (nombre, descripcion)
ON target.nombre = source.nombre
WHEN MATCHED THEN
    UPDATE SET target.descripcion = source.descripcion
WHEN NOT MATCHED THEN
    INSERT (nombre, descripcion)
    VALUES (source.nombre, source.descripcion);

MERGE es explícito: define Qué hacer cuando hay coincidencia (MATCHED) y Qué hacer cuando no la hay (NOT MATCHED). La fila existente queda intacta — mismo id, mismas FK, sin borrados. Si NO choca: INSERT normal. Si CHOCA: UPDATE limpio.

El caso real: sincronizar el catálogo

-- un archivo de especialidades que llega de la sede central:
MERGE INTO especialidades AS target
USING (VALUES
    ('Cardiología',   'Corazón y sistema circulatorio — v2'),
    ('Pediatría',     'Atención de niños y adolescentes'),
    ('Nutrición',     'Planes alimentarios y metabolismo')
) AS source (nombre, descripcion)
ON target.nombre = source.nombre
WHEN MATCHED THEN
    UPDATE SET target.descripcion = source.descripcion
WHEN NOT MATCHED THEN
    INSERT (nombre, descripcion)
    VALUES (source.nombre, source.descripcion);
-- resultado: 1 insertada (Nutrición), 2 actualizadas: una sola sentencia

El tercer caso: WHEN NOT MATCHED BY SOURCE

-- borrar del catálogo las especialidades que ya no vienen del archivo:
MERGE INTO especialidades AS target
USING (VALUES
    ('Cardiología', 'Corazón y sistema circulatorio'),
    ('Pediatría',   'Atención de niños y adolescentes')
) AS source (nombre, descripcion)
ON target.nombre = source.nombre
WHEN NOT MATCHED BY SOURCE THEN
    DELETE;
-- elimina las especialidades del target que NO están en el source
-- ¡cuidado! Solo úsalo cuando realmente necesitas limpiar

Los tres motores, comparados

SQL Server (MERGE)MySQLPostgreSQL
Si NO chocainserta (NOT MATCHED)insertainserta
Si chocaupdate (MATCHED)ON DUPLICATE KEY UPDATEON CONFLICT DO UPDATE
Borrar los que sobranNOT MATCHED BY SOURCEno directono directo
Veredictoel más explícito de los tresON DUPLICATE KEY es suficienteON CONFLICT es suficiente
Advertencia de concurrencia: MERGE puede fallar si otra sesión modifica las mismas filas durante la ejecución. En entornos de alta concurrencia, se recomienda usar transacciones con nivel de aislamiento SNAPSHOT o SERIALIZABLE (cap 22). Para operaciones simples de una sola tabla, INSERT/UPDATE por separado puede ser más seguro que MERGE.

Dialectos: la tabla completa

MotorUPSERT
MySQL/MariaDBINSERT ... ON DUPLICATE KEY UPDATE
PostgreSQLINSERT ... ON CONFLICT (clave) DO UPDATE
SQL ServerMERGE INTO ... USING ... ON ... WHEN MATCHED THEN UPDATE
SQLiteINSERT ... ON CONFLICT DO UPDATE (desde 3.24)
Regla del curso: MERGE por defecto en SQL Server. Evítalo si la concurrencia es extrema y usa INSERT/UPDATE por separado. Y en el cap 30 verás que desde PHP el patrón es idéntico — solo cambian los parámetros.

Puntos clave

  • MERGE: INTO target USING source ON condición WHEN MATCHED/NOT MATCHED.
  • No hay INSERT IGNORE ni REPLACE — SQL Server exige decisiones explícitas.
  • NOT MATCHED BY SOURCE: el tercer caso (borrar los que sobran).
  • Cada motor tiene su sintaxis: la tabla de dialectos crece.
  • Concurrencia: MERGE puede fallar en paralelo — SNAP/SERIALIZABLE o INSERT/UPDATE separados.

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 BEGIN TRAN / 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

BEGIN TRAN;

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', SYSTEM_USER, 5);

COMMIT;    -- todo queda, atómico
-- o ROLLBACK;   -- nada ocurrió, como si no hubieras escrito

Nota: SQL Server usa BEGIN TRAN (no START TRANSACTION). Todo lo que hagas entre BEGIN 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 (MSSQL)
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 (transaction 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 (S/ 500); Carlos registra 3 pagos; Rosa vuelve a consultar para su cierre (S/ 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 COMMITTED (default SQL Server)noposibleposiblebajo
REPEATABLE READnonoposible*medio
SERIALIZABLEnononoalto
SNAPSHOTnononomedio (usa tempdb)
-- ¿dónde estoy?
SELECT CASE transaction_isolation_level
    WHEN 0 THEN 'UNSPECIFIED' WHEN 1 THEN 'READ UNCOMMITTED'
    WHEN 2 THEN 'READ COMMITTED' WHEN 3 THEN 'REPEATABLE READ'
    WHEN 4 THEN 'SERIALIZABLE' WHEN 5 THEN 'SNAPSHOT'
END AS nivel_actual;

-- subir de nivel (con costo):
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

*REPEATABLE READ mitiga parcialmente los fantasmas con key-range locking, pero no tan sólido como SERIALIZABLE. SQL Server además ofrece SNAPSHOT: usa versión en tempdb para que las lecturas no bloqueen escrituras (y viceversa). Requiere ALTER DATABASE clinica SET ALLOW_SNAPSHOT_ISOLATION ON.

La regla práctica

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

Puntos clave

  • BEGIN TRAN/COMMIT/ROLLBACK: unidad atómica de trabajo.
  • ACID: la C es compartida — tus constraints son parte.
  • 3 problemas: lectura sucia, no repetible, fantasma.
  • SQL Server: READ COMMITTED por defecto — sube con criterio.
  • Transacción corta: abrir-hacer-cerrar, sin pasear por medio.

22 · Tablas temporales y SELECT INTO

Intermedio ~14 min

A veces necesitas un espacio de trabajo: copiar datos para procesar, guardar un resultado intermedio, preparar una carga. SQL Server da dos herramientas para eso — y una regla para elegir entre ellas y las CTEs.

  • Crear tablas temporales con # y entender su ciclo de vida.
  • Duplicar estructura y datos con SELECT INTO.
  • Respaldar una tabla antes de una operación riesgosa (patrón real).
  • Decidir entre tabla temporal, CTE y vista.

#temporales: vive en tu sesión, muere con ella

-- la tabla temporal se crea con # al inicio del nombre:
SELECT c.id AS cita_id, p.apellido, p.nombre, c.motivo
INTO #tmp_deudores
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 DROP 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), vive en tempdb, y se destruye automáticamente al cerrar la sesión. Si quieres soltarla antes: DROP TABLE #tmp_deudores;

Las #temporales son objetos reales: tienen índices, estadísticas y permiten JOINs. Son el equivalente a las TEMPORARY TABLE de MySQL, pero con mejor rendimiento (estadísticas automáticas del motor).

Las tres formas de crear desde otra tabla

-- 1) SELECT INTO: copia estructura + datos en una nueva tabla permanente:
SELECT * INTO respaldo_citas_2026
FROM citas WHERE fecha_hora >= '2026-01-01';

-- 2) SELECT INTO #temp: lo mismo pero como temporal:
SELECT * INTO #tmp_vacia
FROM pagos WHERE 1 = 0;  -- solo estructura, sin datos

-- 3) LIKE para copiar solo la estructura de una tabla existente:
CREATE TABLE respaldo_pagos (
    LIKE pagos);  -- no soportado en SQL Server

SQL Server no soporta CREATE TABLE ... LIKE. En su lugar, se usa SELECT ... INTO ... WHERE 1=0 para copiar solo estructura, o sp_help 'tabla' + script manual. SELECT INTO copia columnas, tipos y datos, pero no copia índices, PK ni FK — si los necesitas, créalos después.

El patrón real: respaldo antes de operar

-- antes de una migración riesgosa de pagos:
SELECT * INTO pagos_bkp_20260825 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.

@@IDENTITY, SCOPE_IDENTITY y OUTPUT

-- OUTPUT devuelve los ids generados en el mismo INSERT:
INSERT INTO pacientes (documento, nombre, apellido, fecha_nacimiento)
OUTPUT INSERTED.id
VALUES ('99887766', 'Luis', 'Ccahuana', '1995-05-12');

-- SCOPE_IDENTITY(): el último id generado en tu sesión/ámbito
SELECT SCOPE_IDENTITY();  -- más seguro que @@IDENTITY

-- @@IDENTITY: último id de la sesión, pero se contamina con triggers

La diferencia clave: SCOPE_IDENTITY() respeta el ámbito — si un trigger inserta en otra tabla, @@IDENTITY devuelve el id del trigger, no el tuyo. SCOPE_IDENTITY() siempre devuelve el tuyo. Usa SCOPE_IDENTITY() siempre.

¿Tabla temporal, CTE o vista?

#temporalCTE (cap 17)VISTA (cap 24)
Vivetu conexión (tempdb)una 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
Tabla de dialectos: MySQL usa CREATE TEMPORARY TABLE, PostgreSQL usa CREATE TEMP TABLE, SQL Server usa #nombre con SELECT INTO, y SQLite tiene CREATE TEMP TABLE. El concepto es universal; la sintaxis varía.

Puntos clave

  • #temporal: por conexión, en tempdb, se autodestruye.
  • SELECT INTO: copia estructura+datos; no copia índices/PK/FK.
  • SCOPE_IDENTITY() más seguro que @@IDENTITY (no se contamina con triggers).
  • Patrón respaldo: SELECT INTO con fecha → verificar conteos → operar.
  • Temporal = proceso multi-paso · CTE = una consulta · Vista = todos.

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 SCOPE_IDENTITY dentro de la transacción.
  • Cerrar Parte IV con el patrón transaccional de la app.

La transacción completa

BEGIN TRAN;

-- 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 (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', GETDATE());

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)

BEGIN TRAN;

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);
-- Msg 2627: Violation of UNIQUE constraint '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 de SCOPE_IDENTITY

En el paso 2 usé el id de la cita explícito (5) porque la cita YA existía. Si la transacción fuera "nuevo paciente + su primera cita", el patrón sería:

BEGIN TRAN;
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 (SCOPE_IDENTITY(),   -- el id del paciente recién creado
        2, '2026-09-01 09:00:00', 'PROGRAMADA', 'Primera consulta');
COMMIT;

SCOPE_IDENTITY() es seguro dentro de transacciones concurrentes: cada sesión ve SU id, sin importar lo que hagan otros. Más seguro que @@IDENTITY que se contamina si un trigger inserta en otra tabla.

El patrón transaccional que usará la app

PasoEn el cap 23 (SQL puro)En el cap 31 (PHP/PDO)
1. abrirBEGIN TRAN$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), MERGE 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).
  • SCOPE_IDENTITY() por ámbito: 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 ALTER VIEW o 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,
       p.apellido + ', ' + p.nombre AS paciente,
       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 >= CAST(GETDATE() AS DATE)
  AND c.fecha_hora < DATEADD(DAY, 1, CAST(GETDATE() AS DATE));

-- 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: ALTER VIEW

-- cambiar la definición (no pierde permisos asociados):
ALTER VIEW citas_hoy AS
SELECT c.id, c.fecha_hora, c.estado,
       p.apellido + ', ' + p.nombre AS paciente,
       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 >= CAST(GETDATE() AS DATE)
  AND c.fecha_hora < DATEADD(DAY, 1, CAST(GETDATE() AS DATE));

-- ver su definición guardada:
sp_helptext 'citas_hoy';

SQL Server no tiene CREATE OR REPLACE VIEW como MySQL — usa ALTER VIEW. sp_helptext muestra el texto de la definición (equivalente a SHOW CREATE VIEW).

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; #temporal = 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).
  • ALTER VIEW actualiza sin perder permisos (no hay CREATE OR REPLACE).
  • sp_helptext 'vista' = SHOW CREATE VIEW de SQL Server.
  • No son más rápidas ni aceptan parámetros.

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.

  • Crear procedures con CREATE PROCEDURE...AS BEGIN...END.
  • Usar parámetros IN (default) y OUTPUT.
  • Lanzar errores con THROW.
  • Entender por qué NO necesitas DELIMITER en SQL Server.

Sin DELIMITER: el cuerpo va entre AS y END

MySQL necesita DELIMITER porque el cliente interpreta cada ; como "envía la sentencia". SQL Server no tiene ese problema: el lote termina con GO, y el cuerpo del procedure va entre AS y END sin cambiar nada.

CREATE PROCEDURE p_test AS
BEGIN
    SELECT 1;
END;
GO

GO no es T-SQL — es un separador de lotes del cliente (sqlcmd, SSMS, Azure Data Studio). El procedure se crea completo de una vez.

agendar_cita: la operación completa

CREATE PROCEDURE agendar_cita
    @paciente_id INT,
    @medico_id   INT,
    @fecha_hora  DATETIME2,
    @motivo      NVARCHAR(255)
AS
BEGIN
    SET NOCOUNT ON;

    -- el paciente existe y está activo:
    IF NOT EXISTS (SELECT 1 FROM pacientes WHERE id = @paciente_id AND activo = 1)
    BEGIN
        THROW 50001, 'Paciente inexistente o inactivo', 1;
    END;

    -- el médico existe y está activo:
    IF NOT EXISTS (SELECT 1 FROM medicos WHERE id = @medico_id AND activo = 1)
    BEGIN
        THROW 50002, 'Medico inexistente o inactivo', 1;
    END;

    -- regla de la doble agenda (uq_medico_fecha, cap 8):
    IF EXISTS (SELECT 1 FROM citas
               WHERE medico_id = @medico_id AND fecha_hora = @fecha_hora)
    BEGIN
        THROW 50003, 'El medico ya tiene una cita en ese horario', 1;
    END;

    INSERT INTO citas (paciente_id, medico_id, fecha_hora, estado, motivo)
    VALUES (@paciente_id, @medico_id, @fecha_hora, 'PROGRAMADA', @motivo);
END;
GO

Usarlo

EXEC agendar_cita
    @paciente_id = 1,
    @medico_id   = 2,
    @fecha_hora  = '2026-09-10 09:00:00',
    @motivo      = 'Control mensual';

-- y el caso de error, con el mensaje NUESTRO:
EXEC agendar_cita @paciente_id = 1, @medico_id = 2,
    @fecha_hora = '2026-09-10 09:00:00', @motivo = 'Doble agenda';
-- Msg 50003, Level 16, State 1, Line X
-- El medico ya tiene una cita en ese horario

THROW 50001-99999 es el mecanismo de errores propios: el cliente recibe Msg 5000X con TU mensaje — 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 OUTPUT: respuestas del procedure

CREATE PROCEDURE contar_citas_paciente
    @paciente_id INT,
    @total       INT OUTPUT,
    @atendidas   INT OUTPUT
AS
BEGIN
    SET NOCOUNT ON;
    SELECT @total = COUNT(*),
           @atendidas = SUM(CASE WHEN estado = 'ATENDIDA' THEN 1 ELSE 0 END)
    FROM citas
    WHERE paciente_id = @paciente_id;
END;
GO

-- uso: declarar variables para OUTPUT:
DECLARE @t INT, @a INT;
EXEC contar_citas_paciente @paciente_id = 1, @total = @t OUTPUT, @atendidas = @a OUTPUT;
SELECT @t AS total, @a AS atendidas;

Parámetros OUTPUT se reciben en variables locales DECLARE. El keyword OUTPUT va tanto en la definición como en la llamada. SET NOCOUNT ON evita el mensaje "N rows affected" que cada SELECT dentro del procedure generaría.

Reglas del cuerpo

ConstructoNota del curso
IF ... BEGIN ... ENDdecisiones del negocio (no BEGIN...END anidados necesarios)
SET @var = valorasignación de variables locales
BEGIN TRAN / COMMITpermitido EN procedures
THROW 50001, 'msg', 1errores propios (reemplaza SIGNAL SQLSTATE)
SET NOCOUNT ONevita mensajes de conteo — siempre al inicio
¿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

  • CREATE PROCEDURE...AS BEGIN...END + GO: sin DELIMITER.
  • THROW 50001, 'msg', 1: errores con TU mensaje.
  • Parámetros OUTPUT se reciben con DECLARE + EXEC...OUTPUT.
  • SET NOCOUNT ON siempre al inicio del procedure.
  • 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 RETURN.
  • Usarlas en SELECT y WHERE como funciones nativas.
  • Entender las restricciones: sin modificaciones, sin COMMIT.
  • Elegir entre función y procedure con criterio.

fn_edad: la edad que nunca miente

CREATE FUNCTION fn_edad(@nacimiento DATE)
RETURNS INT
AS
BEGIN
    RETURN DATEDIFF(YEAR, @nacimiento, GETDATE())
         - CASE WHEN MONTH(@nacimiento) > MONTH(GETDATE())
                  OR (MONTH(@nacimiento) = MONTH(GETDATE())
                      AND DAY(@nacimiento) > DAY(GETDATE()))
                THEN 1 ELSE 0
           END;
END;
GO

-- uso: exactamente como una función nativa:
SELECT nombre, apellido, dbo.fn_edad(fecha_nacimiento) AS edad
FROM pacientes;

-- y en el WHERE:
SELECT nombre, apellido, dbo.fn_edad(fecha_nacimiento) AS edad
FROM pacientes
WHERE dbo.fn_edad(fecha_nacimiento) >= 65
ORDER BY edad DESC;

Nota el dbo. delante: SQL Server requiere el prefijo de esquema al llamar funciones escalares en expresiones. No es opcional — sin dbo. no la encuentra.

¿Por qué DATEDIFF no es suficiente?

-- DATEDIFF cuenta "cruces de año", no años cumplidos:
SELECT DATEDIFF(YEAR, '2000-12-31', '2001-01-01');  -- devuelve 1, pero solo pasó 1 día

-- la función corrige: si el cumpleaños aún no pasó este año, resta 1

La función corrige el artificialismo de DATEDIFF: si la persona nació el 31 de diciembre de 2000 y hoy es 1 de enero de 2001, DATEDIFF dice 1 año pero solo pasó 1 día. La función comprueba si el cumpleaños ya pasó este año y resta 1 si no.

fn_saldo: la función que lee la base

CREATE FUNCTION fn_saldo_paciente(@paciente_id INT)
RETURNS DECIMAL(10,2)
AS
BEGIN
    DECLARE @saldo DECIMAL(10,2);

    SELECT @saldo = COALESCE(SUM(CASE pg.estado
                                    WHEN 'PENDIENTE' THEN pg.monto
                                    ELSE 0 END), 0)
    FROM pagos pg
    JOIN citas c ON c.id = pg.cita_id
    WHERE c.paciente_id = @paciente_id;

    RETURN @saldo;
END;
GO

-- los deudores, en una línea:
SELECT apellido, nombre, dbo.fn_saldo_paciente(id) AS saldo
FROM pacientes
WHERE dbo.fn_saldo_paciente(id) > 0
ORDER BY saldo DESC;

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, OUTPUTs, nada
Se invocadbo.nombre() dentro de expresionesEXEC, como sentencia
Puede modificar datosNO
Puede hacer COMMITNO
En la clínicafn_edad, fn_saldo (cálculos)agendar_cita (operaciones)
Nota sobre esquemas: en SQL Server, las funciones siempre viven en un esquema (dbo por defecto). El prefijo dbo. es OBLIGATORIO al llamarlas en expresiones — sin él, el motor no la encuentra. En MySQL no hay esquema en functions, en PostgreSQL es el search_path.

Puntos clave

  • RETURNS + RETURN: un valor, usable en expresiones.
  • Prefijo dbo. obligatorio al llamar la función.
  • Función: calcula y no modifica · Procedure: opera y modifica.
  • Sin resultset ni COMMIT dentro de una función.
  • DATEDIFF cuenta cruces de año — la función corrige con la resta.

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 y sp_set_session_context: el canal oficial entre tu aplicación y los objetos del motor.

  • Asignar y leer @variables de sesión.
  • Usar sp_set_session_context y SESSION_CONTEXT().
  • Confirmar que procedures y triggers pueden leerlas.
  • Montar el canal usuario_app que el trigger del cap 28 consumirá.

La mecánica: @variables locales

DECLARE @usuario_app NVARCHAR(128) = 'rrosa';

SELECT @usuario_app;                 -- 'rrosa'
SELECT @inexistente;                 -- ERROR: debe declararse primero

SQL Server NO tiene variables de usuario sueltas como MySQL (@var sin DECLARE). Toda variable local se declara con DECLARE al inicio del lote. Es más estricto: no puedes olvidar el tipo, y no existen "variables globales" libres — cada sesión declara las suyas.

sp_set_session_context: el canal persistente

-- 1) la app abre conexión y DECLARA quién opera:
sp_set_session_context N'usuario_app', N'rrosa';

-- 2) leerla en cualquier momento:
SELECT SESSION_CONTEXT(N'usuario_app');  -- 'rrosa'

-- 3) borrarla:
sp_set_session_context N'usuario_app', NULL;

sp_set_session_context es la forma RECOMENDADA de SQL Server para pasar datos de la aplicación al motor. Diferencia clave con @variables: el contexto de sesión persiste a través de MÚLTIPLES lotes y procedures — mientras la conexión viva, el dato está disponible.

El alcance, demostrado

-- Terminal de Rosa:
sp_set_session_context N'usuario_app', N'rrosa';
SELECT SESSION_CONTEXT(N'usuario_app');  -- 'rrosa'

-- Terminal de Carlos (OTRA conexión, al mismo tiempo):
SELECT SESSION_CONTEXT(N'usuario_app');  -- NULL — ¡su sesión es otro mundo!

-- Rosa cierra su cliente y vuelve a entrar:
SELECT SESSION_CONTEXT(N'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:
sp_set_session_context N'usuario_app', N'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 el contexto:
CREATE PROCEDURE registrar_cambio
    @tabla      NVARCHAR(64),
    @operacion  NVARCHAR(10),
    @registro   INT
AS
BEGIN
    SET NOCOUNT ON;
    INSERT INTO auditoria (tabla_afectada, operacion,
                           usuario_app, usuario_bd, registro_id, fecha_hora)
    VALUES (@tabla, @operacion,
            SESSION_CONTEXT(N'usuario_app'),   -- la persona (canal de sesión)
            SYSTEM_USER,                       -- la cuenta de conexión
            @registro, GETDATE());
END;
GO

EXEC registrar_cambio @tabla = 'pacientes', @operacion = 'UPDATE', @registro = 1;

SELECT usuario_app, usuario_bd, operacion, fecha_hora
FROM auditoria
ORDER BY id DESC;
-- rrosa | SA | UPDATE | ...   ← LA DUPLA COMPLETA

El procedure lee SESSION_CONTEXT('usuario_app') que la app puso con sp_set_session_context. La dupla usuario_app + usuario_bd queda registrada automáticamente.

SUSER_NAME() vs SYSTEM_USER: el matiz fino

-- conectado como SA:
SELECT SUSER_NAME(), SYSTEM_USER;
-- SA | SA   (coinciden... casi siempre)

-- la diferencia aparece con EXECUTE AS: SYSTEM_USER es la cuenta original;
-- SUSER_NAME() es la cuenta con permisos actuales.
-- Para auditoría: SYSTEM_USER — es la cuenta real de la conexión.

@variables vs sp_set_session_context

@variable (DECLARE)sp_set_session_context
Alcancelote/procedure actualtoda la sesión
Persiste entre lotesno
Persiste entre proceduresno
LecturaSELECT @varSESSION_CONTEXT('key')
Úsalo paravariables de trabajo localescanal app → motor (auditoría)
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 SESSION_CONTEXT('usuario_app'), en cada INSERT/UPDATE/DELETE de las tablas sensibles. La auditoría dual automática, por fin.

Puntos clave

  • DECLARE @var: variables locales, por lote/procedure.
  • sp_set_session_context / SESSION_CONTEXT(): canal persistente por sesión.
  • Patrón: abrir conexión → sp_set_session_context → operar firmado.
  • Para auditoría: SYSTEM_USER (cuenta real, no la declarada).
  • @variables se pierden entre procedures; session_context no.

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 INSERTED y DELETED según el evento.
  • Cancelar operaciones inválidas con INSTEAD OF + THROW.
  • Probar la auditoría dual completa de punta a punta.

La anatomía

CREATE TRIGGER nombre
ON tabla
{AFTER | INSTEAD OF} {INSERT | UPDATE | DELETE}
AS
BEGIN
  ...cuerpo...
END

Tres decisiones de diseño en la propia cabecera: CUÁNDO (AFTER: después de que la fila se escriba — la fila ya es final; INSTEAD OF: reemplaza la operación — se ejecuta en lugar de la original), QUÉ EVENTO (uno por trigger), y las tablas virtuales INSERTED y DELETED que contienen las filas afectadas.

INSERTED y DELETED: las tablas virtuales

EventoINSERTEDDELETED
INSERTla fila nuevavacía
DELETEvacíala fila que se va
UPDATEla fila DESPUÉS del cambiola fila ANTES del cambio

Equivalencias con MySQL: INSERTED = NEW, DELETED = OLD. La diferencia es que SQL Server no tiene BEFORE triggers (solo AFTER e INSTEAD OF), y las tablas virtuales son tablas completas que puedes usar con SELECT.

La auditoría dual de pacientes

CREATE TRIGGER trg_pacientes_upd
ON pacientes
AFTER UPDATE
AS
BEGIN
    SET NOCOUNT ON;
    INSERT INTO auditoria (tabla_afectada, operacion,
                           usuario_app, usuario_bd, registro_id,
                           fecha_hora, datos_anteriores, datos_nuevos)
    SELECT 'pacientes', 'UPDATE',
           COALESCE(SESSION_CONTEXT(N'usuario_app'), N'(sin app)'),
           SYSTEM_USER,
           i.id, GETDATE(),
           CONCAT('tel=', d.telefono, '; activo=', d.activo),
           CONCAT('tel=', i.telefono, '; activo=', i.activo)
    FROM INSERTED i
    JOIN DELETED d ON d.id = i.id;
END;
GO

Nota la diferencia con MySQL: no hay FOR EACH ROW (en SQL Server el trigger corre una vez por sentencia, pero con acceso a todas las filas via INSERTED/ DELETED). El SELECT sobre INSERTED/DELETED maneja múltiples filas de golpe.

Trigger de INSERT

CREATE TRIGGER trg_pacientes_ins
ON pacientes
AFTER INSERT
AS
BEGIN
    SET NOCOUNT ON;
    INSERT INTO auditoria (tabla_afectada, operacion,
                           usuario_app, usuario_bd, registro_id, fecha_hora)
    SELECT 'pacientes', 'INSERT',
           COALESCE(SESSION_CONTEXT(N'usuario_app'), N'(sin app)'),
           SYSTEM_USER,
           i.id, GETDATE()
    FROM INSERTED i;
END;
GO

La prueba de fuego

sp_set_session_context N'usuario_app', N'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;
-- rrosa | SA | UPDATE
-- tel=987111999; activo=1 | tel=987999888; activo=1 | ...   ← ¡AUTOMÁTICO!

Compara con el cap 19: allá el UPDATE manual no dejaba rastro. Ahora el motor firma SOLO, leyendo SESSION_CONTEXT('usuario_app') del canal del cap 27. Ni un cambio sin testigo.

INSTEAD OF: el guardián que cancela

CREATE TRIGGER trg_pacientes_valida_ins
ON pacientes
INSTEAD OF INSERT
AS
BEGIN
    SET NOCOUNT ON;
    IF EXISTS (SELECT 1 FROM INSERTED
               WHERE fecha_nacimiento > GETDATE())
    BEGIN
        THROW 50010, 'fecha_nacimiento no puede ser futura', 1;
    END;

    IF EXISTS (SELECT 1 FROM INSERTED
               WHERE sexo IS NOT NULL AND sexo NOT IN ('M', 'F'))
    BEGIN
        THROW 50011, 'sexo debe ser M o F', 1;
    END;

    -- si todo está bien, ejecutar la inserción original:
    INSERT INTO pacientes (documento, nombre, apellido, fecha_nacimiento, sexo, activo)
    SELECT documento, nombre, apellido, fecha_nacimiento, sexo, 1
    FROM INSERTED;
END;
GO

-- el guardián en acción:
INSERT INTO pacientes (documento, nombre, apellido, fecha_nacimiento, sexo)
VALUES ('41112222', 'Prueba', 'Futura', '2030-01-01', 'F');
-- Msg 50010, Level 16, State 1
-- fecha_nacimiento no puede ser futura

INSTEAD OF reemplaza la operación original: si la validación falla, THROW cancela y la fila jamás se inserta. Si pasa, el INSERT interno ejecuta el trabajo. Es la capa de validación más fuerte que existe.

Reglas de convivencia

ReglaDetalle
Múltiples triggers AFTERpermitidos; se ejecutan en orden de creación
INSTEAD OFsolo uno por tabla por evento; reemplaza la operación
DELETED/INSERTEDtablas completas, pueden contener múltiples filas
Drop seguroDROP TRIGGER IF EXISTS nombre;
SESSION_CONTEXT en triggersfunciona (el trigger corre en la sesión del invocador)
El círculo se cierra: usuarios_app (cap 6) → canal de sesión sp_set_session_context (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); INSTEAD OF para validar y reemplazar.
  • INSERT: solo INSERTED · DELETE: solo DELETED · UPDATE: ambas.
  • Sin FOR EACH ROW — siempre es por sentencia con acceso a todas las filas.
  • THROW en INSTEAD OF cancela la operación original.
  • Auditoría dual automática: SESSION_CONTEXT + SYSTEM_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 usuarios y roles a nivel de servidor y base de datos.
  • Conceder permisos quirúrgicos (GRANT sobre vistas y procedures).
  • Auditar permisos con sys.fn_my_permissions.
  • Cerrar el modelo de seguridad completo de la clínica.

Dos niveles de identidad: LOGIN y USER

-- NIVEL SERVIDOR: quién puede CONECTAR al servidor
USE master;
CREATE LOGIN clinica_app WITH PASSWORD = 'Clinica.2026';
GO

-- NIVEL BASE DE DATOS: quién puede USAR esta base
USE clinica;
CREATE USER clinica_user FOR LOGIN clinica_app;
GO

SQL Server tiene DOS niveles de identidad (verificado en la doc): el LOGIN (vive en master, controla quién entra al servidor) y el USER (vive en la base de datos, controla qué puede hacer ahí). MySQL solo tiene uno: 'user'@'host'. Esta dualidad es la primera diferencia que sorprende.

Roles: permisos con nombre

-- crear roles a nivel de base de datos:
CREATE ROLE rol_recepcion;
CREATE ROLE rol_medico;
CREATE ROLE rol_admin;

-- recepción: agenda y consulta, no toca diagnósticos:
GRANT SELECT ON pacientes_deudores TO rol_recepcion;
GRANT SELECT ON citas_hoy          TO rol_recepcion;
GRANT EXECUTE ON agendar_cita      TO rol_recepcion;

-- médico: consulta todo, atiende y receta:
GRANT SELECT ON SCHEMA::dbo TO rol_medico;
GRANT EXECUTE ON agendar_cita TO rol_medico;

-- asignar roles al usuario de la app:
ALTER ROLE rol_recepcion ADD MEMBER clinica_user;

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 SELECT ON SCHEMA::dbo FROM clinica_user;

-- solo lo que la app usa:
GRANT SELECT ON citas_hoy               TO clinica_user;
GRANT SELECT ON pacientes_deudores      TO clinica_user;
GRANT EXECUTE ON agendar_cita           TO clinica_user;
GRANT EXECUTE ON registrar_cambio       TO clinica_user;

-- verificación:
SELECT * FROM fn_my_permissions(NULL, 'DATABASE');

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
SA (login)todo (solo administración, jamás en la app)
clinica_user (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 dbotocar bases del sistema (master)

Verificación de permisos

-- ¿qué permisos tengo yo en esta base?
SELECT * FROM fn_my_permissions(NULL, 'DATABASE');

-- ¿qué permisos tiene clinica_user?
EXECUTE AS USER = 'clinica_user';
SELECT * FROM fn_my_permissions(NULL, 'DATABASE');
REVERT;

-- quitar un rol:
ALTER ROLE rol_recepcion DROP MEMBER clinica_user;

-- 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 motorLOGIN + USER + roles4, hoy
Qué puede tocar esa cuentaGRANT quirúrgico: vistas + EXECUTEhoy
Quién hizo cada cambioSESSION_CONTEXT + triggers + SYSTEM_USER27, 28
Qué datos son válidosconstraints + CHECK8
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

  • Dos niveles: LOGIN (servidor) y USER (base de datos).
  • Permisos por ROL (puesto), no por persona.
  • La cuenta de la app: sin tablas directas — solo vistas + EXECUTE.
  • fn_my_permissions: la radiografía de cualquier cuenta.
  • 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 sp_executesql: 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
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: sp_executesql

-- la consulta se envía en dos piezas SEPARADAS:
DECLARE @sql NVARCHAR(500) = N'SELECT id, usuario, rol
    FROM usuarios_app WHERE usuario = @u';
DECLARE @u NVARCHAR(128) = N'admin'' --';  -- intento de ataque como DATO
EXEC sp_executesql @sql, N'@u NVARCHAR(128)', @u;
-- resultado: 0 filas. Buscó un usuario llamado literalmente admin' --
-- (que no existe). El ataque se convirtió en un nombre de usuario raro.

sp_executesql es el equivalente de SQL Server a PREPARE/EXECUTE de MySQL. 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, CHECK (cap 8) + validar en la applista
La defensa realconsultas preparadas SIEMPRE (sp_executesql)cap 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 @param 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.
  • sp_executesql: 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 usando el driver sqlsrv.

  • Conectar con PDO y el driver sqlsrv.
  • Autenticar contra usuarios_app con password_verify.
  • Declarar sp_set_session_context 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(
    'sqlsrv:Server=localhost;Database=clinica',
    'clinica_app',              // la LOGIN (cap 29, nivel servidor)
    '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: apagando la emulación, los preparados son reales del servidor y la separación código/datos del cap 30 es física, no simulada.

Nota: el DSN es sqlsrv:Server=...;Database=... — no existe driver mssql: ni sqlserver: en PHP. El driver oficial es sqlsrv (Microsoft ODBC Driver 18 for SQL Server).

El canal de sesión, desde PHP

<?php
// tras el login, CADA conexión declara quién opera:
$stmt = $pdo->prepare('sp_set_session_context ?, ?');
$stmt->execute(['usuario_app', $_SESSION['usuario_app']]);

// verificar:
$row = $pdo->query("SELECT SESSION_CONTEXT(N'usuario_app')")->fetch();
// $row[0] = 'rrosa'

sp_set_session_context reemplaza a SET @variable de MySQL. El valor vive en la sesión (persiste entre lotes y procedures) y los triggers del cap 28 lo leen con SESSION_CONTEXT(N'usuario_app').

El login contra usuarios_app

<?php
$stmt = $pdo->prepare(
    'SELECT id, usuario, nombre, rol, hash_clave
     FROM usuarios_app WHERE usuario = ? AND activo = 1'
);
$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('sp_set_session_context ?, ?')
        ->execute(['usuario_app', $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('EXEC agendar_cita ?, ?, ?, ?');
    $stmt->execute([1, 2, '2026-09-10 09:00:00', 'Control mensual']);
    echo "Cita agendada\n";
} catch (PDOException $e) {
    // el THROW 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:
$rows = $pdo->query(
    'SELECT TOP 1 usuario_app, usuario_bd, operacion
     FROM auditoria ORDER BY id DESC')->fetchAll();
echo $rows[0]['usuario_app'], ' / ', $rows[0]['usuario_bd'], ' / ',
     $rows[0]['operacion'], "\n";
// rrosa / SA / INSERT

Transacciones y procedures con OUTPUT

<?php
// la transacción del cap 23, versión PDO:
$pdo->beginTransaction();          // BEGIN TRAN
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 OUTPUT: capturar con OUTPUT y SELECT posterior:
$pdo->prepare('DECLARE @t INT, @a INT;
    EXEC contar_citas_paciente ?, @t OUTPUT, @a OUTPUT;
    SELECT @t AS total, @a AS atendidas')
    ->execute([1]);
$result = $pdo->query('SELECT @@ROWCOUNT AS dummy')->fetch();

Verificado contra php.net: commit()/rollBack() sin transacción activa lanzan PDOException (por eso el try/catch envuelve TODO). El driver sqlsrv soporta OUTPUT parameters pero el patrón más limpio es DECLARE local + EXEC + SELECT del resultado.

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 sqlsrv:Server=...;Database=... (driver sqlsrv, no mssql).
  • EMULATE_PREPARES=false: prepares REALES — apágalo siempre.
  • Login: prepare + password_verify, nunca clave en el SQL.
  • sp_set_session_context al abrir conexión = triggers firman solos.
  • EXEC con ? placeholders, no CALL como en MySQL.

32 · Índices y planes de ejecución

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 el plan de ejecución 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 el plan de ejecución y cazar los Table Scans.

Qué es un índice

Un índice es una estructura ordenada (B-tree) que apunta a las filas: como el índice de un libro, te lleva directo a "Quispe" sin leer página por página. El costo: ocupa espacio y CADA INSERT/UPDATE/DELETE debe actualizarlo. No se indexa por indexar — se indexa lo que se CONSULTA.

Los que ya tienes gratis

FuenteÍndice creado
PRIMARY KEYíndice clustered (el orden físico de la tabla)
UNIQUEíndice único (non-clustered por defecto)
FOREIGN KEYSQL Server NO lo crea automáticamente (a diferencia de InnoDB)

Diferencia clave con MySQL: las FK en SQL Server no crean índices automáticamente. Si tus JOINs usan foreign_key_id, crea el índice manualmente — o el motor hará Table Scan en cada consulta.

Índice compuesto y el prefijo izquierdo

-- crear un índice compuesto sobre la doble agenda:
CREATE INDEX idx_citas_medico_fecha
ON citas (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.

Planes de ejecución: preguntarle al motor su plan

-- ver el plan de ejecución en texto:
SET SHOWPLAN_TEXT ON;
GO
SELECT * FROM citas WHERE medico_id = 2;
GO
SET SHOWPLAN_TEXT ON;
-- |--Index Seek(OBJECT(citas.idx_citas_medico_fecha), SEEK(citas.medico_id = 2))
-- El motor USA el índice: Index Seek (salto directo)
-- alternativa: el plan en XML (para SSMS gráfico):
SET SHOWPLAN_XML ON;
GO
SELECT * FROM citas WHERE medico_id = 2;
GO
SET SHOWPLAN_XML OFF;

-- alternativa: estadísticas reales de ejecución:
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
SELECT * FROM citas WHERE medico_id = 2;
SET STATISTICS IO OFF;
SET STATISTICS TIME OFF;
-- Table 'citas'. Scan count 1, logical reads 2, physical reads 0

Los que buscas: Index Seek (el ideal — saltó directo al índice), Index Scan (recorrió el índice entero, puede estar bien), y Table Scan / Clustered Index Scan (el indice de alarma: recorrió TODA la tabla). Si ves un Table Scan sobre millones de filas, necesitas un índice.

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 el plan de ejecución antes y después: el índice inútil es puro costo.

Puntos clave

  • Índice = salto directo; costo = espacio + escrituras más lentas.
  • PK y UNIQUE ya te regalan índices; FK NO en SQL Server.
  • Compuesto: el orden importa — prefijo izquierdo o nada.
  • Index Seek = ideal · Table Scan / Clustered Index Scan = alarma.
  • STATISTICS IO/TIME: datos reales de rendimiento.

33 · Backup y restore

Avanzado ~14 min

La regla del oficio: un administrador se mide por sus respaldos, no por sus instalaciones. Hoy volcamos la clínica completa — procedures, triggers, datos — y la restauramos, con las herramientas propias de SQL Server.

  • Volcar la base completa con BACKUP DATABASE.
  • Entender FULL, DIFF y LOG backups.
  • Restaurar en una base limpia y verificar.
  • Diseñar la estrategia de respaldos de la clínica.

El volcado completo de la clínica

# backup completo a archivo .bak (sqlcmd o SSMS):
sqlcmd -S localhost -E -Q "
BACKUP DATABASE clinica
TO DISK = N'C:\backups\clinica_completa.bak'
WITH INIT, COMPRESSION, NAME = N'Backup completo clinica';"

BACKUP DATABASE produce un archivo binario (.bak), no un script SQL como mariadb-dump de MySQL. Esto tiene ventajas: procedures, triggers, índices, estadísticas y permisos se incluyen automáticamente — no necesitas flags especiales para procedures ni triggers.

OpciónQué hace
WITH INITsobrescribe el archivo anterior (sin INIT, crea un backup incremental dentro del mismo .bak)
WITH COMPRESSIONcomprime el backup (reduce tamaño 40-70%, usa CPU extra durante el backup)
WITH NAMEetiqueta descriptiva visible en la lista de restores (SSMS)

Tipos de backup: FULL, DIFF y LOG

# backup completo (una vez por semana típicamente):
BACKUP DATABASE clinica TO DISK = N'C:\backups\clinica_full.bak'
WITH INIT, COMPRESSION;

# backup diferencial (solo cambios desde el último FULL):
BACKUP DATABASE clinica TO DISK = N'C:\backups\clinica_diff.bak'
WITH DIFFERENTIAL, COMPRESSION;

# backup de log (transaccional, cada 15-30 min en producción):
BACKUP LOG clinica TO DISK = N'C:\backups\clinica_log.trn'
WITH COMPRESSION;

Diferencia clave con MySQL: SQL Server separa los backups de transacciones (log) de los de datos. El log permite point-in-time recovery — restaurar hasta el momento exacto del desastre, no solo hasta el último backup completo.

Restaurar

# restaurar sobre la misma base (producción post-desastre):
RESTORE DATABASE clinica FROM DISK = N'C:\backups\clinica_full.bak'
WITH REPLACE;

# restaurar como base nueva (test/verificación):
RESTORE DATABASE clinica_test FROM DISK = N'C:\backups\clinica_full.bak'
WITH MOVE N'clinica' TO N'C:\Data\clinica_test.mdf',
     MOVE N'clinica_log' TO N'C:\Logs\clinica_test_log.ldf';

# verificación OBLIGATORIA (sin esto no es un backup):
sqlcmd -S localhost -E -d clinica_test -Q "
SELECT COUNT(*) AS total_pacientes FROM pacientes;
EXEC agendar_cita 1, 2, '2027-01-05 09:00:00', 'Prueba restore';"

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.

WITH MOVE es necesario cuando restauras en otro servidor o ruta porque los archivos .mdf/.ldf pueden tener rutas hardcodeadas del servidor original.

La estrategia de la clínica

CapaFrecuenciaHerramienta
FULL backupsemanal (domingo madrugada)BACKUP DATABASE WITH INIT, COMPRESSION
DIF backupdiario (noche)BACKUP DATABASE WITH DIFFERENTIAL
LOG backupcada 15 minBACKUP LOG WITH COMPRESSION
Copia fuera del servidordiariael .bak se COPIA a otro equipo/almacenamiento
Prueba de restauraciónmensualRESTORE a base test + verificar
Los dos pecados capitales: (1) backup sin COMPRESSION en producción: desperdicias disco y tiempo de red; (2) el .bak que vive en el mismo disco que la base: si el disco muere, muere con él todo. Copia FUERA, siempre.

Puntos clave

  • BACKUP DATABASE produce un .bak binario (no script SQL).
  • Procedures, triggers e índices se incluyen automáticamente.
  • FULL + DIFF + LOG: la estrategia completa de respaldos.
  • RESTORE con MOVE cuando cambias de ruta/servidor.
  • 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: monitoreo en vivo, mantenimiento periódico y el checklist final.

  • Monitorear conexiones activas con sp_who2 y DMVs.
  • Mantenimiento: DBCC CHECKDB, UPDATE STATISTICS.
  • Configurar el modo de recuperación adecuado.
  • Recorrer el checklist final de producción.

Monitoreo en vivo

-- ¿quién está conectado y qué está haciendo AHORA?
EXEC sp_who2;

-- tabla completa del SPID 5 (sustituye por el SPID que veas):
DBCC INPUTBUFFER(5);

-- consultas lentas (desde la DMV):
SELECT TOP 10
    qs.total_elapsed_time / qs.execution_count AS media_ms,
    qs.execution_count AS ejecuciones,
    SUBSTRING(qt.text, (qs.statement_start_offset/2)+1,
        ((CASE qs.statement_end_offset
          WHEN -1 THEN DATALENGTH(qt.text)
          ELSE qs.statement_end_offset
        END - qs.statement_start_offset)/2)+1) AS consulta
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) qt
ORDER BY qs.total_elapsed_time / qs.execution_count DESC;

sp_who2 es el equivalente de SHOW PROCESSLIST en MySQL: muestra cada conexión, su estado, SPID y cuánto lleva. Las DMVs (Dynamic Management Views) van más lejos: sys.dm_exec_query_stats muestra las consultas más lentas acumuladas desde el último reinicio del servicio.

Mantenimiento periódico

ComandoQué haceCuándo
DBCC CHECKDB('clinica')verifica integridad física y lógica de todas las tablas, índices y relacionesmensual
UPDATE STATISTICS clinicaactualiza estadísticas para que el optimizador elija buenos planestras cargas masivas
DBCC DROPCLEANBUFFERSlimpia la caché de datos (pruebas de rendimiento, NO en producción activa)solo pruebas
SET STATISTICS IO ONmuestra lecturas lógicas por tabla (diagnóstico puntual)al optimizar

DBCC CHECKDB es el equivalente de OPTIMIZE TABLE de MySQL pero mucho más completo: verifica integridad estructural, no solo fragmentación. Si reporta errores, la base necesita reparación urgente.

Modo de recuperación

-- SIMPLE: backup de log no acumula (desarrollo/pruebas):
ALTER DATABASE clinica SET RECOVERY SIMPLE;

-- FULL: backup de log completo, point-in-time recovery (producción):
ALTER DATABASE clinica SET RECOVERY FULL;

En SIMPLE, el log de transacciones se trunca automáticamente después de cada checkpoint — no puedes hacer backups de log ni point-in-time recovery. En FULL, el log crece hasta que hagas BACKUP LOG. Para producción: FULL.

El Checklist de producción

  • [ ] Recuperación FULL activa — punto de restauración configurado.
  • [ ] Cuenta de la app con mínimo privilegio (cap 29) — SA jamás en la app.
  • [ ] Backup FULL+DIFF+LOG programado y copiado FUERA del servidor.
  • [ ] Restauración probada al menos una vez (y repetida cada mes).
  • [ ] DBCC CHECKDB ejecutado sin errores en el último mes.
  • [ ] Triggers de auditoría activos y la tabla auditoria creciendo (cap 28).
  • [ ] Estadísticas actualizadas (UPDATE STATISTICS) tras cargas pesadas.
  • [ ] sp_who2 revisado en horas pico, sin consultas zombis.
  • [ ] El script bd_sqlserver_clinica.sql versionado en el repositorio.
  • [ ] Login SA protegido — solo para emergencias administrativas.

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

  • sp_who2: el diagnóstico rápido ante lentitud.
  • DMVs: sys.dm_exec_query_stats para consultas lentas acumuladas.
  • DBCC CHECKDB: integridad mensual (no es solo espacio).
  • RECOVERY FULL: point-in-time recovery, el estándar de producción.
  • Checklist de 10 puntos: producción sin sorpresas silenciosas.

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 SQL Server 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 (SQL Server resaltado).
  • Saber el camino: SQLite como el siguiente.

El mapa final

ParteLogro
I · El motor (1-5)por qué una BD, los 4 motores comparados, instalación Win/Ubuntu, sqlcmd y SSMS
II · El modelo (6-10)8 tablas con propósito, tipos correctos, constraints como leyes, datos deterministas, BIT y CHECK
III · Consultas (11-18)del primer SELECT a CTEs y los 5 reportes reales de la clínica
IV · Modificar (19-23)UPDATE con UPDATE FROM, MERGE, transacciones, temporales, la atención atómica
V · Objetos (24-29)vistas, procedures, funciones, sp_set_session_context, triggers de auditoría dual, roles
VI · Producción (30-34)inyección SQL derrotada, PDO con sqlsrv, índices y planes de ejecución, backup, checklist

EXAMEN DE GRADUACIÓN

  • [ ] La base clinica creada desde cero: 8 tablas, constraints, BIT, 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 SESSION_CONTEXT + SYSTEM_USER.
  • [ ] La cuenta de la app sin acceso a tablas — solo vistas y EXECUTE.
  • [ ] Un script PHP con PDO sqlsrv: login, sp_set_session_context, transacción completa.
  • [ ] Sin Table Scan en las consultas calientes (Index Seek/Scan).
  • [ ] Backup FULL restaurado y verificado con conteos + procedure.

ANEXO · Equivalencias entre motores (SQL Server resaltado)

ConceptoMySQLPostgreSQLSQL ServerSQLite
EnteroTINYINT / INT / BIGINTSMALLINT / INT / BIGINTTINYINT / INT / BIGINTINTEGER
BooleanoTINYINT(1) / BOOLEANBOOLEAN realBIT (0/1)INTEGER 0/1
Dinero exactoDECIMAL(p,s) max 65/38NUMERIC / DECIMALDECIMAL / MONEYNUMERIC
Texto cortoVARCHAR(n)VARCHAR(n)VARCHAR(n) / NVARCHAR(n)TEXT (afinidad)
Texto largoTEXTTEXTVARCHAR(MAX) / NVARCHAR(MAX)TEXT
FechaDATEDATEDATETEXT ISO-8601
Fecha+horaDATETIME / TIMESTAMPTIMESTAMP / TIMESTAMPTZDATETIME2 (precisión 100ns)TEXT 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 UPDATEMERGE INTOON CONFLICT DO UPDATE
Tablas temporalesCREATE TEMPORARY TABLECREATE TEMP TABLE#temp / SELECT INTOCREATE TEMP TABLE
Procedures / triggerssi (DELIMITER cliente)si (plpgsql, $$)si (T-SQL, GO)NO
Variables de sesion@varset_config / current_settingsp_set_session_contextNO (capa app)

Guarda este anexo: es la chuleta oficial de la categoria. Cuando abras el manual de SQLite, la misma tabla volvera con su columna resaltada y las verificaciones de esa doc.

Felicidades: administras SQL Server de verdad — modelaste, consultaste, modificaste con responsabilidad y dejaste una base que valida, firma y se defiende sola. Siguiente estacion: bd_sqlite.html, el mismo modelo de clinica con el motor mas portable del mundo — y el mas reducido. Nos vemos ahi.

Puntos clave finales

  • 6 partes, un sistema: modelo, consultas, objetos, produccion.
  • Examen: 9 entregables verificables en tu propia base clinica.
  • El anexo de tipos: tu chuleta para toda la categoria.
  • El 90% del SQL que aprendiste es comun a los 4 motores.
  • Serie Bases de datos: MySQL, PostgreSQL, SQL Server, SQLite.