035.3 Lección 1
Certificación: |
Conceptos básicos de desarrollo web |
---|---|
Versión: |
1.0 |
Tema: |
035 Programación NodeJS server |
Objetivo: |
035.3 Conceptos básicos de SQL |
Lección: |
1 de 1 |
Introducción
Aunque puede escribir sus propias funciones para implementar el almacenamiento persistente, puede ser más conveniente utilizar un sistema de administración de bases de datos para acelerar el desarrollo y garantizar una mejor seguridad y estabilidad para los datos con formato de tabla. La estrategia más popular para almacenar datos organizados en tablas interrelacionadas, especialmente cuando esas tablas son muy consultadas y actualizadas, es instalar una base de datos relacional que admita Structured Query Language (SQL), un lenguaje orientado a bases de datos relacionales. Node.js es compatible con varios sistemas de administración de bases de datos SQL. Siguiendo los principios de portabilidad y ejecución del espacio de usuario adoptados por Node.js Express, SQLite es una opción adecuada para el almacenamiento persistente de datos utilizados por este tipo de servidor HTTP.
SQL
El lenguaje de consulta estructurado es específico de las bases de datos. Las operaciones de escritura y lectura se expresan en instrucciones llamadas sentencias y consultas. Tanto las sentencias como las consultas se componen de cláusulas, que definen las condiciones para ejecutar la operación.
Los nombres y las direcciones de correo electrónico, por ejemplo, se pueden almacenar en una tabla de base de datos que contiene los campos name
e email
. Una base de datos puede contener varias tablas, por lo que cada tabla debe tener un nombre único. Si usamos el nombre contacts
para la tabla de nombres y correos electrónicos, se puede insertar un nuevo registro con la siguiente declaración:
INSERT INTO contacts (name, email) VALUES ("Carol", "carol@example.com");
Esta declaración de inserción se compone de la cláusula INSERT INTO
, que define la tabla y los campos donde se insertarán los datos. La segunda cláusula, VALUES
, establece los valores que se insertarán. No es necesario usar mayúsculas en las cláusulas, pero es una práctica común para reconocer mejor las palabras claves SQL dentro de una declaración o consulta.
Una consulta en la tabla de contactos se realiza de manera similar, pero usando la cláusula SELECT
:
SELECT email FROM contacts;
dave@example.com
carol@example.com
En este caso, la cláusula SELECT email
selecciona un campo de las entradas en la tabla de contacts
. La cláusula WHERE
restringe la consulta a filas específicas:
SELECT email FROM contacts WHERE name = "Dave";
dave@example.com
SQL tiene muchas otras cláusulas, y veremos algunas de ellas en secciones posteriores. Pero primero es necesario ver cómo integrar la base de datos SQL con Node.js.
SQLite
SQLite es probablemente la solución más simple para incorporar características de base de datos SQL en una aplicación. A diferencia de otros sistemas de administración de bases de datos populares, SQLite no es un servidor de base de datos al que se conecta un cliente. En cambio, SQLite proporciona un conjunto de funciones que permiten al desarrollador crear una base de datos como un archivo convencional. En el caso de un servidor HTTP implementado con Node.js Express, este archivo generalmente se encuentra en el mismo directorio que el script del servidor.
Antes de usar SQLite en Node.js, necesita instalar el módulo sqlite3
. Ejecute el siguiente comando en el directorio de instalación del servidor; es decir, el directorio que contiene el script Node.js que ejecutará.
$ npm install sqlite3
Tenga en cuenta que hay varios módulos que admiten SQLite, como better-sqlite3
, cuyo uso es sutilmente diferente de sqlite3
. Los ejemplos de esta lección son para el módulo sqlite3
, por lo que es posible que no funcionen como se esperaba si elige otro módulo.
Abrir la base de datos
Para demostrar cómo un servidor Node.js Express puede funcionar con una base de datos SQL, escribamos un script que almacene y muestre los mensajes enviados por un cliente identificado por una cookie. El cliente envía los mensajes a través del método HTTP POST y la respuesta del servidor puede formatearse como JSON o HTML (a partir de una plantilla), según el formato solicitado por el cliente. Esta lección no entrará en detalles sobre el uso de métodos, cookies y plantillas HTTP. Los fragmentos de código que se muestran aquí asumen que ya tiene un servidor Node.js Express donde estas funciones están configuradas y disponibles.
La forma más sencilla de almacenar los mensajes enviados por el cliente es almacenarlos en un arreglo global, donde cada mensaje enviado previamente se asocia con una clave de identificación única para cada cliente. Esta clave se puede enviar al cliente como una cookie, que se presenta al servidor en futuras solicitudes para recuperar sus mensajes anteriores.
Sin embargo, este enfoque tiene una debilidad: debido a que los mensajes se almacenan solo en un arreglo global, todos los mensajes se perderán cuando finalice la sesión actual del servidor. Ésta es una de las ventajas de trabajar con bases de datos, porque los datos se almacenan de forma persistente y no se pierden si se reinicia el servidor.
Usando el archivo index.js
como el script principal del servidor, podemos incorporar el módulo sqlite3
e indicar el archivo que sirve como base de datos, de la siguiente manera:
const sqlite3 = require('sqlite3')
const db = new sqlite3.Database('messages.sqlite3');
Si aún no existe, el archivo messages.sqlite3
se creará en el mismo directorio que el archivo index.js
. Dentro de este único archivo, se almacenarán todas las estructuras y los datos respectivos. Todas las operaciones de la base de datos realizadas en el script serán intermediadas por la constante db
, que es el nombre dado al nuevo objeto sqlite3
que abre el archivo messages.sqlite3
.
Estructura de una Tabla
No se pueden insertar datos en la base de datos hasta que se cree al menos una tabla. Las tablas se crean con la instrucción CREATE TABLE
:
db.run('CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid CHAR(36), message TEXT)')
El método db.run()
se utiliza para ejecutar sentencias SQL en la base de datos. La declaración en sí está escrita como un parámetro para el método. Aunque las instrucciones SQL deben terminar con un punto y coma cuando se ingresan en un procesador de línea de comandos, el punto y coma es opcional en las instrucciones que se pasan como parámetros en un programa.
Debido a que el método run
se ejecutará cada vez que se ejecute el script con node index.js
, la declaración SQL incluye la cláusula condicional IF NOT EXISTS
para evitar errores en ejecuciones futuras cuando la tabla messages
ya exista.
Los campos que componen la tabla messages
son id
, uuid
y message
. El campo id
es un número entero único que se utiliza para identificar cada entrada en la tabla, por lo que se crea como PRIMARY KEY
. Las claves primarias no pueden ser nulas y no puede haber dos claves primarias idénticas en la misma tabla. Por lo tanto, casi todas las tablas SQL tienen una clave principal para rastrear el contenido de la tabla. Aunque es posible elegir explícitamente el valor de la clave primaria de un nuevo registro (siempre que aún no exista en la tabla), es conveniente que la clave se genere automáticamente. La marca AUTOINCREMENT
en el campo id
se utiliza para este propósito.
Note
|
La configuración explícita de claves primarias en SQLite es opcional, porque SQLite mismo crea una clave primaria automáticamente. Como se indica en la documentación de SQLite: “En SQLite, las filas de la tabla normalmente tienen un entero de 64 bits con signo |
Los campos uuid
y message
almacenan la identificación del cliente y el contenido del mensaje, respectivamente. Un campo de tipo CHAR (36)
almacena una cantidad fija de 36 caracteres, y un campo de tipo TEXT
almacena textos de longitud arbitraria.
Entrada de datos
La función principal de nuestro servidor de ejemplo es almacenar mensajes que están vinculados al cliente que los envió. El cliente envía el mensaje en el campo message
en el cuerpo de la solicitud enviada con el método HTTP POST. La identificación del cliente está en una cookie llamada uuid
. Con esta información, podemos escribir la ruta Express para insertar nuevos mensajes en la base de datos:
app.post('/', (req, res) => {
let uuid = req.cookies.uuid
if ( uuid === undefined )
uuid = uuidv4()
// Insert new message into the database
db.run('INSERT INTO messages (uuid, message) VALUES (?, ?)', uuid, req.body.message)
// If an error occurrs, err object contains the error message.
db.all('SELECT id, message FROM messages WHERE uuid = ?', uuid, (err, rows) => {
let expires = new Date(Date.now());
expires.setDate(expires.getDate() + 30);
res.cookie('uuid', uuid, { expires: expires })
if ( req.headers.accept == "application/json" )
res.json(rows)
else
res.render('index', {title: "My messages", rows: rows})
})
})
Esta vez, el método db.run()
ejecuta una instrucción de inserción, pero tenga en cuenta que el uuid
y req.body.message
no se escriben directamente en la línea de instrucción. En cambio, se sustituyeron los valores por signos de interrogación. Cada signo de interrogación corresponde a un parámetro que sigue a la declaración SQL en el método db.run()
.
El uso de signos de interrogación como marcadores de posición en la declaración que se ejecuta en la base de datos facilita a SQLite distinguir entre los elementos estáticos de la declaración y sus datos variables. Esta estrategia permite que SQLite escape o sanitice el contenido de las variables que son parte de la declaración, evitando una brecha de seguridad común llamada SQL injection. En ese ataque, los usuarios malintencionados insertan declaraciones SQL en los datos variables con la esperanza de que las declaraciones se ejecuten inadvertidamente; sanitizar frustra el ataque al deshabilitar los caracteres peligrosos en los datos.
Consultas
Como se muestra en el código de ejemplo, nuestra intención es usar la misma ruta para insertar nuevos mensajes en la base de datos y generar la lista de mensajes enviados previamente. El método db.all()
devuelve la colección de todas las entradas en la tabla que coinciden con los criterios definidos en la consulta.
A diferencia de las sentencias realizadas por db.run()
, db.all()
genera una lista de registros que son manejados por la función de flecha designada en el último parámetro:
(err, rows) => {}
Esta función, a su vez, toma dos parámetros: err
y rows
. El parámetro err
se utilizará si se produce un error que impida la ejecución de la consulta. Si tiene éxito, todos los registros están disponibles en el arreglo de rows
, donde cada elemento es un objeto correspondiente a un solo registro de la tabla. Las propiedades de este objeto corresponden a los nombres de campo indicados en la consulta: uuid
y message
.
El arreglo de rows
es una estructura de datos de JavaScript. Como tal, se puede utilizar para generar respuestas con métodos proporcionados por Express, como res.json()
y res.render()
. Cuando se representa dentro de una plantilla EJS, un bucle convencional puede enumerar todos los registros:
<ul>
<% rows.forEach( (row) => { %>
<li><strong><%= row.id %></strong>: <%= row.message %></li>
<% }) %>
</ul>
En lugar de llenar el arreglo de rows
con todos los registros devueltos por la consulta, en algunos casos puede ser más conveniente tratar cada registro individualmente con el método db.each()
. La sintaxis del método db.each()
es similar al método db.all()
, pero el parámetro row
en (err, row) ⇒ {}
coincide con un solo registro a la vez.
Modificar el contenido de la base de datos
Hasta ahora, nuestro cliente solo puede agregar y consultar mensajes en el servidor. Dado que el cliente ahora conoce el id
de los mensajes enviados anteriormente, podemos implementar una función para modificar un registro específico. El mensaje modificado también se puede enviar a una ruta del método HTTP POST, pero esta vez con un parámetro de ruta para capturar el id
dado por el cliente en la ruta de la solicitud:
app.post('/:id', (req, res) => {
let uuid = req.cookies.uuid
if ( uuid === undefined ){
uuid = uuidv4()
// 401 Unauthorized
res.sendStatus(401)
}
else {
// Update the stored message
// using named parameters
let param = {
$message: req.body.message,
$id: req.params.id,
$uuid: uuid
}
db.run('UPDATE messages SET message = $message WHERE id = $id AND uuid = $uuid', param, function(err){
if ( this.changes > 0 )
{
// A 204 (No Content) status code means the action has
// been enacted and no further information is to be supplied.
res.sendStatus(204)
}
else
res.sendStatus(404)
})
}
})
Esta ruta demuestra cómo usar las cláusulas UPDATE
y WHERE
para modificar un registro existente. Una diferencia importante con los ejemplos anteriores es el uso de parámetros con nombre, donde los valores se agrupan en un solo objeto (param
) y se pasan al método db.run()
en lugar de especificar cada valor por sí mismo. En este caso, los nombres de los campos (precedidos por $
) son las propiedades del objeto. Los parámetros con nombre permiten el uso de nombres de campo (precedidos por $
) como marcadores de posición en lugar de signos de interrogación.
Una declaración como la del ejemplo no causará ninguna modificación a la base de datos si la condición impuesta por la cláusula WHERE
no coincide con algún registro en la tabla. Para evaluar si la declaración modificó algún registro, se puede usar una función de devolución de llamada como último parámetro del método db.run()
. Dentro de la función, el número de registros modificados se puede consultar desde this.changes
. Tenga en cuenta que las funciones de flecha no se pueden usar en este caso, porque solo las funciones regulares de la forma function(){}
definen el objeto this
.
Eliminar un registro es muy similar a modificarlo. Podemos, por ejemplo, continuar usando el parámetro de ruta :id
para identificar el mensaje a eliminar, pero esta vez en una ruta invocada por el método HTTP DELETE del cliente:
app.delete('/:id', (req, res) => {
let uuid = req.cookies.uuid
if ( uuid === undefined ){
uuid = uuidv4()
res.sendStatus(401)
}
else {
// Named parameters
let param = {
$id: req.params.id,
$uuid: uuid
}
db.run('DELETE FROM messages WHERE id = $id AND uuid = $uuid', param, function(err){
if ( this.changes > 0 )
res.sendStatus(204)
else
res.sendStatus(404)
})
}
})
Los registros se eliminan de una tabla con la cláusula DELETE FROM
. De nuevo usamos la función de devolución de llamada para evaluar cuántas entradas se han eliminado de la tabla.
Cerrar la base de datos
Una vez definido, se puede hacer referencia al objeto db
en cualquier momento durante la ejecución del script, porque el archivo de la base de datos permanece abierto durante la sesión actual. No es común cerrar la base de datos mientras se ejecuta el script.
Sin embargo, una función para cerrar la base de datos es útil para evitar cerrar abruptamente la base de datos cuando finaliza el proceso del servidor. Aunque es poco probable, el cierre abrupto de la base de datos puede generar incoherencias si los datos en memoria aún no se han confirmado en el archivo. Por ejemplo, un cierre abrupto de la base de datos con pérdida de datos puede ocurrir si el usuario finaliza el script presionando el atajo de teclado Ctrl+C.
En el escenario Ctrl+C que se acaba de describir, el método process.on()
puede interceptar las señales enviadas por el sistema operativo y ejecutar un apagado ordenado tanto de la base de datos como del servidor:
process.on('SIGINT', () => {
db.close()
server.close()
console.log('HTTP server closed')
})
El atajo Ctrl+C invoca la señal del sistema operativo SIGINT
, que termina un programa en primer plano en la terminal. Antes de finalizar el proceso al recibir la señal SIGINT
, el sistema invoca la función de devolución de llamada (el último parámetro en el método process.on()
). Dentro de la función de devolución de llamada, puede poner cualquier código de limpieza, en particular el método db.close()
para cerrar la base de datos y server.close()
, que cierra elegantemente la propia instancia Express.
Ejercicios guiados
-
¿Cuál es el propósito de una llave primaria en una tabla de base de datos SQL?
-
¿Cuál es la diferencia entre consultar usando
db.all()
ydb.each()
? -
¿Por qué es importante utilizar marcadores de posición y no incluir los datos enviados por el cliente directamente en una instrucción o consulta SQL?
Ejercicios de exploración
-
¿Qué método del módulo
sqlite3
se puede utilizar para devolver solo una entrada de tabla, incluso si la consulta coincide con varias entradas? -
Suponga que el arreglo de
rows
se pasó como un parámetro a una función de devolución de llamada y contiene el resultado de una consulta realizada condb.all()
. ¿Cómo se puede hacer referencia a un campo llamadoprice
, que está presente en la primera posición derows
, dentro de la función de devolución de llamada? -
El método
db.run ()
ejecuta sentencias de modificación de la base de datos, comoINSERT INTO
. Después de insertar un nuevo registro en una tabla, ¿cómo podría recuperar la clave principal del registro recién insertado?
Resumen
Esta lección cubre el uso básico de bases de datos SQL dentro de las aplicaciones Node.js Express. El módulo sqlite3
ofrece una forma sencilla de almacenar datos persistentes en una base de datos SQLite, donde un solo archivo contiene toda la información y no requiere un servidor de base de datos especializado. Esta lección abarca los siguientes conceptos y procedimientos:
-
Cómo establecer una conexión a la base de datos desde Node.js.
-
Cómo crear una tabla simple y el rol de las claves primarias.
-
Usar la instrucción SQL
INSERT INTO
para agregar nuevos datos desde el script. -
Consultas SQL utilizando métodos estándares SQLite y funciones de devolución de llamada.
-
Modificación de datos en la base de datos usando sentencias SQL
UPDATE
yDELETE
.
Respuestas a los ejercicios guiados
-
¿Cuál es el propósito de una llave primaria en una tabla de base de datos SQL?
La llave primaria es el campo de identificación único para cada registro dentro de una tabla de una base de datos.
-
¿Cuál es la diferencia entre consultar usando
db.all()
ydb.each()
?El método
db.all()
invoca la función de devolución de llamada con un único arreglo que contiene todas las entradas correspondientes a la consulta. El métododb.each()
invoca la función de devolución de llamada para cada fila de resultados. -
¿Por qué es importante utilizar marcadores de posición y no incluir los datos enviados por el cliente directamente en una instrucción o consulta SQL?
Con los marcadores de posición, los datos enviados por el usuario se escapan antes de ser incluidos en la consulta o declaración. Esto dificulta los ataques de inyección de SQL, donde las sentencias de SQL se colocan dentro de datos variables en un intento de realizar operaciones arbitrarias en la base de datos.
Respuestas a los ejercicios de exploración
-
¿Qué método del módulo
sqlite3
se puede utilizar para devolver solo una entrada de tabla, incluso si la consulta coincide con varias entradas?El método
db.get()
tiene la misma sintaxis quedb.all()
, pero devuelve solo la primera entrada correspondiente a la consulta. -
Suponga que el arreglo de
rows
se pasó como un parámetro a una función de devolución de llamada y contiene el resultado de una consulta realizada condb.all()
. ¿Cómo se puede hacer referencia a un campo llamadoprice
, que está presente en la primera posición derows
, dentro de la función de devolución de llamada?Cada elemento en
rows
es un objeto cuyas propiedades corresponden a los nombres de los campos de la base de datos. Por tanto, el valor del campoprice
en el primer resultado está en lasrows[0].price
. -
El método
db.run ()
ejecuta sentencias de modificación de la base de datos, comoINSERT INTO
. Después de insertar un nuevo registro en una tabla, ¿cómo podría recuperar la clave principal del registro recién insertado?Una función regular de la forma
function(){}
se puede utilizar como función de devolución de llamada del métododb.run()
. En su interior, la propiedadthis.lastID
contiene el valor de la clave principal del último registro insertado.