Esquema de cada comando soportado con railroad diagram (mermaid), gramática EBNF, ejemplos válidos y errores típicos. Equivalente al syntax diagrams de SQLite o al SQL command reference de PostgreSQL — pero acotado al subset que gabysql ya entrega.

Para el inventario exhaustivo de lo que NO está implementado todavía (comandos faltantes, prioridades, bloques de implementación sugeridos), ver MISSING_COMMANDS.md.

Para el detalle del formato en disco que respalda esta gramática, ver TECHNICAL_SPECS.md. Para el AST en código, src/sql.rs.

📊 ¿No ves los railroad diagrams? Este documento usa 17 bloques mermaid + 1 en ARCHITECTURE.md. Si los ves como código en lugar de gráfico, tu renderer no soporta mermaid. Soluciones:

Los 17 bloques de este archivo + el de ARCHITECTURE.md fueron validados sintácticamente el 2026-05-29 — si alguno no renderiza es porque el renderer cortó por límite de nodos (raro), no porque el código mermaid esté roto.


🧭 Índice de comandos

Comando Categoría Estado
CREATE DATABASE DDL · server multi-DB 🟢
DROP DATABASE DDL · server multi-DB 🟢
SHOW DATABASES DDL · server multi-DB 🟢
INTEGRITY CHECK Operacional 🟢
CREATE TABLE DDL 🟢
DROP TABLE DDL 🟢
ALTER TABLE ADD COLUMN DDL 🟢
CREATE TABLE AS SELECT / RENAME TABLE / ALTER TABLE DROP/RENAME COLUMN DDL (K1) 🟢
CREATE INDEX DDL 🟢
DROP INDEX DDL 🟢
INSERT DML 🟢
SELECT DML 🟢
UPDATE (WHERE completo desde bloque E3 — multi-fila, indexado, subquery) DML 🟢
DELETE (WHERE completo desde bloque E3 — multi-fila, indexado, subquery) DML 🟢
WHERE col IN (SELECT …) (no-correlacionada, single-column) DML 🟢
WHERE col = (SELECT …) (subquery escalar no-correlacionada) DML 🟢
WHERE [NOT] EXISTS (SELECT …) (no-correlacionada y correlacionada single-eq) DML 🟢
WHERE con AND/OR/NOT + paréntesis y 3VL para NULL (bloque E1) DML 🟢
WHERE con <, >, <=, >=, <>/!=, [NOT] LIKE (con %/_), IS [NOT] NULL, [NOT] IN (lista) (bloque E2) DML 🟢
Agregaciones: COUNT(*), COUNT(col), COUNT(DISTINCT col), SUM, AVG, MIN, MAX, GROUP BY, HAVING, DISTINCT (bloque F) DML 🟢 (sin JOINs aún)
Transacciones explícitas: BEGIN/START TRANSACTION, COMMIT/END, ROLLBACK (bloque T) TCL 🟢 (batch-local + SAVEPOINT/ROLLBACK TO/RELEASE desde M12, ADR-0089)
Cross-request transactions HTTP via X-Gabysql-Session header + endpoints /tx/{begin,commit,rollback} (M13) TCL 🟢 (single-slot global; ver API.md)
Multi-row INSERT VALUES (...), (...), INSERT INTO t SELECT ..., TRUNCATE [TABLE] (bloque J) DML 🟢
UPSERT (INSERT ... ON CONFLICT DO NOTHING / DO UPDATE SET ...), REPLACE INTO, RETURNING (bloque J2) DML 🟢 (sin EXCLUDED.col)
INNER JOIN ... ON l = r, CROSS JOIN, comma-syntax, aliases (AS), multi-tabla chain, self-join DML 🟢
LEFT [OUTER] JOIN, RIGHT [OUTER] JOIN, FULL [OUTER] JOIN con NULL-fill DML 🟢
JOIN ... USING (col), NATURAL JOIN con SELECT * dedup DML 🟢
Index-loop join optimization (transparente: aplica auto cuando hay índice/PK) DML 🟢
PK compuesta (PRIMARY KEY (a, b, ...)) — all-INT NOT NULL DDL 🟢 (K2, VERSION 8)
Índices compuestos (CREATE [UNIQUE] INDEX idx ON t (a, b, ...)) — all-INT, equality-only DDL 🟢 (K2, VERSION 8)
FK referential actions completas (ON DELETE / ON UPDATE con RESTRICT / CASCADE / SET NULL / SET DEFAULT / NO ACTION) DDL+DML 🟢 (L1/VERSION 9 + residual #4)
CHECK (expr) column-level y table-level, ALTER TABLE ADD [CONSTRAINT n] CHECK (expr) DDL 🟢 (L2/VERSION 10 + L3)
Nombres de constraint (CONSTRAINT <name> en PK/UNIQUE/FK/CHECK) + ALTER TABLE DROP CONSTRAINT [IF EXISTS] <name> DDL 🟢 (residual #2/VERSION 11)
FK multi-col (FOREIGN KEY (a, b) REFERENCES p (x, y)) DDL 🟢 (residual #3/VERSION 12)
UPDATE de PK con re-encode + cascade ON UPDATE (regular UPDATE; UPSERT DO UPDATE sigue restringido) DML 🟢 (residual #4)
CREATE VIEW [IF NOT EXISTS] v [(col_aliases)] AS SELECT ... / DROP VIEW [IF EXISTS] v, SELECT ... FROM v DDL+DML 🟢 (bloque V/VERSION 13)
WITH name AS (SELECT ...) (CTEs no-recursivas) — encadenables, en JOIN/subquery/set-ops, shadowing ANSI DML 🟢 (bloque W1)
WITH RECURSIVE name AS (anchor UNION [ALL] step) <body> — fixpoint base+step, delta semantics, guards de runaway DML 🟢 (bloque W2)
Window functionsROW_NUMBER/RANK/DENSE_RANK/NTILE/LAG/LEAD/FIRST_VALUE/LAST_VALUE/SUM/COUNT/AVG/MIN/MAX con OVER (PARTITION BY ... ORDER BY ...) DML 🟢 (bloque W3)
CREATE TRIGGER name {BEFORE\|AFTER} {INSERT\|UPDATE\|DELETE} ON t FOR EACH ROW <body> + DROP TRIGGER — body single-stmt o BEGIN stmt; stmt; END DDL 🟢 (bloques X1+X2 / VERSION 14)
CREATE PROCEDURE name(p1 TYPE, ...) AS <body> + DROP PROCEDURE + CALL name(args) DDL+DML 🟢 (bloque X3 / VERSION 15)
CREATE FUNCTION name(p1 TYPE, ...) RETURNS TYPE AS <expr> + DROP FUNCTION, invocable como name(args) en cualquier expresión DDL+DML 🟢 (bloque X3b / VERSION 16)
IF expr THEN ... [ELSIF ...]* [ELSE ...] END IF — statement top-level + dentro de bodies; anidado TCL 🟢 (bloque X4)
DECLARE + SET + WHILE LOOP + EXIT [WHEN] — variables locales con scope plano; vars visibles en Expr (no en INSERT VALUES) TCL 🟢 (bloque X4b)
RAISE [EXCEPTION\|NOTICE] 'msg' + FOR i IN start TO end LOOP ... END LOOP — aborto explícito + range loop con auto-decl TCL 🟢 (bloque X4c)
BEGIN <body> [EXCEPTION WHEN OTHERS THEN <handler>] END + LOOP <body> END LOOP standalone — try/catch catch-all + loop infinite hasta EXIT TCL 🟢 (bloque X4d)
CASE WHEN cond THEN ... [ELSE ...] END CASE + EXCEPTION WHEN <code> THEN ... — CASE statement-level + filtros por código en EXCEPTION + OTHERS fallback TCL 🟢 (bloque X4e)
CREATE FUNCTION ... AS BEGIN ... RETURN expr; END — function bodies multi-statement con RETURN como sentinel (compat single-expr body de X3b) DDL 🟢 (bloque X4f)
NEW mutable en BEFORE, EXCEPTION WHEN <name> simbólico, FOR row IN SELECT, RETURN expr, RETURNS TABLE, body de function como SELECT, partial indexes, frame specs explícitas, WINDOW w AS (...), múltiples CTEs RECURSIVE 🔴 (ver MISSING_COMMANDS)

🔤 Identificadores

Tablas, columnas e índices comparten una sola regla. Definida en catalog::validate_identifier.

Palabras reservadas (case-insensitive): add, alter, and, between, bool, column, create, database, databases, date, datetime, default, delete, drop, exists, false, float, from, if, index, insert, int, into, json, key, limit, not, null, offset, on, primary, select, set, show, table, text, true, unique, update, values, where.

Errores típicos:

Mensaje Causa
nombre de tabla 'X' es palabra reservada el nombre coincide con una keyword del parser
nombre de columna 'X' inválido: debe empezar con letra o '_' empieza con dígito o símbolo
nombre de índice 'X' inválido: solo se admiten [A-Za-z0-9_] tiene caracteres no permitidos (guion, espacio, etc.)
nombre de tabla 'X' excede el máximo de 64 caracteres identificador demasiado largo

🧱 Tipos de dato soportados

flowchart LR
    INT["INT<br/>i64 little-endian"]
    TEXT["TEXT<br/>UTF-8 bytes"]
    BOOL["BOOL<br/>0 ó 1"]
    FLOAT["FLOAT<br/>f64 little-endian"]
    DATE["DATE<br/>texto ISO-8601"]
    DATETIME["DATETIME<br/>texto ISO-8601"]
    TIME["TIME<br/>texto HH:MM:SS[.fff]"]
    UUID["UUID<br/>texto 8-4-4-4-12 hex"]
    JSON["JSON<br/>texto, no indexable"]
    NULL["NULL<br/>solo en columnas no PK"]
Tipo canónico Almacenamiento Indexable Aliases sintácticos (bloque Y) Notas
INT 8 bytes LE INTEGER, INT2, INT4, INT8, BIGINT, SMALLINT, TINYINT, MEDIUMINT Único tipo válido como PK. Sin enforcement de rango para SMALLINT/TINYINT (alias puro).
TEXT bytes UTF-8 VARCHAR[(n)], CHAR[(n)], CHARACTER[(n)], CHARACTER VARYING[(n)], NVARCHAR[(n)], NCHAR[(n)], STRING, CLOB Hasta 65 535 bytes globalmente. Si la columna se declara con (n), el n se enforce en bytes UTF-8 desde el bloque Y2 — [GBY-4119] si se excede.
BOOL 1 byte BOOLEAN TRUE / FALSE
FLOAT 8 bytes LE (f64) REAL, DOUBLE, DOUBLE PRECISION, NUMERIC[(p,s)], DECIMAL[(p,s)], DEC[(p,s)] DECIMAL/NUMERIC son aliases — no son decimal exacto.
DATE texto YYYY-MM-DD, validación lexical (no semántica).
DATETIME texto TIMESTAMP YYYY-MM-DD HH:MM:SS, validación lexical.
TIME (bloque Y) texto HH:MM:SS o HH:MM:SS.fff. Validación lexical en CAST. No timezone.
UUID (bloque Y) texto Canónico 8-4-4-4-12 hex. CAST AS UUID normaliza a lowercase.
JSON texto Sin semántica de igualdad canónica
NULL tag de presencia n/a No admitido en columnas PK

No soportado todavía: BLOB/BYTEA/BINARY (binario crudo), DECIMAL exacto, ARRAY[T], ENUM(...), INTERVAL, TIME WITH TIME ZONE, TIMESTAMP WITH TIME ZONE. Diferidos a Y2.


CREATE DATABASE

Solo en modo server multi-DB (-dir) o CLI con un directorio. Crea un archivo .db aplicando el formato VERSION = 33 (bump P4 por column stats persistidas, ver ADR-0068). En modo single-DB (-db) responde 405.

🛤️ Railroad

flowchart LR
    S([▶]) --> C[CREATE] --> D[DATABASE]
    D --> IFNE{IF NOT EXISTS?}
    IFNE -- "no" --> N[/db_name/]
    IFNE -- "sí" --> IF[IF] --> NOT[NOT] --> EX[EXISTS] --> N
    N --> SEMI[";"] --> E([■])

📜 EBNF

create_database ::= "CREATE" "DATABASE" ("IF" "NOT" "EXISTS")? identifier

✅ Ejemplos

CREATE DATABASE shop;
CREATE DATABASE IF NOT EXISTS analytics;

❌ Errores típicos

Mensaje Causa
base de datos 'X' ya existe falta IF NOT EXISTS
CREATE/DROP/SHOW DATABASE requieren modo -dir el server fue arrancado con -db
nombre de DB inválido: solo [A-Za-z0-9_-] identificador con caracteres prohibidos

DROP DATABASE

Elimina el archivo .db y su .wal si quedó. Acción irreversible, sin respaldo.

🛤️ Railroad

flowchart LR
    S([▶]) --> D[DROP] --> DB[DATABASE]
    DB --> IFE{IF EXISTS?}
    IFE -- "no" --> N[/db_name/]
    IFE -- "sí" --> IF[IF] --> EX[EXISTS] --> N
    N --> SEMI[";"] --> E([■])

📜 EBNF

drop_database ::= "DROP" "DATABASE" ("IF" "EXISTS")? identifier

✅ Ejemplos

DROP DATABASE analytics;
DROP DATABASE IF EXISTS legacy;

❌ Errores típicos

Mensaje Causa
base de datos 'X' no existe falta IF EXISTS y la DB no estaba

SHOW DATABASES

🛤️ Railroad

flowchart LR
    S([▶]) --> SH[SHOW] --> DBS[DATABASES] --> SEMI[";"] --> E([■])

📜 EBNF

show_databases ::= "SHOW" "DATABASES"

✅ Resultado

Devuelve una ResultSet con una columna database, un row por DB ordenado alfabéticamente:

{ "columns": ["database"], "rows": [["analytics"], ["shop"]], "message": null }

CREATE TABLE

🛤️ Railroad diagram

flowchart LR
    Start([▶]) --> CREATE[CREATE]
    CREATE --> TABLE[TABLE]
    TABLE --> Name[/identifier/]
    Name --> POPEN["("]
    POPEN --> Col[ColumnDef]
    Col --> COMMA{","}
    COMMA -- "sí" --> Col
    COMMA -- "no" --> PCLOSE[")"]
    PCLOSE --> SEMI[";"]
    SEMI --> End([■])
flowchart LR
    S([ColumnDef]) --> N[/identifier/]
    N --> T[/type/]
    T --> CST{constraint?}
    CST -- "no" --> E([fin])
    CST -- "PRIMARY KEY" --> CST
    CST -- "NOT NULL" --> CST
    CST -- "UNIQUE" --> CST
    CST -- "DEFAULT lit" --> CST

📜 EBNF

create_table   ::= "CREATE" "TABLE" identifier "(" table_item ("," table_item)* ")"
table_item     ::= column_def | table_constraint
column_def     ::= identifier type column_constraint*
column_constraint ::= [ "CONSTRAINT" identifier ]
                    ( "PRIMARY" "KEY"
                    | "NOT" "NULL"
                    | "UNIQUE"
                    | "DEFAULT" default_value
                    | "CHECK" "(" expr ")"
                    | "REFERENCES" identifier "(" identifier ")" fk_action* )
table_constraint  ::= [ "CONSTRAINT" identifier ]
                    ( "PRIMARY" "KEY" "(" identifier { "," identifier } ")"
                    | "UNIQUE" "(" identifier { "," identifier } ")"
                    | "CHECK" "(" expr ")"
                    | "FOREIGN" "KEY" "(" identifier { "," identifier } ")"
                        "REFERENCES" identifier "(" identifier { "," identifier } ")" fk_action* )
fk_action      ::= ("ON" "DELETE" | "ON" "UPDATE")
                   ("RESTRICT" | "CASCADE" | "SET" "NULL" | "SET" "DEFAULT" | "NO" "ACTION")
type           ::= "INT" | "TEXT" | "BOOL" | "FLOAT" | "DATE" | "DATETIME" | "JSON"
literal        ::= integer | float | string | "TRUE" | "FALSE" | "NULL"
default_value  ::= literal | default_fn
default_fn     ::= ("gen_random_uuid" | "uuid_v4" | "uuid_generate_v4" | "random_uuid"
                  | "uuid_v7" | "uuid_generate_v7" | "gen_uuid_v7"
                  | "current_timestamp" | "now") "(" ")"
identifier     ::= [A-Za-z_][A-Za-z0-9_]*

Notas:

✅ Ejemplos válidos

CREATE TABLE users (
  id INT PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  name TEXT,
  active BOOL DEFAULT TRUE,
  score FLOAT,
  status TEXT NOT NULL DEFAULT 'pending',
  born DATE,
  meta JSON
);

CREATE TABLE orders (
  id INT PRIMARY KEY,
  user_id INT REFERENCES users(id) ON DELETE CASCADE,
  total FLOAT,
  tries INT DEFAULT 0
);

-- Self-reference: cada empleado puede tener un manager (que también es empleado).
CREATE TABLE employee (
  id INT PRIMARY KEY,
  name TEXT NOT NULL,
  manager_id INT REFERENCES employee(id)
);

❌ Errores típicos

Mensaje Causa
PRIMARY KEY 'pk' debe ser INT (...) la columna marcada como PK no es INT
PRIMARY KEY requerida (...) no se declaró ninguna columna como PK
PRIMARY KEY 'pk' no admite DEFAULT en esta versión DEFAULT aplicado sobre la PK
columna 'X': NOT NULL incompatible con DEFAULT NULL combinación contradictoria de constraints
columna 'X': DEFAULT incompatible con tipo TEXT el literal de DEFAULT no coincide con el tipo declarado
FOREIGN KEY 'X.col' referencia tabla inexistente 'Y' el target table no existe (ni es self-ref)
FOREIGN KEY 'X.col' debe referenciar la PK de 'Y' (es 'pk_real'); esta versión no admite REFERENCES contra columnas no-PK target column no es la PK del parent
FOREIGN KEY 'X.col' debe ser INT para coincidir con la PK de 'Y' tipo del FK no matchea el tipo de la PK referenciada
nombre de columna duplicado dos columnas con el mismo nombre
tabla X ya existe hay otra tabla con ese nombre en el catálogo
tipo no soportado: BIGINT tipos fuera de la lista anterior

CREATE INDEX

🛤️ Railroad

flowchart LR
    S([▶]) --> C[CREATE] --> I[INDEX] --> N[/index_name/]
    N --> ON[ON] --> T[/table_name/]
    T --> POPEN["("] --> COL[/column_name/] --> PCLOSE[")"] --> SEMI[";"] --> E([■])

📜 EBNF

create_index ::= "CREATE" "UNIQUE"? "INDEX" identifier "ON" identifier "(" identifier ")"

✅ Ejemplos

-- Crear índice (backfill automático sobre las filas ya existentes)
CREATE INDEX idx_users_name ON users (name);
CREATE INDEX idx_orders_status ON orders (status);

-- Índice único: el backfill aborta si existen duplicados; en caliente
-- INSERT/UPDATE conflictivos se rechazan antes de tocar disco.
CREATE UNIQUE INDEX uq_users_email ON users (email);

❌ Errores típicos

Mensaje Causa
ya existe un índice llamado 'X' en la tabla 'Y' el nombre del índice se repite (debe ser único en toda la DB)
la columna 'X' ya tiene un índice secundario esta versión soporta solo un índice por columna
no se admiten índices sobre columnas JSON en esta versión JSON no es indexable (ver tabla de tipos)
columna no existe: X la columna no aparece en el CREATE TABLE
CREATE UNIQUE INDEX rechazado: columna 'X' tiene valores duplicados existentes la tabla ya tenía duplicados al pedir el índice unique
violación de UNIQUE en índice 'uq_t_c' (PK existente: N) INSERT/UPDATE intenta colocar un valor ya presente en otra fila

Reglas: una sola columna por índice, solo equality (=). UNIQUE permite múltiples NULL. Ver ADR-0005.


DROP INDEX

🛤️ Railroad

flowchart LR
    S([▶]) --> D[DROP] --> I[INDEX] --> N[/index_name/] --> SEMI[";"] --> E([■])

📜 EBNF

drop_index ::= "DROP" "INDEX" identifier

✅ Ejemplos

DROP INDEX idx_users_name;

❌ Errores típicos

Mensaje Causa
índice no existe: X no hay un índice con ese nombre en ninguna tabla

DROP INDEX no libera las páginas del B+Tree del índice. La reclamación queda para una futura herramienta vacuum (ver STATUS.md).


DROP TABLE

Borra la entrada de la tabla en el catálogo. Las páginas backing (data + índices secundarios de esa tabla) no se liberan; el espacio se reclama con un futuro vacuum.

🛤️ Railroad

flowchart LR
    S([▶]) --> D[DROP] --> T[TABLE]
    T --> IFE{IF EXISTS?}
    IFE -- "no" --> N[/table_name/]
    IFE -- "sí" --> IF[IF] --> EX[EXISTS] --> N
    N --> SEMI[";"] --> E([■])

📜 EBNF

drop_table ::= "DROP" "TABLE" ("IF" "EXISTS")? identifier

✅ Ejemplos

DROP TABLE users;
DROP TABLE IF EXISTS scratch;

❌ Errores típicos

Mensaje Causa
tabla no existe: X no hay tabla con ese nombre y no se usó IF EXISTS

ALTER TABLE ADD COLUMN

Agrega una columna al final del esquema. Las filas previas se decodifican con su DEFAULT (o NULL) sin reescritura — el rewrite ocurre naturalmente cuando un UPDATE toca esa fila.

🛤️ Railroad

flowchart LR
    S([▶]) --> A[ALTER] --> T[TABLE] --> N[/table_name/]
    N --> ADD[ADD] --> COL{COLUMN?} --> CD[ColumnDef]
    CD --> SEMI[";"] --> E([■])

📜 EBNF

alter_table_add ::= "ALTER" "TABLE" identifier "ADD" "COLUMN"? column_def
column_def       ::= identifier type column_constraint*

✅ Ejemplos

ALTER TABLE users ADD COLUMN nick TEXT;
ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'pending';
ALTER TABLE users ADD COLUMN email TEXT UNIQUE;
ALTER TABLE users ADD score FLOAT DEFAULT 0;     -- COLUMN es opcional

❌ Errores típicos

Mensaje Causa
tabla no existe: X la tabla a alterar no está en el catálogo
columna 'X' ya existe en la tabla 'Y' nombre repetido
ALTER TABLE ADD COLUMN no admite PRIMARY KEY (la PK ya existe) esta versión no permite swap ni multi-PK
ALTER TABLE ADD COLUMN 'X' NOT NULL requiere un DEFAULT no nulo (...) sin DEFAULT, las filas previas violarían la constraint
ALTER TABLE ADD COLUMN 'X' UNIQUE con DEFAULT no nulo produciría duplicados en N filas existentes el backfill insertaría el mismo valor en todas las filas
columna 'X': DEFAULT incompatible con tipo TEXT mismo validador de tipos que CREATE TABLE

Restricciones: para DROP COLUMN, RENAME COLUMN y RENAME TABLE ver la sección DDL extendido (K1). PK compuesta + índices compuestos cerrados en K2 (VERSION 8, ver ADR-0019). ALTER ... TYPE y ALTER PK siguen pendientes.


DDL extendido (K1)

Bloque K1 (2026-05-26). Cuatro sentencias DDL adicionales que no cambian el formato en disco (VERSION sigue en 7).

CREATE TABLE [IF NOT EXISTS] [(col_aliases)] AS SELECT

Materializa el resultado de una SelectQuery (SELECT, set ops o VALUES) como una tabla nueva. La primera columna del result-set debe ser INT no-NULL y se promueve a PRIMARY KEY.

ctas ::= "CREATE" "TABLE" ("IF" "NOT" "EXISTS")? identifier
         ("(" identifier ("," identifier)* ")")?
         "AS" select_query ";"
CREATE TABLE activos AS SELECT id, nombre FROM usuarios WHERE id > 0;
CREATE TABLE IF NOT EXISTS dst (pk, label) AS SELECT id, nombre FROM src;
CREATE TABLE lit (id, label) AS VALUES (1, 'a'), (2, 'b');
CREATE TABLE merged AS SELECT id, nombre FROM a UNION SELECT id, nombre FROM b;

Errores típicos: [GBY-4058] primera columna no INT no-NULL · [GBY-4063] arity de aliases ≠ arity del SELECT · [GBY-2004] el destino ya existe (sin IF NOT EXISTS).

RENAME TABLE / ALTER TABLE ... RENAME TO

rename_table ::= "RENAME" "TABLE" identifier "TO" identifier ";"
              |  "ALTER" "TABLE" identifier "RENAME" "TO" identifier ";"
RENAME TABLE old TO new;
ALTER TABLE old RENAME TO new;

Las FKs entrantes (otras tablas que referencien old) se reescriben automáticamente al nuevo nombre. Errores: [GBY-4062] destino tomado · [GBY-2001] origen no existe.

ALTER TABLE ... DROP COLUMN [IF EXISTS]

alter_drop ::= "ALTER" "TABLE" identifier "DROP" "COLUMN"
               ("IF" "EXISTS")? identifier ";"
ALTER TABLE users DROP COLUMN nick;
ALTER TABLE users DROP COLUMN IF EXISTS deprecated_flag;

Bloqueado sobre la PK ([GBY-4059]), columnas indexadas ([GBY-4060], sugiere DROP INDEX <name> primero) y columnas con FK saliente o entrante ([GBY-4061]). Implementación: rewrite in place de cada fila (decode + remove + encode + upsert).

ALTER TABLE ... RENAME COLUMN

alter_rename_col ::= "ALTER" "TABLE" identifier "RENAME" "COLUMN"
                     identifier "TO" identifier ";"
ALTER TABLE users RENAME COLUMN nick TO handle;
ALTER TABLE pedidos RENAME COLUMN id TO pedido_id;  -- arrastra PK + FKs entrantes

No reescribe filas (el on-disk row es posicional). Si la columna es la PK, TableMeta.primary_key se actualiza; si está indexada, IndexMeta.column se actualiza; las FKs entrantes que referencien la columna se reescriben. Errores: [GBY-4062] destino tomado · [GBY-2002] origen no existe.


ALTER TABLE ADD CHECK (L3) / DROP CONSTRAINT (residual #2)

L3 (2026-05-27): agrega un CHECK después del CREATE TABLE. Hace full-scan de la tabla y revalida todas las filas existentes contra la expresión — si alguna viola, la sentencia se aborta sin persistir el constraint. Costo O(n) en filas, una sola vez.

Residual #2 (2026-05-27): elimina por nombre cualquier constraint nombrado (CHECK / UNIQUE / FK). PK siempre rechazada ([GBY-4072]).

📜 EBNF

alter_add_check    ::= "ALTER" "TABLE" identifier "ADD"
                       [ "CONSTRAINT" identifier ] "CHECK" "(" expr ")" ";"
alter_drop_constraint ::= "ALTER" "TABLE" identifier "DROP" "CONSTRAINT"
                          [ "IF" "EXISTS" ] identifier ";"

✅ Ejemplos

-- Agregar CHECK con re-validación O(n)
ALTER TABLE products ADD CHECK (price >= 0);
ALTER TABLE products ADD CONSTRAINT chk_stock_positivo CHECK (stock >= 0);

-- Quitar constraints nombradas
ALTER TABLE products DROP CONSTRAINT chk_stock_positivo;
ALTER TABLE orders DROP CONSTRAINT IF EXISTS fk_orders_user;
ALTER TABLE users DROP CONSTRAINT uq_users_email;

❌ Errores típicos

Mensaje Causa
[GBY-3008] CHECK_VIOLATED (en ALTER TABLE ADD CHECK) alguna fila existente viola el predicado — sentencia abortada
[GBY-4069] subquery dentro de CHECK el AST de CHECK no admite subqueries en esta versión
[GBY-4071] CONSTRAINT_NOT_FOUND nombre no existe (omite con IF EXISTS)
[GBY-4072] CANNOT_DROP_PRIMARY_KEY_CONSTRAINT intento de drop sobre la PK

CREATE VIEW / DROP VIEW (bloque V)

Bloque V (2026-05-27, VERSION 13). Vistas lógicas read-only sobre el SELECT que las definió. La vista guarda únicamente el source_sql + alias de columnas; en cada query se expande dentro del FROM como una derived table. Tablas y vistas comparten namespace.

📜 EBNF

create_view ::= "CREATE" "VIEW" [ "IF" "NOT" "EXISTS" ] identifier
                [ "(" identifier ("," identifier)* ")" ]
                "AS" select_stmt ";"
drop_view   ::= "DROP" "VIEW" [ "IF" "EXISTS" ] identifier ";"

✅ Ejemplos

CREATE VIEW activos AS
  SELECT id, nombre, email FROM users WHERE active = TRUE;

SELECT * FROM activos ORDER BY nombre;

CREATE VIEW IF NOT EXISTS resumen (cliente, total)
  AS SELECT customer_id, SUM(total) FROM orders GROUP BY customer_id;

-- DML sobre vistas no se permite
-- INSERT INTO activos VALUES (...);  -- [GBY-4075] VIEWS_ARE_READONLY

-- Vistas anidadas (la fuente puede referenciar otra vista; cycle guard depth=32)
CREATE VIEW vips AS SELECT * FROM activos WHERE id IN (SELECT user_id FROM gold_members);

DROP VIEW IF EXISTS resumen;

⚠️ Reglas

❌ Errores típicos

Mensaje Causa
[GBY-4075] VIEWS_ARE_READONLY DML sobre vista
[GBY-4076] VIEW_DEPTH_EXCEEDED recursión / ciclo entre vistas más allá de 32 niveles
[GBY-4077] NAME_ALREADY_EXISTS colisión de nombre tabla ↔ vista
[GBY-4078] VIEW_SOURCE_NOT_SIMPLE_SELECT la fuente no es un SELECT simple

INSERT

🛤️ Railroad

flowchart LR
    S([▶]) --> I["INSERT (o REPLACE)"] --> INTO[INTO] --> T[/table/]
    T --> COLS["( col_list )"]
    COLS --> SRC{insert_source}
    SRC -- "VALUES" --> ROWS["(vals), (vals), ..."]
    SRC -- "SELECT" --> SEL[SELECT body]
    ROWS --> OC{ON CONFLICT?}
    SEL --> OC
    OC -- "DO NOTHING" --> RET
    OC -- "DO UPDATE SET ..." --> RET
    OC -- "(REPLACE)" --> RET
    OC --> RET{RETURNING?}
    RET -- "*  /  cols" --> SEMI[";"]
    RET --> SEMI
    SEMI --> E([■])

📜 EBNF

insert        ::= ("INSERT" | "REPLACE") "INTO" identifier "(" col_list ")"
                  insert_source
                  on_conflict_clause?
                  returning_clause?
insert_source ::= "VALUES" "(" value_list ")" ("," "(" value_list ")")*
                | "SELECT" select_body
on_conflict_clause
              ::= "ON" "CONFLICT" ( "(" identifier ")" )? "DO" conflict_action
conflict_action
              ::= "NOTHING"
                | "UPDATE" "SET" assignment ("," assignment)*
assignment    ::= identifier "=" value
returning_clause
              ::= "RETURNING" ( "*" | identifier ("," identifier)* )
col_list      ::= identifier ("," identifier)*
value_list    ::= value ("," value)*
value         ::= integer | float | string | "TRUE" | "FALSE" | "NULL"
string        ::= "'" ([^'] | "''")* "'"

REPLACE INTO ... VALUES (...) se desugara internamente a INSERT ... ON CONFLICT DO REPLACE — la cláusula ON CONFLICT explícita no se acepta si la sentencia empezó con REPLACE. El target opcional (col) solo se admite si col es PK o tiene índice UNIQUE ([GBY-4032]). En DO UPDATE SET, los valores de la derecha son literales — EXCLUDED.col no se soporta en este release.

Desde el bloque J (2026-05-25) el INSERT admite tres formas:

El message del response trae la cuenta: "OK (3 filas insertadas)".

✅ Ejemplos

-- Single-row (compat pre-J)
INSERT INTO users (id, name, active, score) VALUES (1, 'Ana', TRUE, 9.5);
INSERT INTO products (id, name, price) VALUES (10, 'Café o''rgánico', 4500.50);

-- Multi-row (bloque J)
INSERT INTO users (id, name, active) VALUES
  (2, 'Beto',  FALSE),
  (3, 'Carla', TRUE),
  (4, 'Dario', TRUE);

-- INSERT...SELECT (bloque J)
INSERT INTO users_backup (id, name, active)
SELECT id, name, active FROM users WHERE active = TRUE;

-- Con agregados del bloque F
INSERT INTO sales_summary (region, total)
SELECT region, SUM(monto) FROM ventas GROUP BY region;

-- UPSERT (bloque J2): ON CONFLICT DO NOTHING
INSERT INTO users (id, email) VALUES (1, 'a@x')
  ON CONFLICT DO NOTHING;

-- UPSERT: ON CONFLICT DO UPDATE (sin EXCLUDED.col por ahora — RHS literal)
INSERT INTO users (id, name) VALUES (1, 'Ana M')
  ON CONFLICT (id) DO UPDATE SET name = 'Ana M';

-- REPLACE INTO (SQLite-style): borra la fila conflictiva + inserta
REPLACE INTO users (id, name) VALUES (1, 'Anna');

-- RETURNING (bloque J2): la respuesta trae las filas afectadas
INSERT INTO orders (id, total) VALUES (10, 199.50) RETURNING id;
UPDATE products SET on_sale = TRUE WHERE stock > 0 AND price < 50 RETURNING id, price;
DELETE FROM sessions WHERE last_seen < '2024-01-01' RETURNING user_id;

❌ Errores típicos

Mensaje Causa
cantidad columnas != valores la lista de columnas y la de valores tienen distinto largo
columna duplicada en INSERT se nombra dos veces la misma columna
columna no existe: X la columna no está en el schema
duplicate primary key: N la PK ya está usada — usa otra o haz UPDATE
PRIMARY KEY no puede ser NULL se intentó pasar NULL para la PK
<col> debe ser INT (o FLOAT, etc.) tipo del valor no encaja con el de la columna

Mantenimiento: tras el insert, todos los índices secundarios de la tabla se actualizan automáticamente.


SELECT

🛤️ Railroad

flowchart LR
    S([▶]) --> SEL[SELECT]
    SEL --> DIST{DISTINCT?}
    DIST --> COLS{select_list}
    COLS -- "*" --> FROM
    COLS -- "items" --> ITEMS[col, agg AS alias, ...] --> FROM[FROM]
    FROM --> T[/table/]
    T --> JOIN{JOINs?}
    JOIN --> WH{WHERE?}
    WH --> GB{GROUP BY?}
    GB --> HAV{HAVING?}
    HAV --> OB{ORDER BY?}
    OB --> LIM{LIMIT?}
    LIM --> OFF{OFFSET?}
    OFF --> SEMI[";"] --> E([■])
flowchart LR
    A([where_clause]) --> OR{OR}
    OR --> AND{AND}
    AND --> NOT{NOT?}
    NOT -- "NOT" --> NOT
    NOT --> PRIM{primary}
    PRIM -- "(...)" --> A
    PRIM -- "EXISTS (SELECT)" --> EX([EXISTS])
    PRIM --> ATOM[atom]
    ATOM --> COL[/column/]
    COL --> OP{operador}
    OP -- "=" --> VEQ["value, (SELECT), o ref outer"]
    OP -- "&lt; &gt; &lt;= &gt;= &lt;&gt; !=" --> VCMP["value"]
    OP -- "BETWEEN" --> VBT["int AND int"]
    OP -- "IS [NOT] NULL" --> VNULL([NULL])
    OP -- "[NOT] LIKE" --> VLIKE["'patron' (con % _ y \\)"]
    OP -- "[NOT] IN" --> VIN["lista o (SELECT)"]

📜 EBNF

select       ::= "SELECT" ["DISTINCT"] select_list "FROM" identifier
                  ("WHERE" where_clause)?
                  ("GROUP" "BY" identifier ("," identifier)*)?
                  ("HAVING" where_clause)?
                  ("ORDER" "BY" identifier ("ASC" | "DESC")?)?
                  ("LIMIT" integer)?
                  ("OFFSET" integer)?
select_list  ::= "*" | select_item ("," select_item)*
select_item  ::= identifier
              | agg_func "(" agg_arg ")" ["AS" identifier | identifier]
agg_func     ::= "COUNT" | "SUM" | "AVG" | "MIN" | "MAX"
agg_arg      ::= "*" | "DISTINCT" identifier | identifier
where_clause  ::= where_or
where_or      ::= where_and ( "OR" where_and )*
where_and     ::= where_not ( "AND" where_not )*
where_not     ::= "NOT" where_not
                | where_primary
where_primary ::= "(" where_or ")"
                | "EXISTS" "(" select ")"
                | "NOT" "EXISTS" "(" select ")"
                | where_atom
where_atom    ::= identifier "=" ( value | "(" select ")" | qualified_ident )
                | identifier compare_op value
                | identifier "BETWEEN" integer "AND" integer
                | identifier "IS" ["NOT"] "NULL"
                | identifier ["NOT"] "LIKE" string
                | identifier ["NOT"] "IN" "(" value_list ")"
                | identifier "IN" "(" select ")"
compare_op    ::= "<" | "<=" | ">" | ">=" | "<>" | "!="
value_list    ::= value ("," value)*
qualified_ident ::= identifier ( "." identifier )?

Precedencia (de más baja a más alta): OR < AND < NOT < paréntesis / átomo. Es la convención estándar SQL: a OR b AND c se interpreta como a OR (b AND c). Los paréntesis fuerzan agrupaciones distintas.

Lógica trivaluada (3VL) — el WHERE evalúa con la tabla de verdad de SQL estándar para NULL:

Limitación E1: EXISTS correlacionado y col = otra.col (column-ref del outer) solo se permiten como único átomo del WHERE. Combinarlos con AND/OR/NOT devuelve [GBY-4024] — soporte completo queda para un bloque posterior. Subqueries no-correlacionadas (IN (SELECT), = (SELECT), EXISTS no-correlacionado) sí se pueden combinar libremente.

🔗 FROM con JOINs (bloque A del roadmap)

from_clause ::= table_ref join_clause*
table_ref   ::= identifier [ ["AS"] identifier ]
join_clause ::= ( "," | "CROSS" "JOIN" ) table_ref
              | ( "INNER" "JOIN" | "JOIN" ) table_ref "ON" qualified_ident "=" qualified_ident
              | ( "LEFT"  ["OUTER"] "JOIN" ) table_ref "ON" qualified_ident "=" qualified_ident
              | ( "RIGHT" ["OUTER"] "JOIN" ) table_ref "ON" qualified_ident "=" qualified_ident
              | ( "FULL"  ["OUTER"] "JOIN" ) table_ref "ON" qualified_ident "=" qualified_ident
              | ( "INNER" | "LEFT" ["OUTER"] | "RIGHT" ["OUTER"] | "FULL" ["OUTER"] | ε ) "JOIN" table_ref "USING" "(" identifier ")"
              | "NATURAL" ( "INNER" | "LEFT" ["OUTER"] | "RIGHT" ["OUTER"] | "FULL" ["OUTER"] | ε ) "JOIN" table_ref

Reglas:

Complejidad:

Sobre qualified_ident en el RHS del =: solo es válido dentro de una subquery correlacionada dentro de EXISTS (...). Permite expresar WHERE inner_col = outer_table.outer_col, donde outer_table es la tabla del SELECT padre. Usarlo fuera de ese contexto devuelve [GBY-4016].

Sobre EXISTS: la subquery se ejecuta una sola vez si no referencia columnas del outer; cuando sí lo hace, se re-ejecuta una vez por cada fila del outer (post-filter). Esta variante correlacionada es O(N × costo_subquery), sin optimizer; tiene sentido cuando la subquery se reduce vía PK/índice con la outer-ref.

= funciona sobre la PK o sobre cualquier columna que tenga índice secundario. BETWEEN funciona sobre la PK y sobre cualquier columna INT con índice secundario (índice OrderedInt, default automático al crear índice sobre INT; ver ADR-0017); para TEXT/FLOAT/BOOL/DATE/DATETIME indexados, BETWEEN queda en el Camino A. IN (SELECT …) acepta subqueries no-correlacionadas (la subquery no referencia columnas del outer); la subquery debe devolver exactamente una columna, se ejecuta una sola vez y el resultado se materializa como set para filtrar el outer — la columna del outer debe ser la PK o tener índice secundario, igual que =. ORDER BY funciona sobre cualquier columna del schema (no requiere índice); el sort es en memoria post-scan, así que para tablas grandes con LIMIT chico conviene tener un WHERE que reduzca el conjunto antes del sort.

✅ Ejemplos

SELECT * FROM users;
SELECT id, name FROM users LIMIT 10;
SELECT id, name FROM users LIMIT 10 OFFSET 20;

-- Por PK
SELECT * FROM users WHERE id = 1;
SELECT id, name FROM users WHERE id BETWEEN 1 AND 100;

-- Por columna indexada (requiere CREATE INDEX previo)
SELECT * FROM users WHERE name = 'Ana';
SELECT id FROM orders WHERE status = 'pending' LIMIT 50;

-- BETWEEN sobre columna INT indexada (índice OrderedInt, ADR-0017)
-- CREATE INDEX idx_users_score ON users (score);  -- score INT
SELECT id, name FROM users WHERE score BETWEEN 80 AND 100 LIMIT 25;

-- Operadores E2: <, >, <=, >=, <>, !=, LIKE, IS NULL, IN literal
SELECT id FROM users WHERE score < 50;
SELECT id FROM users WHERE score >= 80 AND score <= 100;
SELECT id FROM users WHERE name <> 'Ana';
SELECT id FROM users WHERE name LIKE 'A%';            -- empieza con 'A'
SELECT id FROM users WHERE name LIKE '_eto';          -- 4 chars, termina en 'eto'
SELECT id FROM users WHERE description NOT LIKE '%spam%';
SELECT id FROM users WHERE deleted_at IS NULL;
SELECT id FROM users WHERE id IN (1, 2, 3);
SELECT id FROM users WHERE country NOT IN ('AR', 'BR');
SELECT id FROM products WHERE code LIKE '50\%%';      -- LIKE literal '%' con escape

-- Agregaciones (bloque F): COUNT/SUM/AVG/MIN/MAX, GROUP BY, HAVING, DISTINCT
SELECT COUNT(*) FROM users;
SELECT COUNT(*) AS total FROM users WHERE active = TRUE;
SELECT COUNT(monto), SUM(monto), AVG(monto) FROM ventas;
SELECT region, SUM(monto) AS total
   FROM ventas
  GROUP BY region
  ORDER BY total DESC;
SELECT region, producto, COUNT(*) AS n
   FROM ventas
  GROUP BY region, producto
 HAVING COUNT(*) > 1;
SELECT DISTINCT category FROM products;
SELECT COUNT(DISTINCT user_id) FROM sessions;

-- Reglas ANSI estrictas:
-- - Toda columna no-agregada en el SELECT debe figurar en GROUP BY ([GBY-4027]).
-- - Las funciones agregadas solo se permiten en SELECT y HAVING, no en WHERE ([GBY-4025]).
-- - Sin GROUP BY pero con agregados → UNA fila global (incluso sobre input vacío: COUNT=0, resto=NULL).
-- - Agregados sobre SELECT con JOIN funcionan desde F2 (2026-05-30, ADR-0066 Gap 1+7).
-- - COUNT(DISTINCT col) sobre JOIN funciona desde R9 (2026-06-15, ADR-0079).

-- AND / OR / NOT + paréntesis (bloque E1)
SELECT id FROM users WHERE active = TRUE AND score BETWEEN 80 AND 100;
SELECT id FROM users WHERE city = 'BA' OR city = 'MDQ';
SELECT id FROM users WHERE NOT status = 'banned';
SELECT id FROM users WHERE (city = 'BA' OR city = 'MDQ') AND active = TRUE;
-- Precedencia estándar: AND ata más fuerte que OR.
SELECT id FROM users WHERE city = 'BA' OR city = 'MDQ' AND active = TRUE;
-- Equivale a: city = 'BA' OR (city = 'MDQ' AND active = TRUE)

-- ORDER BY (cualquier columna; ASC default; NULLs primero)
SELECT id, name FROM users ORDER BY name ASC;
SELECT id, name FROM users ORDER BY score DESC LIMIT 10;
SELECT id FROM orders WHERE status = 'pending' ORDER BY total DESC LIMIT 5 OFFSET 10;

-- IN (SELECT …) — subquery no-correlacionada
-- Requiere: outer.curso_id indexado (o ser PK) e inner.nivel indexado (o ser PK).
SELECT nombre FROM alumnos
 WHERE curso_id IN (SELECT id FROM cursos WHERE nivel = '3 Medio');

-- IN sobre PK directa (no requiere índice en el outer):
SELECT id, label FROM t WHERE id IN (SELECT ref_id FROM picks);

-- = (SELECT …) — subquery escalar (1 columna × ≤1 fila).
-- Si la subquery devuelve 0 filas o NULL, el match es vacío (semántica ANSI).
-- Si devuelve >1 fila, error [GBY-4014] — usar IN (...) en su lugar.
SELECT nombre FROM alumnos
 WHERE curso_id = (SELECT id FROM cursos WHERE nombre = 'matematica');

-- EXISTS no-correlacionada: la subquery se ejecuta UNA vez.
SELECT id FROM padre WHERE EXISTS (SELECT id FROM auditoria WHERE id = 1);

-- EXISTS correlacionada: re-ejecuta por cada fila del outer.
-- Padres que tienen al menos un hijo:
SELECT id, nombre FROM padre
 WHERE EXISTS (SELECT id FROM hijo WHERE parent_id = padre.id);

-- NOT EXISTS correlacionado: padres sin hijos.
SELECT id, nombre FROM padre
 WHERE NOT EXISTS (SELECT id FROM hijo WHERE parent_id = padre.id);

-- INNER JOIN clásico con aliases y columnas cualificadas
SELECT a.nombre, c.nombre FROM alumnos a
 INNER JOIN cursos c ON a.curso_id = c.id
 ORDER BY a.nombre ASC;

-- JOIN de 3 tablas en cadena (left-deep)
SELECT persona.nombre, ciudad.nombre, pais.nombre
  FROM persona
  JOIN ciudad ON persona.ciudad_id = ciudad.id
  JOIN pais   ON ciudad.pais_id = pais.id;

-- CROSS JOIN explícito (cartesian product)
SELECT a.v, b.w FROM a CROSS JOIN b;

-- Comma-syntax = CROSS JOIN
SELECT a.v, b.w FROM a, b;

-- Self-join vía aliases distintos
SELECT e.nombre, j.nombre FROM empleado e
  INNER JOIN empleado j ON e.jefe_id = j.id;

-- WHERE sobre columna cualificada de cualquier tabla
SELECT alumnos.nombre FROM alumnos
  JOIN cursos ON alumnos.curso_id = cursos.id
 WHERE cursos.nivel = '3M';

-- LEFT JOIN: padres sin hijos aparecen con etiqueta NULL
SELECT padre.nombre, hijo.etiqueta FROM padre
  LEFT JOIN hijo ON padre.id = hijo.parent_id
 ORDER BY padre.id ASC;

-- RIGHT JOIN: filas del derecho sin match aparecen con columnas del izq en NULL
SELECT a.v, b.w FROM a
  RIGHT JOIN b ON a.id = b.a_id;

-- FULL OUTER JOIN: combina LEFT + RIGHT en un solo paso
SELECT a.v, b.w FROM a
  FULL OUTER JOIN b ON a.id = b.a_id;

-- USING (col) — sugar para ON l.col = r.col; SELECT * dedup la columna
SELECT ciudad.nombre, pais.nombre_pais
  FROM ciudad JOIN pais USING (pais_id);

-- NATURAL JOIN — auto-detecta la columna común por nombre
SELECT ciudad.nombre, pais.nombre_pais
  FROM ciudad NATURAL JOIN pais;

❌ Errores típicos

Mensaje Causa
tabla no existe: X la tabla no está creada en la DB
ORDER BY: columna 'X' no existe en 'Y' la columna del ORDER BY no está en el schema de la tabla
WHERE solo soporta PK (X) o columnas con índice secundario; 'Y' no está indexada [GBY-4001] aplica al fast-path indexado de SELECT (= o BETWEEN sobre columna sin índice). El WHERE compuesto con AND/OR/NOT/</>/LIKE/IS NULL/IN literal cae a FullScan y no exige índice.
WHERE: no se reconoció el operador después de la columna 'X' [GBY-4001] operador fuera de la gramática actual del WHERE. Lista soportada: =, <, >, <=, >=, <>/!=, BETWEEN, IS [NOT] NULL, [NOT] LIKE, [NOT] IN (lista | SELECT), EXISTS.
subquery en IN debe devolver exactamente 1 columna; devolvió N la subquery proyectó más de una columna — reescribila con una sola
subquery escalar debe devolver exactamente 1 columna; devolvió N igual que el anterior pero en = (SELECT ...)
subquery escalar en WHERE devolvió N filas; debe devolver a lo sumo 1 la subquery escalar matcheó más de una fila — agregar WHERE/LIMIT 1 o usar IN (SELECT ...)
WHERE IN solo soporta PK (X) o columnas con índice secundario; 'Y' no está indexada [GBY-4013] aplica solo cuando el WHERE es col IN (SELECT) como único átomo (fast-path). El WHERE compuesto (col IN (SELECT) AND ...) cae a FullScan + 3VL y no exige índice.
EXISTS requiere '(SELECT ...)' a continuación [GBY-4015] EXISTS no seguido por un paréntesis abriendo un SELECT
outer column 'X.Y' fuera de alcance [GBY-4016] col = outer.col usado fuera de un EXISTS (...) correlacionado, o la tabla outer no coincide con la del outer-stack
PRIMARY KEY 'X' es INT; valor incompatible en WHERE pasaste un string a una PK INT

UPDATE

🛤️ Railroad

flowchart LR
    S([▶]) --> U[UPDATE] --> T[/table/]
    T --> SET[SET] --> A[col = value]
    A --> COMMA{","}
    COMMA -- "sí" --> A
    COMMA -- "no" --> WH[WHERE]
    WH --> WC[where_clause] --> RET{RETURNING?}
    RET -- "*  /  cols" --> SEMI[";"]
    RET --> SEMI
    SEMI --> E([■])

📜 EBNF

update       ::= "UPDATE" identifier "SET" assignment ("," assignment)*
                  "WHERE" where_clause
                  returning_clause?
assignment   ::= identifier "=" value

where_clause y returning_clause son los mismos definidos en SELECT y INSERT respectivamente — ver esas secciones.

Desde el bloque E3 el WHERE de UPDATE acepta exactamente la misma gramática que SELECT: combinadores AND/OR/NOT, paréntesis, todos los operadores E1+E2 (=, <, >, <=, >=, <>/!=, LIKE, IS NULL, IN literal, BETWEEN) y subqueries (IN (SELECT), = (SELECT), EXISTS). El UPDATE se aplica a todas las filas que el WHERE matchee; el message del response trae la cuenta.

✅ Ejemplos

-- Por PK directa
UPDATE users SET name = 'Ana M' WHERE id = 1;

-- Multi-asignación
UPDATE orders
   SET status = 'paid', total = 199.50
 WHERE id = 42;

-- Por columna indexada (afecta a todas las filas matcheadas)
UPDATE users SET active = FALSE WHERE city = 'BA';

-- Por predicado compuesto (E1+E2)
UPDATE products SET on_sale = TRUE
 WHERE price < 100 AND stock > 0 AND name LIKE '%demo%';

-- Por subquery
UPDATE users SET status = 'banned'
 WHERE id IN (SELECT uid FROM blacklist);

❌ Errores típicos

Mensaje Causa
fila no existe: PK=N el WHERE era pk = N y N no está en la tabla. Solo aplica al fast-path de PK literal; un WHERE compuesto con 0 matches devuelve OK con cuenta 0.
[GBY-4008] UPDATE_PK_NOT_ALLOWED (solo UPSERT DO UPDATE) SET pk = ... dentro de ON CONFLICT DO UPDATE. En el UPDATE regular está permitido desde residual #4 (2026-05-27): se hace re-encode + move físico de la fila y cascade ON UPDATE a las hijas. Errores típicos del nuevo path: [GBY-4073] FK_RESTRICT_BLOCKS_UPDATE (hijos con RESTRICT) y [GBY-4074] FK_UPDATE_CASCADE_AFFECTS_CHILD_PK.
columna duplicada en SET dos asignaciones a la misma columna

Solo los índices cuya columna está en el SET se tocan; los demás no pagan costo. La resolución del WHERE en E3 hace FullScan + filtro 3VL salvo cuando el WHERE es exactamente pk = N (fast-path por PK). La optimización para = col_indexada y IN (SELECT) queda en backlog (correctitud primero). Ver src/sql.rs:exec_update.


DELETE

🛤️ Railroad

flowchart LR
    S([▶]) --> D[DELETE] --> F[FROM] --> T[/table/]
    T --> WH[WHERE] --> WC[where_clause]
    WC --> RET{RETURNING?}
    RET -- "*  /  cols" --> SEMI[";"]
    RET --> SEMI
    SEMI --> E([■])

📜 EBNF

delete  ::= "DELETE" "FROM" identifier "WHERE" where_clause
            returning_clause?

where_clause y returning_clause son los mismos definidos en SELECT e INSERT respectivamente.

Mismo where_clause que SELECT y UPDATE (bloque E3). Borra todas las filas matcheadas en orden de PK ascendente; las FK con ON DELETE CASCADE se aplican fila por fila. El message del response trae la cuenta.

✅ Ejemplos

-- Por PK
DELETE FROM users WHERE id = 5;

-- Por columna indexada (multi-fila)
DELETE FROM logs WHERE level = 'debug';

-- Por subquery
DELETE FROM sessions WHERE user_id IN (SELECT id FROM users WHERE banned = TRUE);

-- Por predicado compuesto
DELETE FROM tickets WHERE status = 'closed' AND updated_at < '2024-01-01';

❌ Errores típicos

Mensaje Causa
fila no existe: PK=N el WHERE era pk = N y N no está. Con WHERE compuesto, 0 matches es OK.
violación de FK: 'X.col' referencia 'Y' (ON DELETE RESTRICT, N fila(s) afectadas) hay filas hijas y la FK fue declarada ON DELETE RESTRICT (default)

Antes de borrar la fila, el engine la lee para evictar la entrada correspondiente de cada índice secundario. Si la tabla tiene FKs entrantes, el motor resuelve cascade/restrict iterativamente con un worklist y cycle protection (visited set sobre (tabla, pk)). Para tablas grandes con FKs entrantes, se recomienda crear un índice secundario sobre la columna FK del hijo — el engine lo usa automáticamente para que el lookup de hijos sea O(log n) en vez de full scan.


TRUNCATE

Bloque J (2026-05-25). Borra todas las filas de la tabla manteniendo el schema (columnas, índices, FKs). Implementación naive: scan de todas las PKs + delete_with_cascade por fila. Respeta las declaraciones ON DELETE CASCADE / RESTRICT de FKs entrantes — no es un O(1) hack como en Postgres/MySQL.

📜 EBNF

truncate ::= "TRUNCATE" ["TABLE"] identifier

✅ Ejemplos

TRUNCATE TABLE logs;          -- borra todo `logs`
TRUNCATE staging;             -- la palabra TABLE es opcional

❌ Errores típicos

Mensaje Causa
tabla no existe: X la tabla no está en el catálogo
violación de FK: ... ON DELETE RESTRICT, N fila(s) afectadas hay filas hijas con FK ON DELETE RESTRICT apuntando a la tabla — no se puede vaciar sin borrar primero los hijos

Transacciones explícitas (BEGIN / COMMIT / ROLLBACK)

Bloque T (2026-05-25). Por defecto cada batch enviado a /exec (HTTP) o cada invocación de gabysql exec (CLI) es una transacción atómica implícita: o se commitean todas las sentencias del batch o ninguna. BEGIN/COMMIT/ROLLBACK permiten ademas abortar el batch a mitad de camino y obtener feedback explícito por sentencia.

📜 EBNF

tcl ::= "BEGIN" ["TRANSACTION" | "WORK"]
      | "START" "TRANSACTION"
      | "COMMIT" ["TRANSACTION" | "WORK"]
      | "END" ["TRANSACTION" | "WORK"]
      | "ROLLBACK" ["TRANSACTION" | "WORK"]
      | "ROLLBACK" "TO" ["SAVEPOINT"] ident                  -- M12
      | "SAVEPOINT" ident                                    -- M12
      | "RELEASE" ["SAVEPOINT"] ident                        -- M12

Sinónimos ANSI aceptados: BEGIN = START TRANSACTION. COMMIT = END. Las palabras TRANSACTION y WORK después de BEGIN/COMMIT/END/ROLLBACK son opcionales. Las palabras SAVEPOINT después de ROLLBACK TO o RELEASE son opcionales (compat con PostgreSQL).

✅ Ejemplos

BEGIN;
  INSERT INTO ledger (id, amount) VALUES (1, 100);
  INSERT INTO ledger (id, amount) VALUES (2, -100);
COMMIT;

-- Aborto a mitad de batch:
BEGIN;
  UPDATE inventory SET stock = stock - 1 WHERE sku = 'ABC';
  -- ... validación adicional falla ...
ROLLBACK;

-- M12: recuperación parcial con SAVEPOINT (ANSI SQL:2003).
BEGIN;
  INSERT INTO usuarios (id, nombre) VALUES (1, 'Ana');
  SAVEPOINT antes_de_imports;
    INSERT INTO usuarios SELECT * FROM csv_dump;  -- falla con PK dup
  ROLLBACK TO SAVEPOINT antes_de_imports;          -- vuelve al checkpoint
  -- Ana sigue intacta. Sigo con otro intento.
  INSERT INTO usuarios (id, nombre) VALUES (2, 'Beto');
  RELEASE SAVEPOINT antes_de_imports;              -- libera el slot (no revierte)
COMMIT;

🧠 Semántica SAVEPOINT (M12)

Ver ADR-0089.

⚠️ Limitaciones

❌ Errores típicos

Mensaje Causa
[GBY-4029] BEGIN: ya hay una transacción explícita abierta dos BEGIN consecutivos sin COMMIT/ROLLBACK intermedio
[GBY-4030] COMMIT/ROLLBACK: no hay transacción explícita activa COMMIT o ROLLBACK sin BEGIN previo

Funciones escalares (bloques G1 + G2 + G3)

Desde el bloque G1 (2026-05-26), el SELECT list acepta expresiones escalares además de columnas crudas y agregadas: funciones built-in (LENGTH, UPPER, CONCAT, …), CAST(x AS TYPE), CASE … END, literales, y los conditionals COALESCE/NULLIF/IFNULL/IF. Cada expresión puede recibir AS alias para nombrar la columna del ResultSet.

El bloque G2 (2026-05-26) extiende esas mismas expresiones a las superficies de filtrado y mutación:

El bloque G3 (2026-05-26) cierra la familia:

Pendientes residuales menores: EXCLUDED.col dentro de ON CONFLICT DO UPDATE SET y unary - prefix sobre expresión (se puede escribir 0 - LENGTH(x)).

NULL propagation: por defecto cualquier argumento NULL hace que la función devuelva NULL. Las excepciones son COALESCE/NULLIF/IFNULL/IF/Now/CurrentDate/CurrentTimestamp (la primera tiene su propio short-circuit, las últimas no tienen args).

📜 EBNF mínima

select_item    = expression [ "AS" ident | ident ] ;
expression     = arith [ cmp_op arith | postfix ] ;
postfix        = "IS" [ "NOT" ] "NULL"
               | [ "NOT" ] "LIKE" string_literal
               | [ "NOT" ] "IN" "(" value { "," value } ")"
               | [ "NOT" ] "BETWEEN" arith "AND" arith ;
arith          = arith_term { ( "+" | "-" | "||" ) arith_term } ;
arith_term     = arith_factor { ( "*" | "/" | "%" ) arith_factor } ;
arith_factor   = primary ;
primary        = literal
               | qualified_ident
               | func_call
               | "CAST" "(" expression "AS" type_name ")"
               | "CASE" [ expression ] ( "WHEN" expression "THEN" expression )+
                 [ "ELSE" expression ] "END"
               | "(" expression ")" ;
func_call      = ident "(" [ expression { "," expression } ] ")"
               | "EXTRACT" "(" extract_field "FROM" expression ")"
               | "CURRENT_DATE" | "CURRENT_TIMESTAMP" ;
extract_field  = "YEAR" | "MONTH" | "DAY" | "HOUR" | "MINUTE" | "SECOND" ;
cmp_op         = "=" | "<>" | "!=" | "<" | "<=" | ">" | ">=" ;
type_name      = "INT" | "FLOAT" | "TEXT" | "BOOL" | "DATE" | "DATETIME" | "JSON" ;

🧮 Operadores aritméticos (bloque G3)

Operador Precedencia Notas
* / % Alta Multiplicación, división, módulo. INT×INT con checked_* → overflow [GBY-4042]. División o módulo por cero → [GBY-4043].
+ - \|\| Baja Suma, resta y concat. \|\| reduce ambos lados a TEXT con la misma regla que CONCAT (NULL propaga). Promoción INT+FLOAT → FLOAT en +/-.

🧰 Funciones soportadas (G1 + G3)

Familia Función Notas
String LENGTH(s) Largo en caracteres (no bytes). Solo TEXT. Aliases: LEN, CHAR_LENGTH.
String UPPER(s) / LOWER(s) Solo TEXT.
String SUBSTR(s, from [, len]) from es 1-based; from <= 0 se trata como 1. Alias: SUBSTRING.
String CONCAT(a, b, …) Convierte cada arg a texto. NULL propaga (ANSI).
String (G3) TRIM(s) / LTRIM(s) / RTRIM(s) Solo TEXT. Strip de whitespace ambos lados / izq / der.
String (G3) REPLACE(s, from, to) Solo TEXT. Reemplazo no-overlap. from = '' deja s sin cambios.
String (G3) SPLIT_PART(s, sep, idx) 1-based; idx <= 0[GBY-4035]; fuera de rango → ''.
Numéricas ABS(x) INT o FLOAT.
Numéricas ROUND(x) / ROUND(x, n) INT pasa tal cual; FLOAT redondea al entero o a n decimales.
Numéricas (G3) CEIL(x) / CEILING(x) / FLOOR(x) INT pasa tal cual; FLOAT aplica .ceil() / .floor().
Numéricas (G3) MOD(a, b) Mismo semántica que el operador %. Cero → [GBY-4043].
Numéricas (G3) POWER(x, y) / POW(x, y) Devuelve FLOAT. POWER(0, y<0)[GBY-4045].
Numéricas (G3) SQRT(x) Devuelve FLOAT. Negativo → [GBY-4045].
Fecha / hora NOW() / CURRENT_TIMESTAMP UTC, formato YYYY-MM-DD HH:MM:SS como TEXT.
Fecha / hora CURRENT_DATE UTC, formato YYYY-MM-DD como TEXT. Alias: CURDATE.
Fecha / hora (G3) DATE_ADD(d, n) / DATE_SUB(d, n) d es DATE o DATETIME; suma/resta n días al date-part, preservando time-part en DATETIME.
Fecha / hora (G3) DATEDIFF(d1, d2) Días entre d1 y d2 (d1 - d2), usando solo date-part.
Fecha / hora (G3) EXTRACT(field FROM d) field: YEAR/MONTH/DAY/HOUR/MINUTE/SECOND. Sintaxis especial (no es coma).
Fecha / hora (G3) STRFTIME(fmt, d) Placeholders mínimos: %Y %m %d %H %M %S %%. Otros %X pasan literal.
Conversión CAST(x AS TYPE) Tipos: INT, FLOAT, TEXT, BOOL, DATE, DATETIME, JSON. Errores → [GBY-4036].
Condicional COALESCE(a, b, …) Primer argumento no-NULL. Todos NULL → NULL.
Condicional NULLIF(a, b) NULL si a = b, sino a.
Condicional IFNULL(a, b) a si no-NULL, sino b.
Condicional IF(cond, a, b) cond debe ser BOOL. Alias: IIF.
Condicional CASE WHEN cond THEN val [...] [ELSE val] END Searched form: cond debe evaluar a BOOL (NULL = no-match).
Condicional CASE expr WHEN x THEN val [...] [ELSE val] END Simple form: matchea x contra expr por igualdad ANSI (NULL ≠ NULL).

✅ Ejemplos

SELECT id, UPPER(name) AS n FROM users WHERE id = 1;

SELECT
  CASE WHEN score >= 90 THEN 'A'
       WHEN score >= 75 THEN 'B'
       ELSE 'C' END AS grade
FROM exams;

SELECT COALESCE(nickname, name, 'anónimo') FROM users;

SELECT CAST(price AS TEXT) || '?' FROM products; -- G3: `||` soportado
SELECT CONCAT(CAST(price AS TEXT), '?') FROM products; -- forma equivalente

-- G2: expresiones en WHERE, HAVING, UPDATE SET
SELECT id FROM users WHERE LENGTH(name) > 3;
SELECT id FROM users WHERE UPPER(name) = 'ANA';
SELECT id FROM users WHERE COALESCE(active, false) = true;
SELECT id FROM users WHERE CASE WHEN age > 18 THEN true ELSE false END = true;

SELECT g, COUNT(*) FROM t GROUP BY g HAVING UPPER(g) = 'X';

UPDATE users SET name = UPPER(name) WHERE id = 1;
UPDATE users SET descr = COALESCE(descr, 'sin descr') WHERE id = 2;
UPDATE users SET tier = CASE WHEN age >= 18 THEN 'adult' ELSE 'minor' END;
DELETE FROM users WHERE LENGTH(name) = 0;

-- G3: aritméticos, concat, postfix Expr y funciones P2/P3
SELECT precio * cantidad AS total FROM ventas;
SELECT id FROM ventas WHERE precio * 1.21 > 1000;
UPDATE ventas SET contador = contador + 1 WHERE id = 1;
SELECT nombre || ' ' || apellido AS fullname FROM users;
SELECT id FROM users WHERE LENGTH(name) IS NULL;
SELECT id FROM users WHERE UPPER(name) LIKE 'A%';
SELECT id FROM users WHERE LENGTH(name) IN (3, 4, 5);
SELECT id FROM users WHERE LENGTH(name) BETWEEN 3 AND 10;
SELECT TRIM('  hola  '), REPLACE('a-b-c', '-', '_'), SPLIT_PART('a-b-c', '-', 2);
SELECT CEIL(1.2), FLOOR(1.8), MOD(10, 3), POWER(2, 10), SQRT(16);
SELECT DATE_ADD('2026-01-01', 31), DATEDIFF('2026-12-31', '2026-01-01');
SELECT EXTRACT(YEAR FROM '2026-05-26'), STRFTIME('%Y-%m', '2026-05-26');

❌ Errores típicos

Error Causa
[GBY-4034] LENGTH: cantidad incorrecta de argumentos función llamada con la aridad equivocada (e.g. LENGTH()).
[GBY-4035] LENGTH requiere TEXT, recibí INT argumento de un tipo no aceptado por la función.
[GBY-4036] CAST('xyz' AS INT): no es un entero válido conversión imposible al tipo destino.
[GBY-4037] función escalar desconocida: 'FOO' nombre no presente en la lista soportada.
[GBY-4038] CASE WHEN: la condición debe ser BOOL, recibí INT CASE WHEN x THEN … con x no booleano.
[GBY-4039] EXPR_IN_PREDICATE_NOT_SUPPORTED G2 (cerrado por G3): postfix sobre Expr ahora funciona; el código queda reservado y sin emisión activa.
[GBY-4040] expresión en WHERE/HAVING debe evaluar a BOOL (o NULL) G2: predicado expresional sin comparador (WHERE LENGTH(x)) — falta >/=/etc.
[GBY-4041] UPDATE sobre 't': el valor calculado para 'col' es TEXT y la columna es INT G2: la RHS de un SET col = <expr> rinde un tipo incompatible — envolver con CAST(... AS T).
[GBY-4042] overflow aritmético en INT: 9223372036854775807 + 1 G3: operación entera con overflow — promover a FLOAT con CAST.
[GBY-4043] división entera por cero G3: divisor cero en / o %. Usar NULLIF(div, 0) o pre-filtrar.
[GBY-4044] operador '+' no acepta operandos TEXT y INT G3: operador aritmético sobre tipos incompatibles. ¿Quisiste decir \|\|?
[GBY-4045] SQRT(-1) indefinido en reales (argumento negativo) G3: función matemática fuera del dominio real.
[GBY-4046] DATE_ADD: '2026-13-01' no es DATE ni DATETIME válido G3: TEXT no parseable como fecha en una función de fecha.
[GBY-4047] EXTRACT: campo 'CENTURY' no soportado G3: EXTRACT(<campo> FROM ...) con campo no permitido.

Subqueries y derived tables (bloque H)

Bloque H (2026-05-26) cierra los P0+P1 de subqueries: derived tables, NOT IN (SELECT), subquery escalar en SELECT list, y multi-predicate correlated EXISTS dentro de AND/OR/NOT.

📜 EBNF

from_source   := tabla_ident [ alias ]
               | "(" subquery ")" alias       (* derived table — alias OBLIGATORIO *)

subquery      := "SELECT" select_stmt

scalar_subq   := "(" subquery ")"             (* dentro de Expr *)

where_atom    := … (formas pre-H) …
               | columna [ "NOT" ] "IN" "(" subquery ")"   (* H *)
               | "EXISTS" "(" subquery ")"                  (* puede ir dentro de AND/OR/NOT — H *)
               | "NOT" "EXISTS" "(" subquery ")"            (* idem *)

expr_primary  := … (formas pre-H) …
               | scalar_subq                  (* H *)

✅ Ejemplos

-- Derived table: lista de cursos con cantidad de alumnos.
SELECT sub.curso_id, sub.total
FROM (SELECT curso_id, COUNT(*) AS total FROM alumnos GROUP BY curso_id) AS sub
ORDER BY sub.total DESC;

-- Derived table joineada con una tabla persistente.
SELECT cursos.nivel, sub.total
FROM cursos
INNER JOIN (SELECT curso_id, COUNT(*) AS total FROM alumnos GROUP BY curso_id) AS sub
  ON cursos.id = sub.curso_id;

-- NOT IN con 3VL ANSI estricta.
SELECT id FROM cursos
WHERE id NOT IN (SELECT curso_id FROM alumnos WHERE edad = 19);

-- Subquery escalar en SELECT list (correlated).
SELECT cursos.id,
       (SELECT COUNT(*) FROM alumnos WHERE alumnos.curso_id = cursos.id) AS cnt
FROM cursos;

-- EXISTS correlacionado combinado con otro predicado.
SELECT id FROM cursos
WHERE EXISTS (SELECT 1 FROM alumnos WHERE alumnos.curso_id = cursos.id)
  AND id = 1;

⚠️ Reglas y limitaciones

❌ Errores típicos

Mensaje Por qué
[GBY-4048] derived table (SELECT ...) requiere un alias obligatorio El parser vio FROM (SELECT ...) sin alias.
[GBY-4049] derived table 'x' proyecta dos columnas con el mismo nombre 'id' La subquery devuelve nombres duplicados. Aliasear: SELECT a AS x, b AS y.
[GBY-4014] subquery escalar … devolvió 5 filas Subquery en SELECT list o en WHERE = (SELECT ...) con más de una fila.
[GBY-4011] subquery … debe devolver exactamente 1 columna La subquery escalar/IN devuelve múltiples columnas.

Set operations y VALUES (bloque I)

Operaciones de conjunto entre queries (UNION / INTERSECT / EXCEPT) con su variante ALL, alias MINUS de Oracle, y VALUES usable como query standalone o como tabla virtual dentro del FROM.

📜 EBNF

select_query    ::= select_term { ( "UNION" | "EXCEPT" | "MINUS" ) [ "ALL" ] intersect_term }*
                    [ "ORDER" "BY" ident [ "ASC" | "DESC" ] ]
                    [ "LIMIT" int ] [ "OFFSET" int ]
intersect_term  ::= select_term { "INTERSECT" [ "ALL" ] select_term }*
select_term     ::= select_stmt
                  | values_stmt
                  | "(" select_query ")"
values_stmt     ::= "VALUES" row { "," row }*
row             ::= "(" expr { "," expr }* ")"

🪜 Precedencia (ANSI)

  1. INTERSECT (más alta — ata más fuerte).
  2. UNION y EXCEPT / MINUS (misma precedencia, asociativos a izquierda).

A UNION B INTERSECT C se parsea como A UNION (B INTERSECT C). Para forzar otro orden, usar paréntesis.

🧮 Semántica de multisets

🪧 Compatibilidad columna a columna

✅ Ejemplos

-- Unión simple con dedup
SELECT id FROM a UNION SELECT id FROM b ORDER BY id ASC;

-- Unión preservando duplicados
SELECT nombre FROM clientes_2024 UNION ALL SELECT nombre FROM clientes_2025;

-- Intersección + ORDER BY al nivel del resultado
(SELECT id FROM activos) INTERSECT (SELECT id FROM premium) ORDER BY id DESC LIMIT 10;

-- Diferencia con alias MINUS
SELECT id FROM a MINUS SELECT id FROM b;

-- VALUES standalone (devuelve ResultSet con headers column1, column2, ...)
VALUES (1, 'a'), (2, 'b'), (3, 'c');

-- VALUES como tabla virtual en FROM
SELECT id, name FROM (VALUES (1, 'a'), (2, 'b')) AS t(id, name) ORDER BY id ASC;

-- JOIN entre persistente y VALUES virtual
SELECT a.id, t.tag
FROM a INNER JOIN (VALUES (1, 'uno'), (3, 'tres')) AS t(id, tag) ON a.id = t.id;

-- Precedencia: INTERSECT ata más fuerte que UNION
-- ( == A UNION (B INTERSECT C) )
VALUES (1), (2) UNION VALUES (3), (4) INTERSECT VALUES (4), (5);
-- → 1, 2, 4

❌ Errores típicos

Mensaje Causa
[GBY-4054] UNION entre queries con N y M columnas Distinta arity entre LHS y RHS.
[GBY-4055] UNION: la columna K del LHS es Int y la del RHS es Text Tipos incompatibles (sin promoción INT↔FLOAT).
[GBY-4052] VALUES en FROM requiere alias de tabla obligatorio Falta AS t(c1, c2, ...) tras (VALUES ...).
[GBY-4053] lista de aliases de columna tiene X entradas pero las filas de VALUES tienen Y Mismatch entre t(c1, c2) y la arity de las tuplas.
[GBY-4056] VALUES: fila K tiene N expresiones pero la fila 1 tiene M Dos filas del VALUES con distinta arity.
[GBY-4057] VALUES requiere al menos una fila VALUES; sin tuplas.

⚠️ No soportado todavía


CTEs no-recursivas (WITH, bloque W1)

Common Table Expressions: dar nombre a una subquery para reusarla en FROM / JOIN / subqueries (IN, EXISTS, scalar) y en cualquiera de las ramas de un set op. Múltiples CTEs encadenables — b puede referenciar a declarada antes.

📜 EBNF

with_query    ::= "WITH" cte_def { "," cte_def }* select_query
cte_def       ::= ident "AS" "(" select_stmt ")"

🧠 Semántica

✅ Ejemplos

-- CTE simple
WITH seniors AS (SELECT id, nombre FROM empleados WHERE salario >= 100)
SELECT nombre FROM seniors ORDER BY nombre;

-- Encadenamiento
WITH ventas_2025 AS (SELECT * FROM ventas WHERE anio = 2025),
     top_clientes AS (SELECT cliente_id, SUM(monto) AS total
                      FROM ventas_2025 GROUP BY cliente_id)
SELECT cliente_id FROM top_clientes WHERE total >= 10000;

-- CTE en JOIN
WITH big AS (SELECT uid FROM orders WHERE total >= 100)
SELECT u.name FROM users u INNER JOIN big ON u.id = big.uid;

-- CTE en subquery
WITH high AS (SELECT id FROM t WHERE v >= 20)
SELECT id FROM t WHERE id IN (SELECT id FROM high);

-- CTE visible desde ambas ramas de un UNION
WITH x AS (SELECT id FROM t WHERE id = 1)
SELECT id FROM x UNION SELECT id FROM t WHERE id = 2 ORDER BY id;

❌ Errores típicos

Mensaje Causa
[GBY-4079] WITH: el nombre 'X' aparece más de una vez Dos CTEs con el mismo nombre en el mismo WITH (case-insensitive).
[GBY-4081] WITH cte(...) AS (...): los column aliases en la cabecera están diferidos Column aliases en la cabecera de la CTE. Workaround: aliasar dentro del body (SELECT x AS c1, y AS c2 FROM ...).

⚠️ No soportado todavía


CTEs recursivas (WITH RECURSIVE, bloque W2)

Common Table Expressions recursivas: el body se materializa por fixpoint base+step. Soporta exactamente UNA CTE recursive por statement, con body canónico anchor UNION [ALL] step. Casos típicos: generadores de números, traversal de jerarquías (descendientes / ancestros), expansión transitiva.

📜 EBNF

recursive_query  ::= "WITH" "RECURSIVE" ident "AS" "(" recursive_body ")" select_query
recursive_body   ::= select_stmt "UNION" [ "ALL" ] select_stmt

El primer SELECT es el anchor (caso base — no puede referenciar la CTE). El segundo es el step (caso recursivo — debe referenciar la CTE por nombre en algún FROM).

🧠 Semántica (delta semantics, ANSI)

✅ Ejemplos

-- Generador 1..100
WITH RECURSIVE nums AS (
    SELECT 1 AS n
    UNION ALL
    SELECT n + 1 FROM nums WHERE n < 100
)
SELECT n FROM nums;

-- Descendientes de un nodo en un árbol (id, parent)
WITH RECURSIVE descendants AS (
    SELECT id FROM tree WHERE id = :root_id
    UNION ALL
    SELECT t.id FROM tree t INNER JOIN descendants d ON t.parent = d.id
)
SELECT id FROM descendants ORDER BY id;

-- UNION (no ALL) — termina natural por dedup
WITH RECURSIVE r AS (
    SELECT 1 AS n
    UNION
    SELECT 1 FROM r  -- step produce SIEMPRE 1; dedup ⇒ delta vacío ⇒ stop
)
SELECT n FROM r;

-- JOIN entre la CTE recursive y una tabla persistente
WITH RECURSIVE nums AS (
    SELECT 1 AS n UNION ALL SELECT n + 1 FROM nums WHERE n < 5
)
SELECT l.lab FROM nums INNER JOIN labels l ON l.n = nums.n ORDER BY l.lab;

❌ Errores típicos

Mensaje Causa
[GBY-4082] WITH RECURSIVE: soportamos exactamente UNA CTE recursive por statement Más de una CTE dentro del mismo WITH RECURSIVE. Workaround: anidar.
[GBY-4083] WITH RECURSIVE r: superó el límite de 1000 iteraciones Recursión sin condición de corte. Agregar WHERE n < N al step, o usar UNION (no UNION ALL) para dedup.
[GBY-4084] WITH RECURSIVE r: superó el límite de 100000 filas Mismo diagnóstico.
[GBY-4085] WITH RECURSIVE r: el step proyecta N columnas pero el anchor proyectó M Schema inconsistente — ANSI exige arity posicional idéntica.
[GBY-4086] WITH RECURSIVE r: el body debe ser anchor UNION [ALL] step Body que no es la forma canónica. Si no necesita ser recursive, quitar la palabra RECURSIVE.

⚠️ No soportado todavía


Window functions (bloque W3)

Funciones que computan un valor por fila usando OTRAS filas de la misma partition — clásicas: enumerar dentro de un grupo (ROW_NUMBER), rankear (RANK/DENSE_RANK), totales corridos (SUM(x) OVER (ORDER BY d)), mirar fila anterior/siguiente (LAG/LEAD), partir en buckets (NTILE), valores extremos de la partition (FIRST_VALUE/LAST_VALUE).

📜 EBNF

window_call    ::= window_func "(" [ expr {"," expr}* ] ")"
                   "OVER" "(" [ "PARTITION" "BY" expr {"," expr}* ]
                              [ "ORDER" "BY" expr [ "ASC" | "DESC" ]
                                              {"," expr [ "ASC" | "DESC" ]}* ] ")"
window_func    ::= "ROW_NUMBER" | "RANK" | "DENSE_RANK" | "NTILE"
                 | "COUNT" | "SUM" | "AVG" | "MIN" | "MAX"
                 | "LAG" | "LEAD" | "FIRST_VALUE" | "LAST_VALUE"

🧠 Defaults de frame (sin spec explícita — ROWS BETWEEN ... no soportado)

Familia Con ORDER BY Sin ORDER BY
Ranking (ROW_NUMBER/RANK/DENSE_RANK/NTILE) per-row rank arbitrario (no determinístico)
Aggregate (SUM/COUNT/AVG/MIN/MAX) running (acumula hasta CURRENT ROW) full partition
LAG/LEAD offset hacia atrás/adelante [GBY-4088] requerido
NTILE(n) distribución balanceada [GBY-4088] requerido
FIRST_VALUE primera fila de la partition ordenada primera del orden source
LAST_VALUE última de la partition (no CURRENT ROW; deviation de ANSI) última del orden source

✅ Ejemplos

-- Numerar dentro de cada región por salario descendente
SELECT id, region, ROW_NUMBER() OVER (PARTITION BY region ORDER BY salary DESC) AS rk
FROM employees;

-- Rankear con ties (RANK salta, DENSE_RANK no)
SELECT id, score,
       RANK() OVER (ORDER BY score DESC) AS r,
       DENSE_RANK() OVER (ORDER BY score DESC) AS dr
FROM students;

-- Total corrido por fecha
SELECT date, amount, SUM(amount) OVER (ORDER BY date) AS running_total
FROM tx;

-- Total por región (full partition, sin ORDER BY)
SELECT id, region, SUM(amount) OVER (PARTITION BY region) AS region_total
FROM sales;

-- Mirar fila anterior con default
SELECT date, price, LAG(price, 1, 0) OVER (ORDER BY date) AS prev_or_zero
FROM ticks;

-- Partir en quartiles
SELECT id, score, NTILE(4) OVER (ORDER BY score DESC) AS quartile
FROM students;

❌ Errores típicos

Mensaje Causa
[GBY-4088] LAG: requiere ORDER BY dentro del OVER LAG/LEAD/NTILE sin ORDER BY. También: nombre always-window sin OVER.
[GBY-4089] ROW_NUMBER: arity esperada [0, 0], recibí 1 Arity incorrecta.
[GBY-4090] window functions mezcladas con GROUP BY ... Mezcla con GROUP BY/HAVING/agregado clásico en el mismo SELECT. Workaround: derived table sobre el GROUP BY.
[GBY-4091] RETURNING no admite window functions Window fuera de SELECT list top-level.

⚠️ No soportado todavía


Triggers (bloques X1 + X2)

CREATE TRIGGER ... {BEFORE|AFTER} ... FOR EACH ROW <body>: ejecuta un body DML antes o después de cada fila afectada por un INSERT/UPDATE/DELETE. Body puede ser una sola sentencia (X1) o un bloque BEGIN ... END con múltiples sentencias (X2). Persistido en el catálogo (bump VERSION 13 → 14 en X1; X2 sin bump).

📜 EBNF

create_trigger ::= "CREATE" "TRIGGER" ident ("BEFORE"|"AFTER") ("INSERT"|"UPDATE"|"DELETE")
                   "ON" ident "FOR" "EACH" "ROW" trigger_body
trigger_body   ::= dml_stmt
                 | "BEGIN" dml_stmt {";" dml_stmt}* [";"] "END"
drop_trigger   ::= "DROP" "TRIGGER" ["IF" "EXISTS"] ident
dml_stmt       ::= insert_stmt | update_stmt | delete_stmt | replace_stmt

🧠 Referencias NEW.col / OLD.col

Evento NEW disponible OLD disponible
INSERT ✅ (fila recién insertada) ❌ → [GBY-4094]
UPDATE ✅ (fila post-update) ✅ (fila pre-update)
DELETE ❌ → [GBY-4094] ✅ (fila recién borrada)

Las referencias se substituyen a nivel de TOKEN antes de re-parsear el body — funciona en cualquier contexto (INSERT VALUES, UPDATE SET, WHERE, etc.).

✅ Ejemplos

-- Auditoría simple
CREATE TABLE audit (id INT PRIMARY KEY, action TEXT, who INT);
CREATE TRIGGER audit_user_insert AFTER INSERT ON users
    FOR EACH ROW INSERT INTO audit (id, action, who)
                 VALUES (NEW.id, 'inserted', NEW.id);

-- Log de cambios con NEW y OLD
CREATE TRIGGER log_price_change AFTER UPDATE ON products
    FOR EACH ROW INSERT INTO price_log (id, old_price, new_price)
                 VALUES (NEW.id, OLD.price, NEW.price);

-- Tombstone table
CREATE TRIGGER tomb AFTER DELETE ON items
    FOR EACH ROW INSERT INTO removed (id, name) VALUES (OLD.id, OLD.name);

-- Borrado
DROP TRIGGER IF EXISTS audit_user_insert;

❌ Errores típicos

Mensaje Causa
[GBY-4092] CREATE TRIGGER '...': ya existe un objeto ... Colisión de nombre con tabla / vista / trigger.
[GBY-4093] el body debe arrancar con INSERT, UPDATE, DELETE o REPLACE (también body inválido por otras razones) Body con shape no soportado. Históricamente este mismo código bloqueaba BEFORE triggers en X1 (pre-X2); BEFORE está soportado desde X2 (2026-05-28).
[GBY-4093] el body debe arrancar con INSERT, UPDATE, DELETE o REPLACE Body es SELECT u otra cosa.
[GBY-4094] trigger 'X': NEW.y no es una columna válida Referencia a columna inexistente o NEW/OLD en evento incompatible.
[GBY-4095] cascada de triggers excedió la profundidad 16 Recursión runaway (un trigger dispara otro DML que dispara más triggers).
[GBY-4096] DROP TRIGGER '...': no existe Nombre desconocido sin IF EXISTS.

🧠 BEFORE vs AFTER

Aspecto BEFORE AFTER
Cuándo corre Antes del write Después del write
NEW construido INSERT: user-stated + NULL; UPDATE: OLD + assignments El row tal cual quedó persistido
NEW mutable ❌ (X2 read-only)
Aborta el DML principal si rebota ✅ (no se escribe nada) ✅ (lo escrito + el trigger se rollback-ean)

🧠 Body BEGIN ... END (X2)

Las sentencias dentro del block se separan con ; y se ejecutan en orden. Si alguna falla, las restantes NO se ejecutan y el error se propaga al DML principal — toda la transacción rollback. BEGIN ... END puede anidarse aunque no agrega expresividad en X2 (no hay scope ni control de flujo).

⚠️ No soportado (estado al 2026-06-15)

Entregado posteriormente (notar que esta sección estaba escrita para X1/X2):

Sigue pendiente:


Stored procedures (bloque X3)

CREATE PROCEDURE name(p1 TYPE, ...) AS <body> + CALL name(args) + DROP PROCEDURE: encapsular side effects parametrizados. Body es DML simple o BEGIN ... END multi-stmt. CALL es un statement standalone — no se puede usar como expresión (funciones invocables en SELECT llegan en X3b). Persistido en catálogo (bump VERSION 14 → 15).

📜 EBNF

create_proc  ::= "CREATE" "PROCEDURE" ident "(" [param {"," param}*] ")"
                 "AS" proc_body
param        ::= ident type_name
proc_body    ::= dml_stmt
               | "BEGIN" dml_stmt {";" dml_stmt}* [";"] "END"
drop_proc    ::= "DROP" "PROCEDURE" ["IF" "EXISTS"] ident
call_stmt    ::= "CALL" ident "(" [expr {"," expr}*] ")"

✅ Ejemplos

-- Procedure simple
CREATE PROCEDURE log_msg(p_id INT, p_msg TEXT) AS
    INSERT INTO log (id, msg) VALUES (p_id, p_msg);

CALL log_msg(42, 'hello');

-- Body multi-statement
CREATE PROCEDURE log_both(p_id INT) AS BEGIN
    INSERT INTO log_a (id) VALUES (p_id);
    INSERT INTO log_b (id) VALUES (p_id);
END;

CALL log_both(99);

-- Args como expresiones
CALL log_msg(10 + 5, UPPER('abc'));

-- Cleanup
DROP PROCEDURE IF EXISTS log_msg;

⚠️ Limitación conocida — choque param/columna

Si una columna tiene el mismo nombre que un parámetro, el ident de la columna también se substituye (token-level) y la query rompe. Workaround: prefijar param names (p_id, arg_name) — convención estándar PG.

-- ❌ choque: `id` aparece como param Y como columna
CREATE PROCEDURE add_log(id INT) AS INSERT INTO log (id) VALUES (id);

-- ✅
CREATE PROCEDURE add_log(p_id INT) AS INSERT INTO log (id) VALUES (p_id);

❌ Errores típicos

Mensaje Causa
[GBY-4097] CREATE PROCEDURE '...': ya existe un objeto ... Colisión de nombre con tabla / vista / trigger / procedure.
[GBY-4098] el body debe arrancar con INSERT, UPDATE, DELETE, REPLACE o BEGIN Body no válido o BEGIN sin END.
[GBY-4099] CALL '...': procedure no existe También DROP PROCEDURE sin IF EXISTS.
[GBY-4100] CALL '...': esperaba N args, recibí M Arity mismatch.

⚠️ No soportado todavía


User-defined functions (bloque X3b)

CREATE FUNCTION name(p1 TYPE, ...) RETURNS TYPE AS <expr> + DROP FUNCTION: funciones escalares invocables desde cualquier expresión (SELECT, WHERE, HAVING, ORDER BY, body de otra function…). Body es UNA expresión (no SELECT — desviación práctica de ANSI). Persistido en catálogo (bump VERSION 15 → 16).

📜 EBNF

create_function ::= "CREATE" "FUNCTION" ident "(" [param {"," param}*] ")"
                    "RETURNS" type_name "AS" expr
drop_function   ::= "DROP" "FUNCTION" ["IF" "EXISTS"] ident

✅ Ejemplos

-- Doble
CREATE FUNCTION dbl(p_x INT) RETURNS INT AS p_x * 2;
SELECT id, dbl(v) AS doubled FROM t;

-- Saludar con CONCAT
CREATE FUNCTION greet(p_name TEXT) RETURNS TEXT AS CONCAT('Hi ', p_name);
SELECT greet(name) FROM users;

-- Predicado en WHERE
CREATE FUNCTION big(p_x INT) RETURNS BOOL AS p_x >= 100;
SELECT * FROM t WHERE big(v);

-- Composición
CREATE FUNCTION quad(p_x INT) RETURNS INT AS dbl(dbl(p_x));
SELECT quad(v) FROM t;  -- = dbl(dbl(v)) = v * 4

-- Cleanup
DROP FUNCTION IF EXISTS dbl;

⚠️ Restricciones

❌ Errores típicos

Mensaje Causa
[GBY-4101] CREATE FUNCTION '...': ya existe un objeto ... Colisión de nombre.
[GBY-4102] body vacío AS sin expr siguiente, params duplicados.
[GBY-4103] función '...' no existe Invocación a function inexistente; también DROP sin IF EXISTS.
[GBY-4104] '...': esperaba N args, recibí M Arity mismatch.

⚠️ No soportado todavía


Control de flujo IF (bloque X4)

IF expr THEN <stmts> [ELSIF expr THEN <stmts>]* [ELSE <stmts>] END IF: control de flujo como statement top-level. Funciona en cualquier contexto donde quepa un statement — batches SQL planos, bodies de trigger / procedure, branches de otro IF (anidado).

📜 EBNF

if_stmt    ::= "IF" expr "THEN" stmt_list
               { "ELSIF" expr "THEN" stmt_list }*
               [ "ELSE" stmt_list ]
               "END" "IF"
stmt_list  ::= stmt {";" stmt}* [";"]

🧠 Semántica

✅ Ejemplos

-- Top-level
IF (SELECT COUNT(*) FROM orders) > 1000 THEN
    INSERT INTO alerts (msg) VALUES ('alta carga');
END IF;

-- Chain ELSIF
IF total >= 1000 THEN INSERT INTO platinum VALUES (id);
ELSIF total >= 100 THEN INSERT INTO gold VALUES (id);
ELSIF total >= 10  THEN INSERT INTO silver VALUES (id);
ELSE INSERT INTO bronze VALUES (id);
END IF;

-- Dentro de trigger
CREATE TRIGGER classify AFTER INSERT ON t FOR EACH ROW BEGIN
    IF NEW.v >= 100 THEN INSERT INTO big VALUES (NEW.id);
    ELSE INSERT INTO small VALUES (NEW.id);
    END IF;
END;

-- Dentro de procedure
CREATE PROCEDURE classify(p_id INT, p_v INT) AS BEGIN
    IF p_v >= 100 THEN INSERT INTO log (id, label) VALUES (p_id, 'big');
    ELSE INSERT INTO log (id, label) VALUES (p_id, 'small');
    END IF;
END;

-- Anidado
IF cond1 THEN
    IF cond2 THEN INSERT INTO log VALUES (1); END IF;
END IF;

❌ Errores típicos

Mensaje Causa
[GBY-4105] IF: la condición debe evaluar a BOOL ... IF 42 THEN ... o similar. Usar comparación / IS NULL / función que devuelva BOOL.
[GBY-4106] IF: falta END IF para cerrar el bloque Falta END IF matching.
[GBY-4106] IF: se esperaba THEN después de la condición Falta THEN.
[GBY-4106] EOF antes de END IF El input se cortó dentro de un bloque IF abierto.

⚠️ No soportado todavía


Variables + WHILE LOOP (bloque X4b)

DECLARE + SET + WHILE LOOP + EXIT [WHEN]: variables locales y loops. Top-level y dentro de bodies de trigger/procedure. Scope plano (limitación X4b — no nested scope por BEGIN..END).

📜 EBNF

declare_stmt  ::= "DECLARE" ident type_name [ "DEFAULT" expr ]
set_stmt      ::= "SET" ident "=" expr
while_stmt    ::= "WHILE" expr "LOOP" stmt_list "END" "LOOP"
exit_stmt     ::= "EXIT" [ "WHEN" expr ]

🧠 Semántica

⚠️ Limitación importante: variables NO en INSERT VALUES

DECLARE n INT DEFAULT 5;
-- ❌ falla — el parser de VALUES exige Value literal, no Expr
INSERT INTO t VALUES (n);

Workarounds:

-- ✅ INSERT SELECT acepta Expr
INSERT INTO t SELECT n FROM (VALUES (1)) AS dummy;

-- ✅ UPDATE SET acepta Expr (vars OK en RHS)
UPDATE t SET col = n WHERE id = 1;

-- ✅ En procedure: usar params (se substituyen a literal en CALL)
CREATE PROCEDURE foo(p_n INT) AS INSERT INTO t VALUES (p_n);

✅ Ejemplos

-- Counter loop
DECLARE i INT DEFAULT 0;
WHILE i < 10 LOOP
    SET i = i + 1;
END LOOP;

-- Loop con EXIT WHEN
DECLARE counter INT DEFAULT 0;
WHILE TRUE LOOP
    SET counter = counter + 1;
    EXIT WHEN counter >= 42;
END LOOP;

-- Loop con IF + EXIT incondicional
DECLARE found INT DEFAULT 0;
WHILE found = 0 LOOP
    SET found = (SELECT COUNT(*) FROM t WHERE flag = 1);
    IF found > 0 THEN EXIT; END IF;
    -- ... resto del trabajo
END LOOP;

-- En procedure body
CREATE PROCEDURE process(p_max INT) AS BEGIN
    DECLARE i INT DEFAULT 0;
    WHILE i < p_max LOOP
        UPDATE counters SET v = v + 1 WHERE id = 1;
        SET i = i + 1;
    END LOOP;
END;

❌ Errores típicos

Mensaje Causa
[GBY-4107] SET '...': variable no declarada Falta DECLARE previo.
[GBY-4108] DECLARE '...': ya declarada en el scope Redeclare. PG permite shadowing en sub-blocks; X4b scope plano no.
[GBY-4109] WHILE LOOP: superó el límite de 100000 iter Condición no converge. Agregar EXIT WHEN o revisar SET.

⚠️ No soportado al cierre de X4b (estado de la sub-sección X4b — desde 2026-06-15 muchos están entregados, ver bloques X4c/X4d/X4e/X4f/X5/X6 abajo)

Entregado posteriormente:

Sigue pendiente:


RAISE + FOR LOOP (bloque X4c)

RAISE [EXCEPTION|NOTICE] 'msg': aborto explícito con mensaje (EXCEPTION) o info logging (NOTICE). FOR ident IN start TO end LOOP <body> END LOOP: range loop con auto-declaración de la variable de iteración.

📜 EBNF

raise_stmt ::= "RAISE" [ "EXCEPTION" | "NOTICE" ] string_literal
for_stmt   ::= "FOR" ident "IN" expr "TO" expr "LOOP" stmt_list "END" "LOOP"

🧠 Semántica

RAISE:

FOR:

Sintaxis non-PG: PG usa FOR i IN 1..10 LOOP. gabysql usa start TO end para evitar ambigüedad con qualifier ident (tabla.col).

✅ Ejemplos

-- RAISE en validación
CREATE TRIGGER validate AFTER INSERT ON orders FOR EACH ROW BEGIN
    IF NEW.amount < 0 THEN
        RAISE EXCEPTION 'negative amount not allowed';
    END IF;
END;

-- NOTICE para logging
RAISE NOTICE 'job started';

-- FOR loop básico
FOR i IN 1 TO 10 LOOP
    INSERT INTO log SELECT i FROM (VALUES (1)) AS x;
END LOOP;

-- FOR + EXIT WHEN
FOR i IN 1 TO 100 LOOP
    EXIT WHEN i = 5;
END LOOP;

-- FOR en procedure
CREATE PROCEDURE process_n(p_max INT) AS BEGIN
    DECLARE total INT DEFAULT 0;
    FOR i IN 1 TO p_max LOOP
        SET total = total + i;
    END LOOP;
END;

❌ Errores típicos

Mensaje Causa
[GBY-4111] <msg-del-user> RAISE EXCEPTION 'msg' disparada por el código.
[GBY-4112] RAISE: se esperaba un literal STRING RAISE con ident o número en lugar de string.
[GBY-4113] FOR ...: start/end debe ser INT bounds non-INT.

⚠️ No soportado todavía


EXCEPTION handlers + LOOP standalone (bloque X4d)

BEGIN <body> [EXCEPTION WHEN OTHERS THEN <handler>] END: try/catch con catch-all (X4d sólo soporta WHEN OTHERS). LOOP <body> END LOOP standalone: infinite hasta EXIT o MAX_LOOP_ITERATIONS.

📜 EBNF

block_stmt ::= "BEGIN" stmt_list [ "EXCEPTION" "WHEN" "OTHERS" "THEN" stmt_list ] "END"
loop_stmt  ::= "LOOP" stmt_list "END" "LOOP"

🧠 Semántica

BEGIN..END:

LOOP standalone:

✅ Ejemplos

-- Try/catch básico
BEGIN
    INSERT INTO t (id) VALUES (1);
EXCEPTION WHEN OTHERS THEN
    INSERT INTO log VALUES (now(), 'failed');
END;

-- LOOP standalone
DECLARE i INT DEFAULT 0;
LOOP
    SET i = i + 1;
    EXIT WHEN i = 100;
END LOOP;

-- EXCEPTION en trigger body (no aborta el INSERT principal)
CREATE TRIGGER safe AFTER INSERT ON orders FOR EACH ROW BEGIN
    BEGIN
        UPDATE counters SET n = n + 1 WHERE id = NEW.id;
    EXCEPTION WHEN OTHERS THEN
        INSERT INTO err_log VALUES (NEW.id);
    END;
END;

-- EXCEPTION dentro de WHILE (handler corre cada iteración)
DECLARE i INT DEFAULT 0;
DECLARE caught INT DEFAULT 0;
WHILE i < 5 LOOP
    SET i = i + 1;
    BEGIN
        RAISE EXCEPTION 'iter %', i;
    EXCEPTION WHEN OTHERS THEN
        SET caught = caught + 1;
    END;
END LOOP;
-- caught == 5

-- LOOP con condición compleja vía EXIT WHEN
DECLARE done INT DEFAULT 0;
LOOP
    UPDATE jobs SET status = 'processing' WHERE id IN
        (SELECT id FROM jobs WHERE status = 'pending' LIMIT 10);
    SET done = (SELECT COUNT(*) FROM jobs WHERE status = 'pending');
    EXIT WHEN done = 0;
END LOOP;

❌ Errores típicos

Mensaje Causa
[GBY-4114] BEGIN ...: se esperaba END para cerrar Falta END.
[GBY-4114] EXCEPTION WHEN ...: solo se soporta OTHERS en X4d WHEN <code> específico (diferido).
[GBY-4115] LOOP ...: se esperaba END LOOP Falta END LOOP.
[GBY-4109] LOOP standalone: superó 100000 iter Loop infinito sin EXIT.

⚠️ No soportado todavía


CASE statement + EXCEPTION filtrada (bloque X4e)

CASE WHEN cond THEN <stmts> [WHEN cond THEN <stmts>]* [ELSE <stmts>] END CASE — statement-level (vs CASE expression que vive en SELECT list). EXCEPTION WHEN <code> THEN <handler> — filtros por código numérico en BEGIN..END handlers, múltiples WHEN encadenados con OTHERS fallback opcional.

📜 EBNF

case_stmt   ::= "CASE" "WHEN" expr "THEN" stmt_list
                { "WHEN" expr "THEN" stmt_list }*
                [ "ELSE" stmt_list ]
                "END" "CASE"
exc_handler ::= "WHEN" ( integer | "OTHERS" ) "THEN" stmt_list

🧠 Semántica

CASE statement:

EXCEPTION WHEN <code>:

✅ Ejemplos

-- CASE statement con clasificación
DECLARE amt INT DEFAULT 500;
CASE
    WHEN amt >= 1000 THEN INSERT INTO platinum VALUES (id);
    WHEN amt >= 100  THEN INSERT INTO gold VALUES (id);
    WHEN amt >= 10   THEN INSERT INTO silver VALUES (id);
    ELSE INSERT INTO bronze VALUES (id);
END CASE;

-- EXCEPTION filtrada por código
BEGIN
    INSERT INTO orders (id, amount) VALUES (1, -5);
EXCEPTION
    WHEN 3008 THEN                                   -- CHECK violation
        INSERT INTO bad_orders (id) VALUES (1);
    WHEN 3001 THEN                                   -- duplicate PK
        UPDATE orders SET amount = -5 WHERE id = 1;
    WHEN OTHERS THEN
        INSERT INTO err_log (msg) VALUES ('unknown');
END;

-- CASE dentro de procedure
CREATE PROCEDURE tier(p_id INT, p_amt INT) AS BEGIN
    CASE
        WHEN p_amt >= 100 THEN INSERT INTO tier_log VALUES (p_id, 'gold');
        WHEN p_amt >= 50  THEN INSERT INTO tier_log VALUES (p_id, 'silver');
        ELSE INSERT INTO tier_log VALUES (p_id, 'bronze');
    END CASE;
END;

❌ Errores típicos

Mensaje Causa
[GBY-4116] CASE statement: se esperaba WHEN Falta la primera branch (searched form requiere WHEN).
[GBY-4116] CASE statement: se esperaba END CASE Falta el cierre.
[GBY-4117] EXCEPTION WHEN: se esperaba OTHERS o código entero Filtro inválido (ident, string, etc.).

⚠️ No soportado todavía


RETURN en function bodies (bloque X4f)

CREATE FUNCTION ... AS BEGIN ... RETURN expr; END — function bodies multi-statement con RETURN expr como mecanismo de salida. La forma single-expression body de X3b (AS x + 1) sigue funcionando.

📜 EBNF

create_function ::= "CREATE" "FUNCTION" ident "(" [ param { "," param }* ] ")"
                    "RETURNS" type "AS" function_body
function_body   ::= expr
                  | "BEGIN" stmt_list "END"
return_stmt     ::= "RETURN" expr

🧠 Semántica

✅ Ejemplos

-- IF con early return
CREATE FUNCTION sign(x INT) RETURNS TEXT AS BEGIN
    IF x < 0 THEN
        RETURN 'negative';
    ELSIF x = 0 THEN
        RETURN 'zero';
    ELSE
        RETURN 'positive';
    END IF;
END;

SELECT sign(-3), sign(0), sign(7);  -- 'negative', 'zero', 'positive'

-- Variables locales + loop + RETURN
CREATE FUNCTION sum_to(n INT) RETURNS INT AS BEGIN
    DECLARE i INT DEFAULT 1;
    DECLARE total INT DEFAULT 0;
    WHILE i <= n LOOP
        SET total = total + i;
        SET i = i + 1;
    END LOOP;
    RETURN total;
END;

SELECT sum_to(10);  -- 55

-- Composición de functions
CREATE FUNCTION dbl(x INT) RETURNS INT AS x * 2;
CREATE FUNCTION quad(x INT) RETURNS INT AS BEGIN
    RETURN dbl(dbl(x));
END;

SELECT quad(3);  -- 12

❌ Errores típicos

Mensaje Causa
[GBY-4118] RETURN fuera de function body RETURN expr usado en script top-level o dentro de procedure.

⚠️ No soportado todavía


INTEGRITY CHECK

Recorre la DB abierta y reporta toda inconsistencia detectable: páginas con CRC inválido, filas no decodificables, entradas de índice secundario huérfanas (apuntan a PKs que ya no existen) y FKs huérfanas (valor no NULL sin parent). De solo lectura — no modifica nada.

🛤️ Railroad

flowchart LR
    S([▶]) --> I[INTEGRITY] --> C[CHECK] --> SEMI[";"] --> E([■])

📜 EBNF

integrity_check ::= "INTEGRITY" "CHECK"

✅ Forma del resultado

INTEGRITY CHECK devuelve un ResultSet con columnas kind, object, detail — una fila por hallazgo. El campo message resume:

Ejemplo de respuesta sin hallazgos:

{
  "columns": ["kind", "object", "detail"],
  "rows": [],
  "message": "OK · 2 tablas · 4 filas · 1 índices · 2 FKs · 4 páginas"
}

🏷️ Categorías de hallazgo

kind Significado
page_corrupt El pager rechazó la página al cargarla (CRC mismatch o lectura corta).
row_decode Los bytes de la fila no se ajustan al esquema actual de la tabla. Muy raro — solo ocurre si el encoder y el decoder se desincronizan.
orphan_index_entry Una entrada de un índice secundario apunta a una PK que ya no existe en la tabla.
fk_target_missing Una columna declara REFERENCES contra una tabla que ya no existe.
fk_orphan Un valor no nulo de una columna FK no tiene parent en la tabla referenciada.

Recomendado correrlo después de un crash, después de restaurar un backup, o como sanity check periódico. La complejidad es O(filas + entradas_de_índice + filas_con_FK), totalmente secuencial.


🧠 Combinaciones útiles dentro de una transacción

gabysql permite múltiples sentencias separadas por ; en un solo /exec HTTP o en un solo gabysql exec ... "...". Todas viajan dentro de la misma transacción: si una falla, todas se revierten.

-- Receta común: schema + datos seed + índice + verify, todo atómico
CREATE TABLE products (id INT PRIMARY KEY, sku TEXT, price FLOAT);
INSERT INTO products (id, sku, price) VALUES (1, 'A-001', 99.0);
INSERT INTO products (id, sku, price) VALUES (2, 'A-002', 149.5);
CREATE INDEX idx_products_sku ON products (sku);
SELECT * FROM products WHERE sku = 'A-001';

Si la 4ª sentencia (CREATE INDEX) falla por el motivo que sea, las 3 anteriores también se revierten: la DB queda en el estado previo.


🔭 Lo que no está implementado todavía

flowchart LR
    A([Camino A]) --> NOT_NULL[NOT NULL] --> UNIQUE[UNIQUE] --> DEFAULT[DEFAULT]
    DEFAULT --> ORDER_BY[ORDER BY indexado] --> COMPOSITE[Índice compuesto]
    A --> EXPLAIN[EXPLAIN]
    B([Camino B]) --> ALTER[ALTER TABLE] --> PREP[Prepared statements]
    C([Camino C]) --> JOIN[JOIN] --> GROUP[GROUP BY] --> SUB[Subqueries] --> CTE[CTE / window]

Cada bucket vive en su camino correspondiente del COMMERCIAL_ROADMAP. Si necesitas alguno con prioridad para un caso de uso real, abre un Issue describiendo el escenario.