En el artículo anterior, exploramos cómo PostgreSQL establece conexiones y se comunica usando su wire protocol. Una vez que tu conexión está establecida y el proceso backend está listo, finalmente puedes enviar consultas. Pero cuando PostgreSQL recibe tu SQL, es solo una cadena de texto—la base de datos no puede ejecutar texto directamente.
Déjame mostrarte qué sucede cuando PostgreSQL recibe esta consulta:
SELECT name FROM users WHERE id = 42;
PostgreSQL aún no ve esto como un comando. Ve caracteres: S, E, L, E, C, T, y así sucesivamente. El viaje desde este texto crudo hasta algo que PostgreSQL puede ejecutar involucra dos transformaciones principales: parsing (entender la estructura) y análisis semántico (agregar significado).
En este artículo, exploraremos ambas fases—cómo PostgreSQL valida tu sintaxis SQL y luego asegura que realmente tiene sentido en el contexto de tu base de datos.
La Transformación de Dos Fases
PostgreSQL divide el procesamiento de consultas en dos fases.
Fase 1: Parsing transforma tu texto SQL en una estructura de árbol usando solo reglas gramaticales. Primero, divide el texto en tokens—cosas como palabras clave, identificadores y números. Luego organiza esos tokens en un árbol que muestra cómo las piezas encajan juntas.
Fase 2: Análisis Semántico toma ese árbol y agrega significado. Aquí es donde PostgreSQL busca nombres de tablas y columnas en el catálogo de base de datos—las propias tablas internas de PostgreSQL donde mantiene registro de qué existe en tu base de datos y qué propiedades tienen esas cosas.
También verifica si tus operaciones tienen sentido. ¿Puedes comparar un entero con una cadena de texto? ¿Los tipos coinciden?
Y registra qué permisos necesitará la consulta (aunque la verificación real de permisos ocurre más tarde, cuando la consulta se ejecuta).
¿El resultado? Una representación completa de consulta que PostgreSQL puede optimizar y ejecutar.
Aquí está el pipeline completo:
digraph pipeline {
rankdir=LR;
node [shape=box, style=rounded];
SQLText [label="SQL Text", shape=box, style="rounded,filled", fillcolor=lightblue];
Lexer [label="Lexer", shape=ellipse, fillcolor=lightyellow, style=filled];
Tokens [label="Tokens", shape=box, style="rounded,filled", fillcolor=lightgreen];
Parser [label="Parser", shape=ellipse, fillcolor=lightyellow, style=filled];
ParseTree [label="Parse Tree", shape=box, style="rounded,filled", fillcolor=lightgreen];
Analyzer [label="Analyzer", shape=ellipse, fillcolor=lightyellow, style=filled];
QueryTree [label="Query Tree", shape=box, style="rounded,filled", fillcolor=lightgreen];
SQLText -> Lexer -> Tokens -> Parser -> ParseTree -> Analyzer -> QueryTree;
}
¿Por qué separar estas fases? Rendimiento y claridad. El parser puede detectar errores de sintaxis instantáneamente usando solo CPU y memoria—sin búsquedas en base de datos necesarias, sin transacción requerida. Cuando escribes SELECT name FROM users WHER id = 42 (nota el error tipográfico), PostgreSQL lo rechaza durante el parsing sin siquiera verificar si users existe. No comienza transacción, no se adquieren bloqueos, no ocurre acceso costoso al catálogo.
Esta separación proporciona fallo rápido para errores de sintaxis mientras difiere la validación semántica costosa (que requiere transacción y acceso al catálogo) hasta que realmente se necesita.
Sigamos nuestra consulta de ejemplo a través de ambas fases.
Fase 1: Parsing
La Fase 1 transforma texto SQL crudo en un árbol de parseo estructurado usando solo reglas gramaticales. Esto sucede en dos pasos: análisis léxico (dividir texto en tokens) y análisis sintáctico (construir esos tokens en un árbol).
Dividiendo Texto en Tokens
El lexer lee tu SQL carácter por carácter, agrupándolos en tokens significativos. Piensa en ello como leer una oración e identificar el rol de cada palabra—excepto que aquí estamos identificando palabras clave, identificadores, números y operadores.
PostgreSQL usa una herramienta llamada Flex para implementar su lexer (puedes ver la definición del lexer en src/backend/parser/scan.l). Veámoslo dividir nuestra consulta:
SELECT name FROM users WHERE id = 42;
El lexer transforma esto en un flujo de tokens:
SELECT → token de palabra clave
name → token de identificador
FROM → token de palabra clave
users → token de identificador
WHERE → token de palabra clave
id → token de identificador
= → token de operador
42 → token de número
; → token de terminador
Cada token lleva dos piezas de información: su tipo (qué clase de cosa es) y su valor (el texto real).
No todos los tokens son creados iguales. Las palabras clave tienen la prioridad más alta—cuando el lexer ve letras, primero verifica si forman una palabra clave antes de considerarlas un identificador. Por eso no puedes usar SELECT o FROM como nombres de tabla sin entrecomillarlos:
SELECT select FROM users; -- ¡Error de sintaxis!
SELECT "select" FROM users; -- ¡Esto funciona!
Si no es una palabra clave, el lexer lo trata como un identificador—un nombre para una tabla, columna o función. Los identificadores deben comenzar con una letra o guion bajo y pueden contener letras, dígitos, guiones bajos y signos de dólar. ¿Quieres caracteres especiales o mayúsculas preservadas? Usa identificadores entrecomillados.
Los números obtienen su propio tratamiento. Los literales numéricos pueden ser enteros como 42, decimales como 3.14159, o notación científica como 1.23e10. Similarmente, los literales de cadena usan comillas simples ('Hello World') o la sintaxis dollar-quote de PostgreSQL ($tag$content$tag$) cuando necesitas evitar escapar comillas dentro de la cadena.
Finalmente, operadores y puntuación como =, <>, , y ; cada uno obtiene sus propios tipos de token. Estos son el pegamento que conecta todo.
Con estos tokens identificados, el parser puede comenzar a construir estructura.
Construyendo el Árbol de Parseo
El parser toma el flujo plano de tokens y lo organiza en un árbol jerárquico que captura relaciones en tu consulta. PostgreSQL usa Bison, un generador de parsers, para implementar su gramática (puedes ver las reglas gramaticales en src/backend/parser/gram.y).
Las reglas gramaticales funcionan como recetas—especifican cómo los tokens se combinan para formar construcciones SQL válidas. Aquí hay reglas simplificadas para declaraciones SELECT:
SelectStmt: SELECT target_list FROM from_list WHERE where_clause
target_list: target_item
| target_list ',' target_item
expression: identifier
| number
| expression operator expression
Para nuestra consulta SELECT name FROM users WHERE id = 42, el parser construye esta estructura:
digraph parse_tree {
rankdir=TB;
node [shape=box, style=rounded];
SelectStmt [label="SelectStmt", style="filled,rounded", fillcolor=lightblue];
TargetList [label="target_list"];
FromClause [label="from_clause"];
WhereClause [label="where_clause"];
TargetItem [label="target_item\n'name'"];
Table [label="table\n'users'"];
Comparison [label="comparison\n'id = 42'"];
SelectStmt -> TargetList;
SelectStmt -> FromClause;
SelectStmt -> WhereClause;
TargetList -> TargetItem;
FromClause -> Table;
WhereClause -> Comparison;
}
Este árbol captura la estructura de la consulta—una declaración SELECT con una columna, una tabla y una cláusula WHERE.
Pero aquí está la clave: en esta etapa, PostgreSQL no sabe qué significa nada. El árbol de parseo muestra estructura, pero PostgreSQL no sabe si users existe, si name es una columna válida, o si comparar id con 42 tiene sentido. Esa validación semántica viene después.
Fase 2: Agregando Significado a Través del Análisis Semántico
El análisis semántico transforma el árbol de parseo en un árbol Query—una representación completa y validada que PostgreSQL puede optimizar y ejecutar. El analizador debe responder preguntas críticas sobre nuestra consulta:
- ¿Existe la tabla
users? ¿Dónde está? - ¿Es
nameuna columna válida enusers? ¿Cuál es su tipo? - ¿Es
iduna columna válida? ¿Cuál es su tipo? - ¿Podemos comparar
idcon42? ¿Son los tipos compatibles? - ¿Qué permisos se necesitarán para ejecutar esta consulta?
Mientras el analizador trabaja a través de la consulta, rastrea qué tablas están disponibles, qué columnas contienen y qué contexto se está procesando.
Exploremos las tres operaciones principales del análisis semántico.
Resolviendo Nombres: ¿A Qué Se Refiere Todo?
La resolución de nombres es la operación más fundamental. PostgreSQL debe determinar a qué se refiere cada identificador consultando el catálogo de base de datos.
Resolución de Tabla
Cuando escribes SELECT * FROM users, PostgreSQL necesita averiguar qué tabla users quieres decir. Podrías tener tablas llamadas users en múltiples esquemas (public.users, hr.users, sales.users).
PostgreSQL usa el search path—una lista ordenada de esquemas para buscar. Puedes ver el tuyo:
SHOW search_path;
search_path
-----------------
"$user", public
(1 row)
PostgreSQL busca esquemas en orden hasta que encuentra una tabla coincidente. Puedes ser explícito calificando el nombre de tabla:
SELECT * FROM public.users; -- Omite search path, usa esta tabla específica
Una vez que PostgreSQL encuentra la tabla correcta, necesita registrar esta información para el resto del procesamiento de consulta.
PostgreSQL asigna a cada objeto de base de datos (tablas, índices, funciones, etc.) un número único llamado OID (Object Identifier). Cuando el analizador encuentra la tabla users, busca su OID—digamos que es 16384. Desde este punto en adelante, PostgreSQL puede referirse a esta tabla específica por su OID en lugar de buscar por nombre otra vez.
El analizador también crea una Range Table Entry (RTE)—una estructura de datos que contiene toda la información sobre cómo esta consulta usa la tabla. El RTE incluye el OID de la tabla, qué columnas se acceden, qué alias tiene (si lo hay) y qué permisos se necesitan. Piensa en la range table como un directorio al frente de la consulta que lista todas las tablas involucradas. Otras partes de la consulta pueden entonces referenciar “tabla #1 en la range table” en lugar de buscar repetidamente nombres de tabla.
Con las tablas resueltas y agregadas a la range table, PostgreSQL ahora puede averiguar qué significa cada referencia de columna en la consulta.
Resolución de Columna
Para referencias de columna, PostgreSQL debe coincidir nombres contra tablas disponibles. Considera:
SELECT name, u.email, p.title
FROM users u
JOIN posts p ON u.id = p.user_id;
Columnas no calificadas (name): PostgreSQL busca en todas las tablas en alcance. Si exactamente una tabla contiene la columna, la resolución tiene éxito. Si múltiples tablas la tienen, eso es ambiguo. Si ninguna tabla la tiene, eso es un error.
Columnas calificadas (u.email, p.title): PostgreSQL resuelve el alias primero, luego busca la columna dentro de esa tabla específica.
El analizador consulta pg_attribute (el catálogo del sistema que contiene información de columna) para verificar que cada columna existe y registra su tipo y atributos.
Puedes explorar los mismos catálogos que PostgreSQL usa:
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'users';
column_name | data_type
-------------+-----------
id | integer
name | text
email | text
active | boolean
(4 rows)
Una vez que PostgreSQL encuentra una columna, registra esto como una referencia a la Range Table Entry—algo como “tabla #1, columna #3” en lugar de almacenar el nombre de texto. Estas referencias numéricas son mucho más rápidas para procesamiento posterior.
Ahora que PostgreSQL sabe a qué tablas y columnas te estás refiriendo, necesita verificar que las operaciones que estás realizando realmente tienen sentido.
Verificación de Tipos: Asegurándose que las Operaciones Tienen Sentido
Una vez que PostgreSQL sabe qué tablas y columnas estás usando, verifica si tus operaciones realmente tienen sentido. ¿Puedes multiplicar un número por una cadena de texto? ¿Puedes comparar un entero con una fecha? La verificación de tipos detecta estos problemas.
Veamos una consulta:
SELECT * FROM products
WHERE price > 19.99
AND quantity > '5';
Para price > 19.99, PostgreSQL ve que estás comparando una columna numérica con un literal numérico. Eso funciona bien.
Para quantity > '5', estás comparando una columna entera con una cadena de texto. PostgreSQL automáticamente convierte '5' a un entero para que la comparación funcione. Esto se llama coerción de tipo—PostgreSQL intenta hacer que tipos compatibles funcionen juntos cuando es seguro hacerlo.
Pero no todo puede ser convertido:
SELECT name + price FROM products;
ERROR: operator does not exist: text + numeric
No puedes sumar texto y números juntos. No hay operador + que funcione con esos tipos, y no hay forma segura de convertir uno al otro. PostgreSQL detecta esto durante el análisis y te lo dice antes de intentar ejecutar la consulta.
Las llamadas a funciones funcionan de manera similar. Cuando llamas length(name), PostgreSQL escoge la versión correcta de la función length basándose en si name es texto o datos binarios. Cada versión hace algo ligeramente diferente, y PostgreSQL averigua cuál necesitas.
Con nombres resueltos y tipos verificados, hay una cosa más que el analizador necesita rastrear.
Registrando Requisitos de Permisos
Durante el análisis semántico, PostgreSQL registra qué permisos se necesitarán para ejecutar la consulta. Las verificaciones reales de permisos ocurren más tarde, durante la ejecución.
Para nuestra consulta:
SELECT name FROM users WHERE id = 42;
El analizador registra en la Range Table Entry que se requiere privilegio SELECT en la tabla users. También nota qué columnas específicas se acceden (name e id), ya que PostgreSQL soporta privilegios a nivel de columna donde un usuario podría tener acceso a algunas columnas pero no a otras. Esta información se almacena en el árbol Query y será verificada cuando la consulta realmente se ejecute.
Con todos los nombres resueltos, tipos verificados y requisitos de permisos registrados, PostgreSQL tiene todo lo que necesita para construir la representación completa de consulta.
Construyendo el Árbol Query Final
La culminación del análisis semántico es el árbol Query—una representación completamente validada, verificada en tipos y verificada en permisos. Para esta consulta:
SELECT u.name, COUNT(p.id)
FROM users u
JOIN posts p ON u.id = p.user_id
WHERE u.active = true
GROUP BY u.name;
El árbol Query organiza todos los componentes en una estructura jerárquica:
digraph query_tree {
rankdir=TB;
node [shape=box, style=rounded];
Query [label="Query\n(SELECT)", style="filled,rounded", fillcolor=lightblue];
RangeTable [label="Range Table\n1: users (alias: u)\n2: posts (alias: p)"];
TargetList [label="Target List\n1: u.name (TEXT)\n2: COUNT(p.id) (BIGINT)"];
FromClause [label="FROM Clause"];
WhereClause [label="WHERE Clause"];
GroupBy [label="GROUP BY\nu.name"];
Query -> RangeTable;
Query -> TargetList;
Query -> FromClause;
Query -> WhereClause;
Query -> GroupBy;
FromClause -> JoinTree;
JoinTree [label="Join Tree\nINNER JOIN"];
JoinTree -> LeftTable [label="left"];
JoinTree -> RightTable [label="right"];
JoinTree -> JoinCond [label="condition"];
LeftTable [label="RTE 1\nusers u"];
RightTable [label="RTE 2\nposts p"];
JoinCond [label="u.id = p.user_id"];
WhereClause -> EqOp [label="expression"];
EqOp [label="= operator"];
EqOp -> VarActive [label="left"];
EqOp -> ConstTrue [label="right"];
VarActive [label="u.active\n(BOOLEAN)"];
ConstTrue [label="true\n(BOOLEAN)"];
}
Este árbol Query completo contiene todo lo que el planificador de PostgreSQL necesita para generar un plan de ejecución óptimo. Cada referencia de tabla está resuelta, cada tipo está verificado, cada requisito de permiso está registrado, y cada expresión está correctamente estructurada.
Jugando con SQL Parsing
¿Quieres ver el parsing en acción? Puedes usar la librería Ruby pg_query para explorar cómo PostgreSQL divide tu SQL:
gem install pg_query
Mira al lexer dividir tu consulta:
require 'pg_query'
query = "SELECT name FROM users WHERE id = 42"
scan_result = PgQuery.scan(query)
puts "Tokens:"
scan_result.first.tokens.each do |token|
token_text = query[token.start...token.end]
puts " #{token.token.to_s.ljust(12)} '#{token_text}'"
end
Salida:
Tokens:
SELECT 'SELECT'
IDENT 'name'
FROM 'FROM'
IDENT 'users'
WHERE 'WHERE'
IDENT 'id'
'=' '='
ICONST '42'
Ve la estructura del árbol de parseo:
parsed = PgQuery.parse(query)
stmt = parsed.tree.stmts.first.stmt.select_stmt
puts "Parse Tree Structure:"
puts " Statement Type: SelectStmt"
puts " Target List: #{stmt.target_list.length} items"
puts " FROM Clause: #{stmt.from_clause.length} items"
PostgreSQL proporciona una opción de depuración para ver el árbol de consulta completo después tanto del parsing como del análisis:
SET debug_print_parse = on;
SELECT name FROM users WHERE id = 42;
Esto escribe el árbol de consulta en los logs del servidor PostgreSQL (no en tus resultados de consulta). A pesar de su nombre, debug_print_parse muestra el árbol después de que el análisis semántico se completa—verás Range Table Entries, tipos resueltos y toda la información semántica que hemos discutido.
Ahora has visto el viaje completo desde texto SQL hasta árbol de consulta. Atemos todo junto.
Resumen
Así que ese es el viaje desde texto SQL hasta árbol de consulta. Tu simple SELECT name FROM users WHERE id = 42 pasa por bastante transformación—primero parseado en tokens y luego en un árbol sintáctico, luego analizado para resolver nombres, verificar tipos y registrar lo que la consulta necesita para ejecutarse.
Lo que hace elegante esta arquitectura es la separación de responsabilidades. El parser solo se preocupa por la gramática—sin búsquedas en base de datos, sin transacciones, solo validación rápida de estructura. El analizador maneja las cosas costosas—consultar el catálogo, resolver ambigüedades, verificar tipos. Para cuando PostgreSQL termina el análisis, tiene un árbol de consulta completo y validado listo para ejecución.
Pero aún no hemos terminado. Este árbol de consulta todavía necesita pasar por el rewriter (donde PostgreSQL aplica vistas, reglas y seguridad a nivel de fila) y el planificador (donde averigua la forma más rápida de ejecutar tu consulta). Esas fases merecen sus propias inmersiones profundas—las exploraremos en artículos futuros.
Por ahora, has visto cómo PostgreSQL toma tu SQL y lo transforma en algo que puede entender y validar. Cada consulta pasa por este proceso, y entenderlo te ayuda a escribir mejor SQL y depurar problemas cuando las cosas van mal.
