Consultar D1: prepare, bind y los métodos de resultado
El ciclo de vida de una consulta en D1: preparar la sentencia con env.DB.prepare, vincular parámetros con bind mediante marcadores, y elegir entre first para una fila, all para muchas y run para escrituras. Por qué los parámetros vinculados son la única defensa correcta contra la inyección de SQL y qué separa un dato de una instrucción.
Con la base creada y enlazada, llega el momento de hablarle. La API de D1 es pequeña y deliberada: preparas una sentencia, le vinculas parámetros y la ejecutas con el método que corresponde a lo que esperas de vuelta. Esa aparente simplicidad esconde una de las decisiones de seguridad más importantes que tomarás al escribir código de datos —separar el dato de la instrucción—, y aprender a leer los tres verbos de resultado, first, all y run, es aprender a pedirle a la base exactamente lo que necesitas y ni un byte más.
- Preparar una consulta con
env.DB.preparey entender qué es un statement. - Vincular parámetros con
bindusando marcadores posicionales. - Elegir entre
first,allyrunsegún lo que necesites de vuelta. - Blindar tus consultas contra la inyección de SQL con parámetros vinculados.
prepare y bind: el ciclo de una consulta
Toda consulta empieza en env.DB.prepare, que recibe una cadena de SQL y devuelve un statement: un objeto que representa la consulta lista para ejecutarse, pero que todavía no ha ido a la base. Sobre ese statement encadenas bind para rellenar los huecos que dejaste con marcadores, y solo al final lo ejecutas.
// prepare devuelve un statement; bind rellena el marcador ?
const stmt = env.DB.prepare('SELECT id, email FROM usuarios WHERE email = ?').bind(correo);
Los marcadores son posicionales: cada ? se rellena, en orden, con los argumentos que pasas a bind. D1 sigue la convención de SQLite, así que también admite marcadores numerados como ?1 y ?2 cuando quieres controlar el orden o repetir un valor sin pasarlo dos veces.
// dos parametros, rellenados en orden
const porNombre = env.DB
.prepare('SELECT * FROM pedidos WHERE usuario_id = ? AND estado = ?')
.bind(usuarioId, 'pendiente');
// marcador numerado: el mismo valor reutilizado en dos posiciones
const porRango = env.DB
.prepare('SELECT * FROM eventos WHERE inicio >= ?1 AND fin <= ?1')
.bind(fecha);
Lo que nunca debes hacer es construir la cadena de SQL concatenando valores; el valor va siempre por bind, jamás dentro del texto de la consulta. Un detalle útil: un statement preparado es reutilizable. Puedes preparar una vez y vincular valores distintos varias veces, lo que resulta natural cuando repites la misma consulta con datos diferentes —un patrón que reaparecerá con batch—.
first, all y run
El statement no hace nada hasta que lo ejecutas, y el método de ejecución que eliges define qué recibes. Son tres, y cada uno responde a una intención distinta.
// first: la primera fila, o null si no hay ninguna
const usuario = await env.DB.prepare('SELECT * FROM usuarios WHERE id = ?').bind(id).first();
// all: todas las filas, en la propiedad results
const { results } = await env.DB
.prepare('SELECT * FROM usuarios ORDER BY creado_en DESC LIMIT 20')
.all();
// run: para escrituras; devuelve metadatos de la operacion
const info = await env.DB.prepare('INSERT INTO usuarios (email) VALUES (?)').bind(correo).run();
first devuelve un único objeto —la primera fila— o null si la consulta no encontró nada; si le pasas el nombre de una columna, te devuelve directamente ese valor en lugar de la fila entera, cómodo para un SELECT count(*). all devuelve un objeto con la lista de filas en results, junto a success y meta. run está pensado para escrituras —INSERT, UPDATE, DELETE— y su valor son los metadatos: cuántas filas cambiaron, el último identificador insertado, cuánto tardó.
Cuando usas TypeScript, puedes pasar un tipo a first y a all para que el resultado deje de ser opaco y el editor conozca las columnas. Modelar la fila una vez y reutilizar ese tipo hace que un error de nombre de columna se detecte al escribir, no en producción.
// tipa la fila para que el editor conozca sus columnas
type Usuario = { id: number; email: string; creado_en: string };
const usuario = await env.DB.prepare('SELECT * FROM usuarios WHERE id = ?').bind(id).first<Usuario>();
Los metadatos de meta no son decorativos: traen duration —cuánto tardó la consulta—, rows_read y rows_written —cuántas filas tocó, la unidad con la que D1 mide y factura el trabajo— y, en las escrituras, last_row_id y changes. Leerlos es la vía directa para descubrir que una consulta está escaneando de más y pide a gritos un índice.
En la D1 de hoy tanto run como all devuelven resultados y metadatos, así que técnicamente podrías leer con run. Pero la convención comunica intención: usa all cuando te importan las filas y run cuando te importa el efecto —qué se escribió, cuánto cambió—. Elegir el verbo correcto no cambia el resultado, pero hace que quien lea tu código entienda de un vistazo si esa consulta busca datos o los modifica. Reserva raw para cuando quieras las filas como arrays en vez de objetos, un caso menos frecuente.
first devuelve la primera fila, pero no modifica la consulta que escribiste: si tu SELECT sin LIMIT casa con diez mil filas, la base puede materializarlas todas aunque tú solo mires una. Cuando esperas un único resultado, dilo en el SQL con LIMIT 1. Así la base hace menos trabajo, lees menos filas —y, por tanto, facturas menos— y tu intención queda explícita en la propia consulta.
flowchart LR P[prepare sql] --> B[bind parametros] B --> F[first una fila o null] B --> A[all muchas filas] B --> R[run escritura y metadatos] style P fill:#89b4fa,color:#11111b style B fill:#cba6f7,color:#11111b
bind: parámetros seguros contra la inyección
Aquí está la lección que trasciende a D1. Cuando construyes una consulta pegando la entrada del usuario dentro del texto del SQL, esa entrada puede dejar de ser un dato y convertirse en instrucciones. Es la inyección de SQL, y sigue siendo, décadas después, una de las vulnerabilidades más explotadas del mundo.
// NUNCA: interpolar la entrada abre la puerta a la inyeccion
const entrada = "'; DROP TABLE usuarios; --";
const malo = env.DB.prepare(`SELECT * FROM usuarios WHERE email = '${entrada}'`);
// SIEMPRE: marcador y bind; la entrada viaja como dato, nunca como codigo
const bueno = env.DB.prepare('SELECT * FROM usuarios WHERE email = ?').bind(entrada);
La diferencia no es de estilo, es de arquitectura. Con bind, D1 envía a la base la estructura de la consulta y los valores por caminos separados: el motor ya ha decidido qué es la consulta antes de mirar tus datos, así que ningún valor que vincules puede alterar esa estructura. La entrada maliciosa del ejemplo, vinculada con bind, se busca literalmente como una dirección de correo rarísima que no existe, y no ejecuta nada. Interpolada en la cadena, en cambio, se lee como SQL y borra tu tabla.
Los marcadores solo vinculan valores, no nombres de tabla ni de columna. Cuando lo que de verdad tiene que variar es la estructura —ordenar por una columna que elige el usuario, por ejemplo— no interpoles su texto a ciegas: mapéalo contra una lista blanca de valores permitidos, de modo que la entrada elija entre opciones seguras en vez de escribir SQL.
// la entrada elige una clave, no escribe SQL: solo columnas permitidas pasan
const columnas = { fecha: 'creado_en', correo: 'email' } as const;
const columna = columnas[orden] ?? 'creado_en';
const { results } = await env.DB.prepare(`SELECT * FROM usuarios ORDER BY ${columna}`).all();
La tentación de concatenar aparece con valores que parecen inofensivos: un identificador numérico, un valor que crees controlar. No lo hagas nunca. Los valores van siempre por bind. Y cuando lo que varía es un nombre de tabla o de columna que no se puede parametrizar, valídalo contra una lista blanca como la del ejemplo, nunca contra la esperanza de que la entrada sea buena. La regla es absoluta porque la única forma segura de tratar la entrada es asumir que toda ella es hostil.
Por qué parametrizar siempre no es negociable
La inyección de SQL es un caso particular de una confusión mucho más honda, que reaparece en casi todas las vulnerabilidades graves de la historia del software: la mezcla entre el canal de control y el canal de datos. Cuando construyes una consulta concatenando texto, estás fundiendo en una sola cadena dos cosas que deberían vivir en planos distintos —la instrucción que quieres ejecutar y el dato sobre el que quieres operar— y le pides al motor que las distinga por su cuenta a base de comillas y escapes. Esa distinción es imposible de hacer bien de forma fiable, porque un atacante que controla el dato puede fabricar exactamente los caracteres que rompen tu escape y cruzan la frontera hacia el canal de control. Los parámetros vinculados resuelven el problema no mejorando el escapado, sino aboliendo la mezcla: la consulta viaja por un canal y los valores por otro, y el motor compila la estructura antes de siquiera mirar los datos, de modo que ninguna combinación de bytes en un valor puede reinterpretarse como instrucción. Por eso bind no es una comodidad ni una optimización de rendimiento —aunque también reutilice el plan de la consulta—, sino la materialización de un principio de diseño: mantén separados el control y los datos, siempre, en todas partes. Ese mismo principio explica por qué se escapan las plantillas de HTML para evitar el cross-site scripting, por qué se separan los argumentos de un comando del shell, por qué se firman los tokens en vez de confiar en su contenido. Interiorizar que la entrada del usuario nunca debe poder convertirse en instrucción es, probablemente, lo más valioso que te llevas de esta lección, porque no caduca con D1 ni con SQL: es la forma de pensar que separa el código que se puede atacar del que no.
- Prepara una consulta con dos marcadores y vincúlalos con
binden el orden correcto; ejecútala conally leeresults. - Cambia esa misma consulta a
firsty observa que ahora recibes un objeto onullen vez de una lista. - Haz un
INSERTconrune inspecciona los metadatos: cuántas filas cambiaron y el último identificador. - Escribe la versión insegura de una consulta con interpolación, razona qué entrada la rompería y reescríbela con
bind.