Toda sentencia recorre el mismo pipeline corto:

lexer tokens parser descenso recursivo AST planificador escaneo vs búsqueda puntual ejecutor filas
Cinco etapas escritas a mano, sin parser ni optimizador externos. Las decisiones del planificador son deterministas.

Tipos

Tipo SQL Value Notas
INTEGER Integer(i64) INTEGER PRIMARY KEY ⇒ alias del rowid (al estilo de SQLite)
REAL Real(f64)
TEXT Text(String) UTF-8 validado
BLOB Blob(Vec<u8>) literales x'…'
BOOLEAN Bool(bool) TRUE / FALSE
Null

Restricciones de columna: PRIMARY KEY, NOT NULL, DEFAULT <literal>, UNIQUE, CHECK (expr) y REFERENCES (claves foráneas). El texto del CHECK se guarda en el catálogo (esquema v7) y se vuelve a parsear al evaluarlo.

Sentencias

-- DDL
CREATE TABLE [IF NOT EXISTS] t (col TYPE [constraints], … [, table_constraint …]);
  -- column-level: PRIMARY KEY | NOT NULL | DEFAULT v | UNIQUE | CHECK (expr) | REFERENCES parent […]
  -- table-level:  UNIQUE (c…) | CHECK (expr) | FOREIGN KEY (c…) REFERENCES parent (p…) […]
CREATE TABLE [IF NOT EXISTS] t AS SELECT …;        -- CTAS: column names + types from the query
DROP TABLE [IF EXISTS] t;
ALTER TABLE t ADD [COLUMN] col TYPE [DEFAULT v] [NOT NULL];  -- appended; never rewrites rows
ALTER TABLE t MOVE COLUMN col {FIRST | BEFORE x | AFTER x};  -- logical reorder (presentation only)
ALTER TABLE t REORDER COLUMNS (col, …);                     -- fix the whole logical order
ALTER TABLE t RENAME [COLUMN] old TO new;                   -- metadata only
ALTER TABLE t DROP [COLUMN] col;                            -- logical DROP (tombstone); no rewrite
CREATE VIEW [IF NOT EXISTS] v AS <select>;                  -- a stored, named SELECT
DROP VIEW [IF EXISTS] v;
CREATE TRIGGER [IF NOT EXISTS] tr {BEFORE|AFTER|INSTEAD OF} {INSERT|UPDATE|DELETE} ON t
  [FOR EACH {ROW|STATEMENT}] BEGIN <dml>; … END;            -- body with OLD./NEW.
DROP TRIGGER [IF EXISTS] tr;

-- DML
INSERT INTO t [(cols)] {VALUES (expr, …)[, …] | SELECT …}
  [ON CONFLICT [(cols)] DO {NOTHING | UPDATE SET col = expr [, …] [WHERE expr]}]
  [RETURNING list | *];
UPDATE t SET col = expr [, …] [WHERE expr] [RETURNING list | *];
DELETE FROM t [WHERE expr] [RETURNING list | *];

-- Query
[WITH [RECURSIVE] cte AS (SELECT …) [, …]]        -- CTEs; RECURSIVE supported
SELECT [DISTINCT] list | *
  [FROM {table | (SELECT …)} [alias]              -- FROM is optional; a subquery here is a derived table
  [{INNER|LEFT} JOIN src2 ON expr]]               -- nested-loop, predicate pushdown
  [WHERE expr]
  [GROUP BY e1, … [HAVING cond]]
  [{UNION [ALL] | INTERSECT | EXCEPT} SELECT …]    -- set operators; ALL keeps duplicates
  [ORDER BY {col | expr | alias | ordinal} [ASC|DESC] [, …]]
  [LIMIT n [OFFSET m]]
  [AS OF {VERSION n | TIMESTAMP 'rfc3339'}];       -- arkeion extension

-- Transactions
BEGIN; COMMIT; ROLLBACK;
SAVEPOINT s;  ROLLBACK TO [SAVEPOINT] s;  RELEASE [SAVEPOINT] s;   -- inside a BEGIN

Consultas

Joins. Joins INNER y LEFT, evaluados con nested-loop y predicate pushdown (un WHERE que restringe el lado interno se aplica según se producen las filas, no después).

Operadores de conjuntos. UNION (deduplica), UNION ALL (mantiene los duplicados), INTERSECT (filas en ambos lados) y EXCEPT (filas del lado izquierdo acumulado que no están en el derecho). Se combinan de forma asociativa por la izquierda; un ORDER BY tras una operación de conjuntos ordena por columna de salida u ordinal.

Subconsultas — escalares (SELECT …), x [NOT] IN (SELECT …) y [NOT] EXISTS (SELECT …). Se admiten subconsultas correlacionadas: cuando la consulta interna referencia una columna de la fila externa, se reevalúa por cada fila externa con los valores de esa fila. Una subconsulta escalar que devuelva más de una fila es un error; cero filas es NULL.

Tablas derivadas — un (SELECT …) en la cláusula FROM, opcionalmente con alias, se trata como una tabla y se puede unir y filtrar como cualquier otra fuente.

CTEs (WITH n AS (SELECT …)) — tablas con nombre, materializadas y visibles para la consulta que las sigue; cada una ve las anteriores y oculta a una tabla real con el mismo nombre. WITH RECURSIVE ejecuta una CTE recursiva: un caso base y después un paso que referencia a la propia CTE, iterado hasta que no produce filas nuevas (UNION deduplica la frontera, UNION ALL conserva todas las filas).

Vistas (CREATE VIEW v AS SELECT …) — como una CTE pero persistente: el texto del SELECT se guarda en el catálogo y se materializa en la lectura, así que siempre refleja los datos actuales. Una vista sobre otra vista funciona, y los nombres de vista nunca chocan con los de tabla. La definición de una vista está versionada, de modo que un AS OF anterior a su creación no la ve.

AgregadosCOUNT(*), COUNT(col), SUM, AVG, MIN, MAX, GROUP_CONCAT(x[, sep]), todos ellos compatibles con DISTINCT (COUNT(DISTINCT x), …). SELECT DISTINCT deduplica las filas proyectadas. MIN/MAX tienen además una forma escalar con ≥2 argumentos (MIN(a, b, …)); con un solo argumento son agregados, igual que en SQLite.

GROUP BY / HAVING — agrupa por el valor de expresiones (normalmente columnas) y emite una fila por grupo, plegando los agregados de la proyección sobre cada grupo. Una columna de la proyección que no esté agregada debe aparecer en el GROUP BY. HAVING filtra los grupos ya agregados.

Funciones de ventanafunc(args) OVER ([PARTITION BY …] [ORDER BY …]) en la lista del SELECT, calculadas sobre el conjunto filtrado antes del ORDER BY/LIMIT exterior. Disponibles: ROW_NUMBER(), RANK(), DENSE_RANK(), NTILE(k), LAG, LEAD, FIRST_VALUE, LAST_VALUE y SUM/COUNT/AVG/MIN/MAX como agregados de ventana. Marcos explícitos ROWS BETWEEN start AND end (ROWS físico, no RANGE) con UNBOUNDED {PRECEDING|FOLLOWING}, N {PRECEDING|FOLLOWING} y CURRENT ROW.

SELECT sin FROM evalúa expresiones constantes contra una única fila implícita (SELECT 1 + 1, SELECT UPPER('hi') AS g). Un WHERE constante puede descartarla. Sin tabla, la proyección rechaza columnas, *, agregados y las cláusulas que necesitan filas (JOIN/GROUP BY/HAVING/ORDER BY/AS OF).

Escrituras

INSERT / UPSERTINSERT … ON CONFLICT [(cols)] DO {NOTHING | UPDATE SET …}. Si una fila colisiona con la PK o con un índice UNIQUE, se omite (DO NOTHING) o se actualiza la fila existente (DO UPDATE). En DO UPDATE, SET/WHERE ven la fila existente y excluded.col = la fila propuesta; un WHERE falso descarta esa actualización.

RETURNINGINSERT/UPDATE/DELETE … RETURNING list | * devuelve las filas afectadas (las insertadas, las actualizadas con sus valores NEW o las borradas) en lugar de solo un recuento. Ejecútalo con query; con execute la escritura se realiza igualmente, pero las filas se descartan.

Claves foráneascol REFERENCES parent[(col)] o a nivel de tabla FOREIGN KEY (a, b) REFERENCES parent (x, y) (compuestas), con [ON DELETE action] [ON UPDATE action] y action ∈ {RESTRICT | CASCADE | SET NULL}. Las columnas referenciadas deben ser la PK del padre o estar cubiertas por un índice UNIQUE. Se comprueban en INSERT/UPDATE; la acción se dispara en el DELETE/UPDATE del padre. Las autorreferencias (árboles) no dan problema.

Triggers{BEFORE|AFTER|INSTEAD OF} {INSERT|UPDATE|DELETE}, FOR EACH {ROW|STATEMENT}, con cuerpo de INSERT/UPDATE/DELETE. FOR EACH ROW (por defecto) se dispara una vez por fila afectada con OLD./NEW. enlazados; FOR EACH STATEMENT se dispara una sola vez. INSTEAD OF (solo en vistas) hace escribible una vista traduciendo la escritura a las tablas base. Las escrituras reentrantes están protegidas frente a una recursión descontrolada: son la base de unos registros de auditoría a prueba de manipulaciones.

Cambios lógicos de esquemaMOVE COLUMN / REORDER COLUMNS solo cambian el orden de presentación (la expansión de * y el INSERT posicional); RENAME COLUMN solo cambia el nombre; DROP COLUMN marca la columna con una lápida (deja de aparecer en * y por nombre) pero congela sus bytes físicos. Todos son O(1) y nunca reescriben filas, así que los viajes en el tiempo siguen intactos: un AS OF anterior al cambio ve el esquema de su época, porque el catálogo está versionado en el mismo b-tree que los datos. Los bytes muertos los recupera el vacuum.

Expresiones

expr    := or
or      := and ( OR and )*
and     := not ( AND not )*
not     := [NOT] cmp
cmp     := add ( ( = | != | <> | < | <= | > | >= | LIKE | IS [NOT] NULL ) add )?
add     := mul ( ( + | - ) mul )*
mul     := unary ( ( * | / | % ) unary )*
unary   := [-] primary
primary := literal | column | table.column | ?N | :name | ( expr ) | function(args)
  • Parámetros: posicionales ?1, ?2, … o con nombre :name (repetibles); no se mezclan en una misma sentencia.
  • LIKE con % / _, sensible a mayúsculas y minúsculas.
  • Comparar tipos distintos es un error de tipos, no una coerción silenciosa (el comportamiento sorprendente se trata como un bug), salvo INTEGERREAL, que se promociona.
  • División o módulo por cero → NULL (compatibilidad con SQLite). Un resultado NaN (por ejemplo inf - inf) se normaliza a NULL; ±inf se conserva. El desbordamiento de enteros es un error (+/-/*, y i64::MIN / -1).
  • Operadores y formas: || (concatenación), CAST(x AS type), CASE WHEN … THEN … [ELSE …] END, x [NOT] BETWEEN a AND b.

Funciones escalares (insensibles a mayúsculas; NULL se propaga salvo que se indique lo contrario):

  • Texto: UPPER, LOWER, LENGTH/CHAR_LENGTH, TRIM/LTRIM/RTRIM, SUBSTR/SUBSTRING, REPLACE, INSTR, REVERSE, HEX, CONCAT, CONCAT_WS, LPAD/RPAD, UNICODE, CHAR, QUOTE, PRINTF/FORMAT, GLOB.
  • Numéricas: ABS, ROUND, CEIL/CEILING, FLOOR, TRUNC, SQRT, POW/POWER, MOD, SIGN, EXP, LN, LOG/LOG10/LOG2/LOG(base, x), SIN/COS/TAN, ASIN/ACOS/ATAN/ATAN2, PI(), RADIANS/DEGREES, RANDOM (no determinista).
  • Condicionales / NULL: COALESCE, IFNULL, NULLIF, TYPEOF, IIF(c, a, b).
  • Fecha y hora: NOW, DATE, TIME, DATETIME, STRFTIME, JULIANDAY, UNIXEPOCH. El entero de tiempo son milisegundos desde epoch en UTC, la misma unidad que las marcas de tiempo de auditoría.
  • JSON (al estilo JSON1, en Rust puro): JSON, JSON_VALID, JSON_TYPE, JSON_EXTRACT, JSON_ARRAY_LENGTH, JSON_OBJECT, JSON_ARRAY, JSON_QUOTE, más json -> key (resultado como JSON) y json ->> key (resultado como valor SQL).

La extensión AS OF

AS OF es una extensión de Arkeion que cierra un SELECT (después de todas las demás cláusulas). Fija toda la sentencia a un único snapshot histórico: no hay mezcla por tabla.

SELECT total, status FROM invoices WHERE id = 7 AS OF VERSION 1042;
SELECT * FROM invoices AS OF TIMESTAMP '2026-05-01T00:00:00Z';
  • AS OF VERSION n — exacto; error VersionNotFound si n se compactó y ya no existe.
  • AS OF TIMESTAMP t — se resuelve a la versión más alta cuya marca de tiempo de commit sea ≤ t. La marca de tiempo es informativa; la autoridad es la versión.
  • Solo en SELECT. No se escribe en el pasado: para eso están las ramas.

El ejecutor es genérico respecto a su fuente de datos, así que fija el snapshot una vez y evalúa todo contra él: joins, subconsultas (incluidas las correlacionadas), CTEs (incluidas las recursivas), operadores de conjuntos, agregados, vistas y el catálogo (columnas, orden y FKs están versionados en el mismo b-tree). La única excepción es el reloj: now() / date('now') devuelven la hora de ejecuciónAS OF retrocede los datos, no el reloj de pared de la sentencia.

SELECT … JOIN … WHERE (SELECT …) AS OF VERSION 1042 joins · subconsultas · agregados · vistas — todo fija un snapshot historial de versiones v1042 ahora
Una cláusula, un snapshot: toda la consulta lee el commit fijado, así que un informe es reproducible byte a byte.

Identificadores

Los identificadores sin comillas empiezan por una letra o _ y continúan con letras, dígitos o _; las letras Unicode cuentan (café, 名前), igual que en SQLite. Las comillas dobles ("…", con "" para una comilla literal) permiten palabras reservadas o espacios y símbolos: SELECT "select", "my col" FROM t. Las comillas simples son siempre una cadena de texto, nunca un identificador.

Fuera de alcance de forma deliberada

Excluido Por qué
ALTER TABLE físico (cambio de tipo, DROP con reescritura de filas) rompería los viajes en el tiempo sin una época por fila; los ADD/MOVE/REORDER/RENAME/DROP lógicos cubren los casos habituales
Recuperación inmediata de espacio en DROP COLUMN el drop lógico deja bytes muertos; el vacuum los recupera en una reescritura
Optimizador basado en costes (estadísticas) sustituido por un planificador determinista basado en reglas — índice frente a escaneo y predicate pushdown — de modo que los planes son reproducibles y auditables

Codegen

El catálogo se serializa a una representación JSON estable mediante Database::schema(). Los generadores de código externos consumen eso, no el texto SQL: el contrato del codegen es el catálogo, no la cadena de la sentencia.