Saltar al contenido

030 — Portabilidad: qué exige la norma y qué añade cada motor

🗂️ parte 🎚️ nivel ⏱️ duración 📗 clase

Programa · Parte 05 · ← Anterior · Siguiente →

Parte 05 — Motores relacionales y dialectos · Intermedio · 3 horas estimadas · motores postgresql, mysql, sqlite, sql-server, oracle-database · laboratorio labs/03-transactions · 4 fuentes.

Conceptos centrales: norma frente a producto · matriz de portabilidad · extensión propietaria

En este caso se comparan 7 motores: 6 lo resuelven (4 con el resultado comprobado por máquina) y 1 no, con el motivo escrito.

De qué trata esta clase

Qué exige la norma ISO/IEC 9075 y qué añade cada producto por su cuenta. El resultado de la clase no es una opinión sobre portabilidad sino un artefacto: una matriz que registra, construcción por construcción, si es de norma y cómo la escribe cada motor del proyecto.

flowchart LR
    C["🗄️ Clase 030"]
    C --> K1["norma frente a producto"]
    C --> K2["matriz de portabilidad"]
    C --> K3["extensión propietaria"]
    classDef raiz fill:#0b3d2e,stroke:#3fb950,color:#fff
    class C raiz

Antes de empezar

Esta clase supone que ya trabajaste lo siguiente. Si algo de la última columna no te suena, vuelve a esa clase antes de seguir: aquí se usa sin volver a explicarlo.

# Clase previa Lo que se da por sabido
024 DDL: el esquema como contrato ejecutable tipo de dato · restricción · valor por defecto · DDL transaccional
029 Nulos y lógica de tres valores UNKNOWN · IS DISTINCT FROM · NOT IN con nulos · agregados y nulos

Vocabulario de la clase

Los términos que siguen se usan más adelante con este significado exacto. La definición completa, con sus términos relacionados, está en el glosario del programa.

Término Qué significa Procedencia
norma frente a producto La distinción entre lo que exige ISO/IEC 9075 y lo que cada motor añade por su cuenta. Ningún producto implementa la norma entera y todos la extienden; saber en qué lado está cada línea de tu código es lo que decide si una migración de motor cuesta un día o un trimestre. se introduce aquí
matriz de portabilidad Tabla que registra, para cada construcción usada, si es de norma y cómo la escribe cada motor del proyecto. Convierte la portabilidad en un artefacto revisable en lugar de en una intención declarada en la primera reunión. se introduce aquí
extensión propietaria Sintaxis o función que solo existe en un motor: LIMIT frente a FETCH FIRST, ON CONFLICT frente a MERGE, tipos de arreglo, RETURNING. Usarlas es legítimo y a menudo correcto; lo que no lo es, es usarlas sin saber que se está atando el proyecto a ese producto. se introduce aquí

Propósito

Escribir SQL que sobreviva a un cambio de motor, y saber exactamente dónde se paga por no hacerlo. La portabilidad total no existe; la portabilidad gestionada sí.

Resultados de aprendizaje

Al terminar podrás:

  1. Explicar por qué ningún motor implementa la norma completa.
  2. Construir una matriz de portabilidad para tu propio código.
  3. Aislar las divergencias en una capa en vez de esparcirlas.
  4. Decidir cuándo usar una extensión propietaria a sabiendas.
  5. Distinguir divergencia sintáctica de divergencia semántica, que es mucho peor.

Fundamentos

Qué es la norma y qué no

ISO/IEC 9075 define SQL en varias partes y con niveles de conformidad. La realidad es que ningún producto implementa la norma completa y todos añaden extensiones. La norma no es un contrato de compatibilidad: es un vocabulario común.

Lo estable en la práctica —el «núcleo portable»— es aproximadamente:

CREATE TABLE con tipos básicos · PRIMARY KEY, FOREIGN KEY, UNIQUE, CHECK, NOT NULL · SELECT/FROM/WHERE/GROUP BY/HAVING/ORDER BY · INNER, LEFT, RIGHT, FULL JOIN · UNION, INTERSECT, EXCEPT · subconsultas y EXISTS · CTE y WITH RECURSIVE · funciones de ventana · INSERT, UPDATE, DELETE · BEGIN/COMMIT/ROLLBACK.

Lo que no es portable, aunque lo parezca: tipos de fecha y hora, funciones de cadena, autoincremento, LIMIT, UPSERT, tipos JSON, expresiones regulares, TOP/FETCH, y —la peor— la colación.

Sintáctica frente a semántica

Esta distinción es la que decide cuánto duele una migración.

Divergencia Tipo Consecuencia
LIMIT frente a FETCH FIRST frente a TOP Sintáctica Error de sintaxis
AUTO_INCREMENT / SERIAL / IDENTITY Sintáctica Error en DDL
Concatenación \|\| frente a CONCAT() Sintáctica Error, salvo en MySQL donde \|\| es OR
Comparación de texto sensible o no a mayúsculas Semántica Resultados distintos, sin error
División entera frente a decimal Semántica Números distintos
Cadena vacía tratada como nulo (Oracle) Semántica Filas que aparecen o desaparecen
Redondeo a la mitad Semántica Descuadres contables

El caso de || en MySQL merece atención: con el modo PIPES_AS_CONCAT desactivado, 'a' || 'b' se evalúa como 'a' OR 'b'0. No es un error; es un resultado equivocado.

Estrategias

flowchart TD
    S["SQL de la aplicación"] --> D{"¿La construcción<br/>está en el núcleo portable?"}
    D -- "Sí" --> OK["Escribirla directamente"]
    D -- "No" --> N{"¿Hay equivalente<br/>portable razonable?"}
    N -- "Sí" --> E["Usar el equivalente<br/>aunque sea más largo"]
    N -- "No" --> V{"¿Aporta valor real<br/>(rendimiento, corrección)?"}
    V -- "No" --> E
    V -- "Sí" --> A["Extensión propietaria<br/>aislada tras una función<br/>+ anotada en la matriz"]

La regla operativa: usar extensiones está permitido; esparcirlas por todo el código, no. Una extensión aislada en un único punto es una decisión reversible; la misma extensión en 200 consultas es una migración de trimestre.

Ejemplo trabajado

Consulta «los 10 estudiantes con mejor promedio», en cinco dialectos.

-- Núcleo portable (norma SQL:2008, soportado hoy por PostgreSQL, SQL Server, Oracle, MariaDB 10.6+)
SELECT s.id, s.nombre, AVG(e.nota) AS promedio
FROM students s JOIN enrollments e ON e.student_id = s.id
GROUP BY s.id, s.nombre
ORDER BY promedio DESC, s.id
FETCH FIRST 10 ROWS ONLY;
Motor Cláusula de límite ¿Acepta FETCH FIRST?
PostgreSQL LIMIT 10
MySQL / MariaDB LIMIT 10 Solo MariaDB reciente
SQLite LIMIT 10 No
SQL Server TOP 10 u OFFSET ... FETCH Sí, con ORDER BY
Oracle FETCH FIRST 10 ROWS ONLY (12c+)

Como SQLite y MySQL no aceptan FETCH FIRST, el mínimo común denominador real hoy es LIMIT, que no está en la norma. Conclusión honesta: la portabilidad se negocia contra el conjunto de motores que uno realmente soporta, no contra la norma.

Ahora la divergencia semántica, mucho más peligrosa:

SELECT * FROM students WHERE email = 'ANA@EJEMPLO.CL';
Motor Colación por defecto ¿Encuentra ana@ejemplo.cl?
MySQL 8 utf8mb4_0900_ai_ci
PostgreSQL Del sistema, sensible No
SQLite BINARY No
SQL Server Suele ser _CI_AS

Una aplicación desarrollada sobre MySQL y desplegada sobre PostgreSQL deja de encontrar usuarios al iniciar sesión, sin ningún error en los registros. La defensa no es configurar la colación: es normalizar al escribir.

CREATE TABLE students (
  id     INTEGER PRIMARY KEY,
  email  TEXT NOT NULL CHECK (email = lower(email)),
  UNIQUE (email)
);

El CHECK convierte una suposición implícita en una regla comprobada por el motor, en cualquier motor.

Aritmética:

SELECT 7 / 2;
Motor Resultado
PostgreSQL, Oracle, SQL Server 3 (división entera)
MySQL 3.5
SQLite 3

Escribir 7.0 / 2 o CAST(7 AS NUMERIC) / 2 elimina la ambigüedad en todos.

Inserción idempotente (UPSERT), ninguna forma es portable:

-- PostgreSQL, SQLite
INSERT INTO t (id, v) VALUES (1,'x') ON CONFLICT (id) DO UPDATE SET v = excluded.v;
-- MySQL
INSERT INTO t (id, v) VALUES (1,'x') ON DUPLICATE KEY UPDATE v = VALUES(v);
-- Norma (SQL Server, Oracle): MERGE

Esta es la construcción que más justifica una capa de aislamiento: se usa constantemente y se escribe distinto en todos.

Comparación

Aspecto Escribir portable Usar extensiones libremente
Velocidad de desarrollo Menor Mayor
Costo de migrar Bajo Alto o prohibitivo
Rendimiento A veces peor Mejor si la extensión existe por algo
Riesgo semántico Bajo si se normaliza Alto
Recomendación Núcleo portable por defecto Extensiones aisladas y documentadas

Errores frecuentes

  1. Suponer que «SQL es SQL». El núcleo común es más pequeño de lo que parece.
  2. Confiar en la colación por defecto. Es la divergencia semántica más frecuente y la más silenciosa.
  3. Depender de la división entera o del redondeo. Cambia entre motores.
  4. Usar || en MySQL sin activar PIPES_AS_CONCAT. Devuelve 0.
  5. Probar solo en el motor de desarrollo. Si producción usa otro, la matriz no se ha comprobado, se ha imaginado.
  6. Portabilidad como dogma. Renunciar a jsonb o a índices parciales «por si acaso» cuesta más de lo que ahorra.

De la clase a la operación

La migración de motor rara vez se decide por gusto: llega por licencias, por costo de nube o por una adquisición. El código que la sobrevive es el que aisló las diferencias cuando no había ninguna urgencia.

Reto de transferencia

  1. Toma 10 consultas reales de tu proyecto y clasifícalas: núcleo portable, divergencia sintáctica o semántica.
  2. Ejecuta las 10 en dos motores del docker-compose y captura las diferencias.
  3. Escribe la matriz de portabilidad resultante, con la construcción y su equivalente en cada motor.
  4. Aísla la construcción menos portable tras una única función y demuestra que el resto del código no cambia.

Preguntas de evaluación

  1. Da un ejemplo propio de divergencia semántica y explica por qué es peor que una sintáctica.
  2. ¿Por qué LIMIT es más portable en la práctica que FETCH FIRST, pese a no estar en la norma?
  3. Escribe el UPSERT de tu dominio en tres dialectos.
  4. Justifica un caso donde usarías una extensión propietaria a sabiendas, y cómo la documentarías.

🌐 El mismo problema en cada motor

Caso: Una etiqueta concatenada y las dos primeras filas, escrito de forma portable

Pedir «los dos mejores de DB-101 con su nombre y su curso en una sola cadena» parece trivial hasta que hay que ejecutarlo en cinco motores. Dos detalles minúsculos rompen el código al migrar: cómo se concatenan dos cadenas y cómo se limita el número de filas.

La norma ISO/IEC 9075 define || para lo primero y FETCH FIRST n ROWS ONLY para lo segundo. Casi ningún motor implementa exactamente eso: MySQL interpreta || como el OR lógico salvo que se cambie el modo, SQL Server concatena con + y limita con TOP, y SQLite y PostgreSQL usan LIMIT, que no está en la norma pero es el estándar de facto.

La salida es la misma en todos. Lo que cambia —y esta clase obliga a escribir— es cuánto hay que tocar para conseguirla.

Salida esperada, idéntica en todos los motores que lo resuelven:

etiqueta
Ada - DB-101
Grace - DB-101

El contrato vive en motores.yaml y lo comprueba python scripts/verificar_equivalencia.py --clase 030: 4 de las 6 implementaciones se ejecutan de verdad y su resultado se compara con esa tabla; el resto se declara como material revisado, no ejecutado.

Motor ¿Resuelve el caso? Nivel de prueba Código Fuente
SQLite núcleo código doc oficial
DuckDB núcleo código doc oficial
PostgreSQL servicio código doc oficial
MySQL servicio código doc oficial
Microsoft SQL Server declarado código doc oficial
Oracle Database declarado código doc oficial
MariaDB no doc oficial

Los que resuelven el caso

SQLite · implementaciones/sqlite/consulta.sql

verificado — se ejecuta en CI sin servicios

-- motor: sqlite
-- doc: https://sqlite.org/lang_expr.html
-- nota: || es el operador de la norma. LIMIT no lo es, pero lo entienden
--       SQLite, PostgreSQL, MySQL, MariaDB y DuckDB: es el estandar de facto.

-- === preparacion ===
CREATE TABLE notas (
    estudiante TEXT NOT NULL,
    curso      TEXT NOT NULL,
    nota       INTEGER NOT NULL,
    PRIMARY KEY (estudiante, curso)
);
INSERT INTO notas (estudiante, curso, nota) VALUES
    ('Ada',   'DB-101', 90),
    ('Grace', 'DB-101', 72),
    ('Linus', 'DB-101', 58),
    ('Ada',   'SE-201', 66);

-- === consulta ===
SELECT estudiante || ' - ' || curso AS etiqueta
FROM notas
WHERE curso = 'DB-101'
ORDER BY nota DESC
LIMIT 2;

DuckDB · implementaciones/duckdb/consulta.sql

verificado — se ejecuta en CI sin servicios

-- motor: duckdb
-- doc: https://duckdb.org/docs/stable/sql/dialect/postgresql_compatibility.html

-- === preparacion ===
CREATE TABLE notas (
    estudiante VARCHAR NOT NULL,
    curso      VARCHAR NOT NULL,
    nota       INTEGER NOT NULL,
    PRIMARY KEY (estudiante, curso)
);
INSERT INTO notas (estudiante, curso, nota) VALUES
    ('Ada',   'DB-101', 90),
    ('Grace', 'DB-101', 72),
    ('Linus', 'DB-101', 58),
    ('Ada',   'SE-201', 66);

-- === consulta ===
SELECT estudiante || ' - ' || curso AS etiqueta
FROM notas
WHERE curso = 'DB-101'
ORDER BY nota DESC
LIMIT 2;

PostgreSQL · implementaciones/postgresql/consulta.sql

verificado — se ejecuta contra el motor real levantado con docker compose

-- motor: postgresql
-- doc: https://www.postgresql.org/docs/current/functions-string.html
-- nota: PostgreSQL tambien acepta la forma de la norma,
--         FETCH FIRST 2 ROWS ONLY
--       que es la que hay que usar si el destino puede ser Oracle o SQL Server.

-- === preparacion ===
DROP TABLE IF EXISTS notas;

CREATE TABLE notas (
    estudiante text NOT NULL,
    curso      text NOT NULL,
    nota       integer NOT NULL,
    PRIMARY KEY (estudiante, curso)
);
INSERT INTO notas (estudiante, curso, nota) VALUES
    ('Ada',   'DB-101', 90),
    ('Grace', 'DB-101', 72),
    ('Linus', 'DB-101', 58),
    ('Ada',   'SE-201', 66);

-- === consulta ===
SELECT estudiante || ' - ' || curso AS etiqueta
FROM notas
WHERE curso = 'DB-101'
ORDER BY nota DESC
LIMIT 2;

MySQL · implementaciones/mysql/consulta.sql

verificado — se ejecuta contra el motor real levantado con docker compose

-- motor: mysql
-- doc: https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html
-- nota: aqui esta la trampa de la clase. Por omision,
--         SELECT estudiante || ' - ' || curso
--       NO concatena: || es el OR logico y la consulta devuelve 0 en cada fila,
--       sin error. Con SET sql_mode = 'PIPES_AS_CONCAT' pasaria a concatenar.
--       CONCAT() evita la ambiguedad y ademas es portable a SQL Server y Oracle.

-- === preparacion ===
DROP TABLE IF EXISTS notas;

CREATE TABLE notas (
    estudiante VARCHAR(50) NOT NULL,
    curso      VARCHAR(50) NOT NULL,
    nota       INT NOT NULL,
    PRIMARY KEY (estudiante, curso)
);
INSERT INTO notas (estudiante, curso, nota) VALUES
    ('Ada',   'DB-101', 90),
    ('Grace', 'DB-101', 72),
    ('Linus', 'DB-101', 58),
    ('Ada',   'SE-201', 66);

-- === consulta ===
SELECT CONCAT(estudiante, ' - ', curso) AS etiqueta
FROM notas
WHERE curso = 'DB-101'
ORDER BY nota DESC
LIMIT 2;

Microsoft SQL Server · implementaciones/sql-server/consulta.sql

declarado — se revisa a mano contra la documentación citada; la máquina no lo ejecuta

-- motor: sql-server
-- doc: https://learn.microsoft.com/sql/t-sql/functions/concat-transact-sql
-- nota: implementacion declarada. Se escribe con CONCAT y con OFFSET/FETCH
--       —las formas de la norma— en vez de con + y TOP, que es lo que aparece
--       en el codigo heredado. La diferencia importa: `'Ada' + 5` intenta
--       convertir la cadena a numero y falla; CONCAT convierte a texto.

-- === preparacion ===
DROP TABLE IF EXISTS dbo.notas;

CREATE TABLE dbo.notas (
    estudiante NVARCHAR(50) NOT NULL,
    curso      NVARCHAR(20) NOT NULL,
    nota       INT NOT NULL,
    CONSTRAINT pk_notas PRIMARY KEY (estudiante, curso)
);
INSERT INTO dbo.notas (estudiante, curso, nota) VALUES
    (N'Ada', N'DB-101', 90), (N'Grace', N'DB-101', 72),
    (N'Linus', N'DB-101', 58), (N'Ada', N'SE-201', 66);

-- === consulta ===
SELECT CONCAT(estudiante, N' - ', curso) AS etiqueta
FROM dbo.notas
WHERE curso = N'DB-101'
ORDER BY nota DESC
OFFSET 0 ROWS FETCH NEXT 2 ROWS ONLY;

Oracle Database · implementaciones/oracle-database/consulta.sql

declarado — se revisa a mano contra la documentación citada; la máquina no lo ejecuta

-- motor: oracle-database
-- doc: https://docs.oracle.com/en/database/oracle/oracle-database/23/sqlrf/Data-Types.html
-- nota: implementacion declarada. Oracle si implementa || de la norma, y desde
--       12c admite FETCH FIRST. Antes habia que envolver la consulta:
--         SELECT * FROM (SELECT ... ORDER BY nota DESC) WHERE ROWNUM <= 2;
--       Y ojo con la cadena vacia: en Oracle '' ES NULL, asi que concatenar con
--       una columna vacia no da lo mismo que en el resto de motores.

-- === preparacion ===
CREATE TABLE notas (
    estudiante VARCHAR2(50) NOT NULL,
    curso      VARCHAR2(20) NOT NULL,
    nota       NUMBER NOT NULL,
    CONSTRAINT pk_notas PRIMARY KEY (estudiante, curso)
);
INSERT INTO notas (estudiante, curso, nota) VALUES ('Ada', 'DB-101', 90);
INSERT INTO notas (estudiante, curso, nota) VALUES ('Grace', 'DB-101', 72);
INSERT INTO notas (estudiante, curso, nota) VALUES ('Linus', 'DB-101', 58);
INSERT INTO notas (estudiante, curso, nota) VALUES ('Ada', 'SE-201', 66);
COMMIT;

-- === consulta ===
SELECT estudiante || ' - ' || curso AS etiqueta
FROM notas
WHERE curso = 'DB-101'
ORDER BY nota DESC
FETCH FIRST 2 ROWS ONLY;

Los que no resuelven este caso — y qué se hace en su lugar

Descartar un motor con un argumento es tan formativo como usarlo. Ninguna de estas filas dice que el motor sea peor: dice que este problema no es el suyo.

Motor Por qué no Qué se hace en su lugar Fuente
MariaDB Aquí no aporta una fila distinta: comparte el dialecto de MySQL en todo lo que este caso toca, incluida la interpretación de \|\|. Repetirlo sería inflar la matriz sin enseñar nada. Se trata donde sí diverge —secuencias, RETURNING, motores de almacenamiento y el catálogo de funciones JSON— en la clase de divergencias entre dialectos. doc

Laboratorio

python scripts/validate_repository.py
python labs/03-transactions/run_transactions_lab.py

Guarda como evidencia la salida completa, la versión del motor y la semilla o los parámetros usados. Una captura sin comando no es evidencia: no se puede repetir.

Evaluación

Criterio Peso Qué se comprueba
Comprensión conceptual 25 % Explica el mecanismo, no solo el resultado
Ejecución reproducible 25 % Otra persona obtiene lo mismo con las instrucciones dadas
Interpretación basada en evidencia 25 % Cada conclusión se apoya en una salida o una medición
Límites y riesgos declarados 25 % Dice qué no demuestra el ejercicio y qué faltaría en producción

La clase se da por superada cuando la respuesta explica el mecanismo, muestra la salida que la respalda y declara al menos un límite del ejercicio.

Fuentes de esta clase

Todo lo afirmado arriba procede de estas obras. Los identificadores viven en catalog/sources.json y el estado de los enlaces se comprueba con python scripts/check_external_links.py.


Programa · Parte 05 · ← Anterior · Siguiente →