24

Bases de datos desde Bash

Vistas: 1
Tutorial: Scripts en Bash
Bases de datos desde Bash

Si has llegado hasta aquí, dominas variables, bucles, condicionales, funciones, arrays, diccionarios, redirecciones, tuberías, coprocesos y señales. Pero todos tus datos viven en archivos de texto plano — CSV, JSON, logs, configuraciones. ¿Y si necesitas consultas estructuradas, relaciones entre datos, transacciones atómicas o respaldos consistentes?

Ahí entran las bases de datos.

Bash no es un lenguaje de aplicaciones web, pero es el mejor lenguaje para automatización de infraestructura. Y la infraestructura moderna habla con bases de datos: respaldos, migraciones, informes periódicos, limpieza de datos, agregación de logs, monitoreo de métricas. Todo eso se hace desde Bash.

En este capítulo verás como,

  • Usar SQLite, la base de datos embebida que no necesita servidor
  • Consultar MySQL/MariaDB y PostgreSQL desde scripts
  • Prevenir SQL injection cuando interpolas variables
  • Exportar datos a JSON y CSV desde cualquier motor
  • Hacer backups automatizados con cron
  • Construir herramientas completas: gestor de contactos, agregador de logs, generador de informes

Filosofía del capítulo: no vas a aprender SQL aquí. Vas a aprender cómo Bash se convierte en el pegamento que conecta tus scripts con los motores de base de datos. Damos SQL por conocido; nos centramos en la orquestación.

SQLite: la base de datos embebida

SQLite es la base de datos más usada del mundo. Está en tu teléfono, en tu navegador, en millones de dispositivos. Y en Bash, es tu mejor aliada porque:

  • No necesita servidor — la base de datos es un archivo .db
  • Viene instalada en prácticamente todos los Linux (sqlite3)
  • Soporta transacciones (ACID) incluso desde scripts
  • Ocupa ~500 KB — no hay dependencias pesadas
  • Permite exportar a JSON, CSV, HTML nativamente

Primeros pasos con sqlite3 CLI

El comando sqlite3 abre una base de datos (la crea si no existe) y entra en modo interactivo:

# Abrir (o crear) una base de datos
sqlite3 contactos.db

# Desde ahí, comandos SQL directamente:
sqlite> CREATE TABLE contactos (
   ...>     id INTEGER PRIMARY KEY AUTOINCREMENT,
   ...>     nombre TEXT NOT NULL,
   ...>     email TEXT UNIQUE,
   ...>     telefono TEXT,
   ...>     creado_en TEXT DEFAULT (datetime('now'))
   ...> );
sqlite> .tables
contactos
sqlite> .quit

Pero el poder real está en no entrar al modo interactivo. Puedes pasar consultas directamente como argumento:

# Consulta única desde línea de comandos
sqlite3 contactos.db "SELECT count(*) FROM contactos;"

# Crear tabla sin entrar al shell
sqlite3 contactos.db "CREATE TABLE IF NOT EXISTS usuarios (id INTEGER PRIMARY KEY, nombre TEXT);"

O usar un heredoc para múltiples consultas:

sqlite3 contactos.db <<EOF
INSERT INTO contactos (nombre, email, telefono) VALUES
    ('Ana García', 'ana@example.com', '555-0101'),
    ('Carlos López', 'carlos@example.com', '555-0102');
SELECT * FROM contactos;
EOF

Comandos punto (dot commands)

Los comandos que empiezan con . son instrucciones para el CLI de sqlite3, no SQL. Controlan el formato de salida, la carga de datos y la configuración:

# .headers ON — Muestra nombres de columna
sqlite3 -headers contactos.db "SELECT * FROM contactos;"
# id|nombre|email|telefono|creado_en
# 1|Ana García|ana@example.com|555-0101|2026-06-11 10:00:00

# .mode — Controla el formato de salida
sqlite3 -header -column contactos.db "SELECT * FROM contactos;"
# id  nombre        email              telefono   creado_en
# --  ------------  -----------------  ---------  -------------------
# 1   Ana García    ana@example.com    555-0101   2026-06-11 10:00:00
# 2   Carlos López  carlos@example.com 555-0102   2026-06-11 10:00:05

# .tables — Lista tablas
sqlite3 contactos.db ".tables"

# .schema — Muestra el CREATE TABLE de cada tabla
sqlite3 contactos.db ".schema"
# CREATE TABLE contactos (
#     id INTEGER PRIMARY KEY AUTOINCREMENT,
#     nombre TEXT NOT NULL,
# ...

# .dump — Volca toda la base como sentencias SQL (ideal para backups)
sqlite3 contactos.db ".dump" > backup.sql

# .import — Carga un CSV en una tabla
sqlite3 contactos.db ".import --csv datos.csv usuarios"

# .output — Redirige la salida a un archivo
sqlite3 contactos.db <<EOF
.output reporte.txt
.mode column
.headers on
SELECT * FROM contactos;
EOF

Puedes combinar varios comandos punto en un solo heredoc:

sqlite3 ventas.db <<EOF
.mode column
.headers on
.width 5 20 10 10
SELECT id, producto, cantidad, precio FROM ventas
WHERE fecha >= date('now', '-7 days');
EOF

INSERT, UPDATE, DELETE desde Bash

La forma más legible de hacer operaciones CRUD desde Bash es con heredocs:

DB="tienda.db"

# INSERT
sqlite3 "$DB" <<EOF
INSERT INTO productos (nombre, precio, stock)
VALUES ('Laptop X200', 899.99, 15);
EOF

# UPDATE
sqlite3 "$DB" <<EOF
UPDATE productos
SET stock = stock - 1
WHERE id = 42 AND stock > 0;
EOF

# DELETE
sqlite3 "$DB" <<EOF
DELETE FROM productos WHERE stock <= 0;
EOF

# SELECT con captura de resultado
EXISTE=$(sqlite3 "$DB" "SELECT count(*) FROM productos WHERE id = 42;")
if [[ "$EXISTE" -gt 0 ]]; then
    echo "El producto 42 existe"
fi

# SELECT con múltiples columnas (separadas por | por defecto)
sqlite3 -separator ' | ' "$DB" "SELECT id, nombre, precio FROM productos;"

Crear tablas con heredoc

Para scripts que crean su propia base de datos al vuelo, el patrón estándar es:

DB="/var/lib/mi_app/datos.db"
mkdir -p "$(dirname "$DB")"

sqlite3 "$DB" <<'EOF'
CREATE TABLE IF NOT EXISTS usuarios (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    username    TEXT UNIQUE NOT NULL,
    email       TEXT NOT NULL,
    hash_pass   TEXT NOT NULL,
    creado_en   TEXT DEFAULT (datetime('now')),
    activo      INTEGER DEFAULT 1
);

CREATE TABLE IF NOT EXISTS logs (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    usuario_id  INTEGER REFERENCES usuarios(id),
    accion      TEXT NOT NULL,
    timestamp   TEXT DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_logs_usuario ON logs(usuario_id);
CREATE INDEX IF NOT EXISTS idx_logs_fecha ON logs(timestamp);
EOF

El 'EOF' con comillas evita la expansión de variables dentro del heredoc — importante cuando el SQL contiene caracteres que Bash podría interpretar.

.import — Cargar CSV directamente

SQLite puede importar archivos CSV directamente sin necesidad de scripts externos:

# Crear la tabla (sqlite3 no la crea automáticamente)
sqlite3 datos.db "CREATE TABLE IF NOT EXISTS ventas (
    id INTEGER, producto TEXT, cantidad INTEGER, precio REAL, fecha TEXT
);"

# Importar el CSV
sqlite3 datos.db ".import --csv ventas.csv ventas"

# Verificar
sqlite3 -header -column datos.db "SELECT * FROM ventas LIMIT 5;"

El flag --csv (disponible desde SQLite 3.32, 2020) indica que el archivo es CSV con comillas y escapes. Sin él, sqlite3 asume un formato más simple (pipe-delimited).

Para CSV sin cabecera (solo datos):

sqlite3 datos.db ".import --csv --skip 1 datos_con_cabecera.csv mi_tabla"

.output y .dump — Exportar y respaldar

# Exportar consulta a archivo de texto
sqlite3 db.sqlite <<EOF
.mode column
.headers on
.output /tmp/reporte_ventas.txt
SELECT * FROM ventas WHERE fecha >= '2026-01-01';
EOF

# Volcar toda la base a SQL (dump)
sqlite3 db.sqlite ".dump" > respaldo_$(date +%F).sql

# Volcar solo una tabla
sqlite3 db.sqlite ".dump contactos" > contactos_backup.sql

# Restaurar desde dump
sqlite3 db_restaurada.db < respaldo_2026-06-11.sql

-json y -csv — Formatos de salida estructurados

SQLite 3.33+ (2020) añadió salida nativa a JSON. Es una de las características más potentes para scripts modernos:

# Salida JSON (cada fila es un objeto, todas en un array)
sqlite3 -json contactos.db "SELECT id, nombre, email FROM contactos;"
# [{"id":1,"nombre":"Ana García","email":"ana@example.com"},
#  {"id":2,"nombre":"Carlos López","email":"carlos@example.com"}]

# Salida CSV (con cabecera)
sqlite3 -csv -header contactos.db "SELECT * FROM contactos;" > contactos_export.csv

# Combinar con jq para procesamiento avanzado
sqlite3 -json contactos.db "SELECT * FROM contactos;" | \
    jq '.[] | {nombre: .nombre, email: .email}'

# Formato HTML (ideal para informes rápidos)
sqlite3 -html contactos.db "SELECT * FROM contactos;" > tabla.html

Prevención de SQL injection con printf %q

Este es el punto más importante del capítulo. Cuando interpolas variables de Bash en consultas SQL, abres la puerta a SQL injection. Un usuario que introduce '; DROP TABLE contactos; -- como nombre puede destruir tu base de datos.

Bash tiene una herramienta built-in para esto: printf %q. Escapa cualquier cadena para que sea segura en SQL:

# ❌ PELIGRO: SQL injection directa
NOMBRE="Ana'; DROP TABLE contactos; --"
sqlite3 db.db "INSERT INTO usuarios (nombre) VALUES ('$NOMBRE');"
# ¡La tabla contactos acaba de desaparecer!

# ✅ SEGURO: printf %q escapa la cadena
NOMBRE="Ana'; DROP TABLE contactos; --"
NOMBRE_SEGURO=$(printf '%q' "$NOMBRE")
sqlite3 db.db "INSERT INTO usuarios (nombre) VALUES ($NOMBRE_SEGURO);"
# Se inserta literalmente: Ana'; DROP TABLE contactos; --

printf %q escapa caracteres especiales de shell, lo que también funciona para SQL porque escapa comillas simples. Crea una función genérica:

# Función de sanitización para SQLite
sql_escape() {
    printf '%q' "$1"
}

# Uso
NOMBRE=$(sql_escape "$nombre_usuario")
EMAIL=$(sql_escape "$email_usuario")
sqlite3 db.db "INSERT INTO usuarios (nombre, email) VALUES ($NOMBRE, $EMAIL);"

Importante: printf %q escapa para shell, no para SQL. En la práctica, para SQLite con valores entrecomillados con comillas simples, funciona perfectamente porque escapa la comilla simple como '\'', que SQL interpreta como una comilla literal dentro de la cadena. Para MySQL/PostgreSQL, considera usar consultas parametrizadas o escapar específicamente.

Para MySQL, puedes usar mysql_real_escape_string si usas mysql --safe-updates, pero no hay equivalente directo desde Bash. La mejor práctica es nunca interpolar directamente:

# Alternativa: usar variables de sqlite3 (3.44+)
sqlite3 db.db "INSERT INTO usuarios (nombre) VALUES (@nombre)" \
    -bind @nombre "$nombre_usuario"

MySQL/MariaDB desde línea de comandos

MySQL y MariaDB requieren un servidor en ejecución, pero el cliente mysql desde Bash es igual de potente que sqlite3.

Consultas inline con mysql -e

# Consulta simple
mysql -u usuario -p -h localhost -e "SELECT * FROM empleados;"

# Con base de datos específica
mysql -u usuario -p mi_base -e "SELECT count(*) FROM empleados;"

El flag -e ejecuta una consulta y termina. Sin -e, mysql entra en modo interactivo.

Modos batch, silent y raw

Para scripting, estos flags son esenciales:

# --batch: formato tabulado, sin bordes, sin cabecera
mysql -u root -p --batch -e "SELECT id, nombre FROM empleados;"

# --silent: aún más silencioso (oculta cabeceras y contadores)
mysql -u root -p --silent -e "SELECT count(*) FROM empleados;"
# 42

# --raw: no escapa caracteres especiales
mysql -u root -p --raw --batch -e "SELECT nombre FROM empleados;"

# Combinación típica para scripting
mysql -u root -p --batch --silent --raw -e "SELECT email FROM empleados WHERE id=1;"

.my.cnf y mysql_config_editor

Nunca pongas contraseñas en scripts. Usa archivos de configuración seguros:

Opción 1: ~/.my.cnf (permisos 600)

# ~/.my.cnf
[client]
user = root
password = MiPasswordSegura
host = localhost
# Uso: ya no necesitas -p
mysql -e "SHOW DATABASES;"

Opción 2: mysql_config_editor (más seguro, almacena credenciales ofuscadas)

# Configurar login
mysql_config_editor set --login-path=produccion \
    --host=db.example.com --user=admin --password

# Usarlo en scripts
mysql --login-path=produccion -e "SHOW DATABASES;"

# Listar logins configurados
mysql_config_editor print --all

# Eliminar un login
mysql_config_editor remove --login-path=produccion

mysqldump — Backup y restauración

# Backup de una base de datos
mysqldump -u root mi_base > /backups/mi_base_$(date +%F).sql

# Backup comprimido
mysqldump -u root mi_base | gzip > /backups/mi_base_$(date +%F).sql.gz

# Backup de varias bases
mysqldump -u root --databases db1 db2 db3 > multi_backup.sql

# Backup de todas las bases
mysqldump -u root --all-databases > all_backup.sql

# Backup solo estructura (sin datos)
mysqldump -u root --no-data mi_base > esquema.sql

# Restaurar
mysql -u root mi_base < /backups/mi_base_2026-06-11.sql

# Restaurar desde gzip
gunzip < /backups/mi_base_2026-06-11.sql.gz | mysql -u root mi_base

Flags útiles para scripting:

mysqldump \
    --single-transaction \   # Consistencia sin bloquear tablas (InnoDB)
    --routines \             # Incluye procedimientos almacenados
    --triggers \             # Incluye triggers
    --events \               # Incluye eventos
    --quick \                # No cachear en memoria
    --compress \             # Comprimir en la conexión
    -u root \
    mi_base > respaldo.sql

PostgreSQL desde línea de comandos

PostgreSQL usa psql como cliente CLI. Es más verboso que mysql pero igual de potente para scripting.

psql -c, -t, -A, -X

# Consulta simple con -c
psql -U postgres -d mi_base -c "SELECT count(*) FROM usuarios;"

# -t (--tuples-only): solo filas, sin cabecera ni pie
psql -U postgres -d mi_base -t -c "SELECT count(*) FROM usuarios;"

# -A (--no-align): salida sin relleno, ideal para scripting
psql -U postgres -d mi_base -A -t -c "SELECT email FROM usuarios;"

# -X (--no-psqlrc): no leer .psqlrc (evita configuraciones locales)
psql -U postgres -X -c "SELECT 1;"

# Combinación típica para scripting
psql -U postgres -d mi_base -t -A -X -c "SELECT email FROM usuarios WHERE id=1;"

PGPASSWORD y .pgpass

Opción 1: Variable PGPASSWORD (útil pero visible en ps)

export PGPASSWORD="MiPasswordSegura"
psql -U postgres -d mi_base -c "SELECT now();"
unset PGPASSWORD  # Limpiar después

Opción 2: Archivo ~/.pgpass (recomendado)

# Formato: host:puerto:base_datos:usuario:password
# ~/.pgpass
localhost:5432:mi_base:postgres:MiPasswordSegura
*:*:*:postgres:MiPasswordSegura
# Permisos estrictos
chmod 600 ~/.pgpass
psql -U postgres -d mi_base -c "SELECT 1;"   # No pide password

Opción 3: Variable PGPASSFILE (para rutas alternativas)

export PGPASSFILE="/etc/secrets/pg_produccion.pass"
psql -U admin -d produccion -c "SELECT 1;"

pg_dump — Backup y restauración

# Backup de una base de datos
pg_dump -U postgres mi_base > /backups/mi_base_$(date +%F).sql

# Backup comprimido
pg_dump -U postgres mi_base | gzip > /backups/mi_base_$(date +%F).sql.gz

# Backup solo estructura (sin datos)
pg_dump -U postgres --schema-only mi_base > esquema.sql

# Backup solo datos (sin estructura)
pg_dump -U postgres --data-only mi_base > datos.sql

# Backup en formato custom (paralelizable, comprimido)
pg_dump -U postgres -Fc mi_base > mi_base.dump

# Restaurar formato SQL
psql -U postgres mi_base < /backups/mi_base_2026-06-11.sql

# Restaurar formato custom
pg_restore -U postgres -d mi_base mi_base.dump

# Backup de todas las bases (como postgres)
pg_dumpall -U postgres > all_backup.sql

Importación y exportación de datos

Bash es el mejor lugar para tuberías de datos entre formatos. Aquí tienes los patrones esenciales.

CSV a SQLite

DB="analisis.db"

# Método 1: .import directo
sqlite3 "$DB" <<EOF
.mode csv
.import ventas_mensuales.csv ventas
EOF

# Método 2: desde línea
sqlite3 "$DB" ".import --csv ventas_mensuales.csv ventas"

# Método 3: creando la tabla explícitamente primero
sqlite3 "$DB" "CREATE TABLE IF NOT EXISTS ventas (
    id INTEGER, producto TEXT, cantidad INTEGER, total REAL
);"
sqlite3 "$DB" ".import --csv ventas_mensuales.csv ventas"

SQLite a JSON

DB="tienda.db"

# Exportar toda una tabla a JSON
sqlite3 -json "$DB" "SELECT * FROM productos WHERE stock > 0;" > inventario.json

# Exportar y procesar con jq
sqlite3 -json "$DB" "
    SELECT p.nombre, p.precio, c.nombre AS categoria
    FROM productos p
    JOIN categorias c ON p.categoria_id = c.id
" | jq 'group_by(.categoria) | {categorias: .}' > categorizado.json

# Exportar consulta agregada
sqlite3 -json "$DB" "
    SELECT strftime('%Y-%m', fecha) AS mes,
           SUM(total) AS ingresos,
           COUNT(*) AS transacciones
    FROM ventas
    GROUP BY mes
    ORDER BY mes
;" > resumen_mensual.json

SQLite a CSV

DB="analisis.db"

# Exportar tabla completa a CSV
sqlite3 -csv -header "$DB" "SELECT * FROM ventas;" > ventas_export.csv

# Exportar consulta con filtros
sqlite3 -csv -header "$DB" "
    SELECT id, producto, cantidad, fecha
    FROM ventas
    WHERE fecha BETWEEN '2026-01-01' AND '2026-06-30'
" > ventas_semestre1.csv

# Exportar y comprimir
sqlite3 -csv "$DB" "SELECT * FROM logs;" | gzip > logs_export.csv.gz

Entre motores: MySQL → SQLite

# 1. Exportar desde MySQL a CSV
mysql -u root --batch --silent --raw -e "
    SELECT id, nombre, email, fecha_registro
    FROM usuarios
" mi_base > /tmp/usuarios.csv

# 2. Crear tabla en SQLite e importar
sqlite3 migracion.db <<EOF
CREATE TABLE usuarios (
    id INTEGER PRIMARY KEY,
    nombre TEXT,
    email TEXT,
    fecha_registro TEXT
);
.mode csv
.import /tmp/usuarios.csv usuarios
EOF

# 3. Verificar
sqlite3 -header -column migracion.db "SELECT count(*) FROM usuarios;"

Mantenimiento automatizado con cron

Las bases de datos necesitan mantenimiento periódico. Bash + cron es la combinación perfecta.

VACUUM, REINDEX, ANALYZE en SQLite

#!/bin/bash
# maintenance_sqlite.sh — Mantenimiento semanal de bases SQLite

DB="/var/data/aplicacion.db"
LOG="/var/log/sqlite_maintenance.log"

fecha=$(date '+%Y-%m-%d %H:%M:%S')

echo "[$fecha] Iniciando mantenimiento de $DB" >> "$LOG"

# VACUUM — Reconstruye la base liberando espacio no usado
sqlite3 "$DB" "VACUUM;"
echo "  VACUUM completado" >> "$LOG"

# REINDEX — Reconstruye índices (útil después de muchas escrituras)
sqlite3 "$DB" "REINDEX;"
echo "  REINDEX completado" >> "$LOG"

# ANALYZE — Actualiza estadísticas para el optimizador de consultas
sqlite3 "$DB" "ANALYZE;"
echo "  ANALYZE completado" >> "$LOG"

# Tamaño antes/después
tamano=$(du -h "$DB" | cut -f1)
echo "  Tamaño actual: $tamano" >> "$LOG"
echo "[$fecha] Mantenimiento completado" >> "$LOG"

Crontab semanal:

# Cada domingo a las 03:00
0 3 * * 0 /usr/local/bin/maintenance_sqlite.sh

Respaldos programados

#!/bin/bash
# backup_diario.sh — Respaldo automático de bases de datos

FECHA=$(date +%F)
HORA=$(date +%H%M)
BASE_DIR="/backups"
LOG="/var/log/backups.log"

mkdir -p "$BASE_DIR"/{sqlite,mysql,postgres}

echo "[$(date '+%Y-%m-%d %H:%M:%S')] Iniciando backups..." >> "$LOG"

# SQLite — copia simple del archivo + dump SQL
for db in /var/data/*.db; do
    nombre=$(basename "$db" .db)
    cp "$db" "$BASE_DIR/sqlite/${nombre}_${FECHA}.db"
    sqlite3 "$db" ".dump" | gzip > "$BASE_DIR/sqlite/${nombre}_${FECHA}.sql.gz"
    echo "  SQLite: $nombre respaldado" >> "$LOG"
done

# MySQL — mysqldump
for db in $(mysql -e "SHOW DATABASES;" | grep -v -E 'Database|information_schema|performance_schema'); do
    mysqldump --single-transaction --routines --triggers "$db" | \
        gzip > "$BASE_DIR/mysql/${db}_${FECHA}.sql.gz"
    echo "  MySQL: $db respaldado" >> "$LOG"
done

# PostgreSQL — pg_dump
export PGPASSWORD="$PG_BACKUP_PASS"
for db in $(psql -U postgres -t -A -c "SELECT datname FROM pg_database
           WHERE datistemplate = false;"); do
    pg_dump -U postgres "$db" | gzip > "$BASE_DIR/postgres/${db}_${FECHA}.sql.gz"
    echo "  PostgreSQL: $db respaldado" >> "$LOG"
done

echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backups completados" >> "$LOG"

Rotación de backups

Nunca acumules backups sin rotación. Un script simple de limpieza:

#!/bin/bash
# rotar_backups.sh — Conserva solo los últimos 30 días de backups

BASE_DIR="/backups"

echo "Rotando backups anteriores a 30 días..."

find "$BASE_DIR" -name "*.sql.gz" -mtime +30 -delete
find "$BASE_DIR" -name "*.db" -mtime +30 -delete
find "$BASE_DIR" -name "*.dump" -mtime +30 -delete

echo "Rotación completada."

Combínalo en un solo cron:

# Backup diario a las 02:00, rotación a las 02:30
0 2 * * * /usr/local/bin/backup_diario.sh
30 2 * * * /usr/local/bin/rotar_backups.sh

Aplicaciones prácticas

Contact manager CRUD

Un gestor de contactos completo desde Bash (la versión completa está en la sección 10). Patrón básico:

CONTACTS_DB="$HOME/.contacts.db"

# Crear tabla si no existe
sqlite3 "$CONTACTS_DB" <<'EOF'
CREATE TABLE IF NOT EXISTS contactos (
    id        INTEGER PRIMARY KEY AUTOINCREMENT,
    nombre    TEXT NOT NULL,
    email     TEXT,
    telefono  TEXT,
    grupo     TEXT DEFAULT 'general',
    creado_en TEXT DEFAULT (datetime('now'))
);
EOF

# Añadir contacto
agregar_contacto() {
    local nombre=$(printf '%q' "$1")
    local email=$(printf '%q' "$2")
    local telefono=$(printf '%q' "$3")
    sqlite3 "$CONTACTS_DB" \
        "INSERT INTO contactos (nombre, email, telefono) VALUES ($nombre, $email, $telefono);"
    echo "Contacto añadido: $1"
}

# Buscar contacto
buscar_contacto() {
    local term=$(printf '%q' "$1")
    sqlite3 -header -column "$CONTACTS_DB" \
        "SELECT id, nombre, email, telefono FROM contactos
         WHERE nombre LIKE '%' || $term || '%'
            OR email LIKE '%' || $term || '%';"
}

# Listar todos
listar_contactos() {
    sqlite3 -header -column "$CONTACTS_DB" \
        "SELECT id, nombre, email, telefono, grupo FROM contactos ORDER BY nombre;"
}

# Eliminar contacto
eliminar_contacto() {
    local id=$(printf '%q' "$1")
    sqlite3 "$CONTACTS_DB" "DELETE FROM contactos WHERE id = $id;"
    echo "Contacto $id eliminado."
}

Log aggregator — Insertar logs en SQLite

Ideal para centralizar logs de múltiples scripts en una base consultable:

LOG_DB="/var/log/agregados/eventos.db"
mkdir -p "$(dirname "$LOG_DB")"

# Inicializar esquema (una vez)
sqlite3 "$LOG_DB" <<'EOF'
CREATE TABLE IF NOT EXISTS eventos (
    id        INTEGER PRIMARY KEY AUTOINCREMENT,
    timestamp TEXT DEFAULT (datetime('now')),
    nivel     TEXT NOT NULL CHECK(nivel IN ('INFO','WARN','ERROR','DEBUG')),
    script    TEXT NOT NULL,
    mensaje   TEXT NOT NULL,
    pid       INTEGER
);
CREATE INDEX IF NOT EXISTS idx_eventos_fecha ON eventos(timestamp);
CREATE INDEX IF NOT EXISTS idx_eventos_nivel ON eventos(nivel);
EOF

# Función para insertar log
log_to_db() {
    local nivel="$1"
    local mensaje="$2"
    local script="${3:-$0}"
    local nivel_s=$(printf '%q' "$nivel")
    local msg_s=$(printf '%q' "$mensaje")
    local script_s=$(printf '%q' "$script")
    sqlite3 "$LOG_DB" \
        "INSERT INTO eventos (nivel, script, mensaje, pid)
         VALUES ($nivel_s, $script_s, $msg_s, $$);"
}

# Uso
log_to_db "INFO" "Iniciando proceso de backup"
log_to_db "ERROR" "No se pudo conectar a la base de datos remota"
log_to_db "WARN" "Disco al 85% de capacidad"

# Consultar errores recientes
sqlite3 -json "$LOG_DB" \
    "SELECT timestamp, script, mensaje FROM eventos
     WHERE nivel = 'ERROR' AND timestamp >= datetime('now', '-1 day')
     ORDER BY timestamp DESC;"

Report generator — Consultas con formato

#!/bin/bash
# reporte_ventas.sh — Genera reporte semanal de ventas

DB="/var/data/tienda.db"
REPORTE_DIR="/var/www/reportes"
FECHA=$(date +%F)
mkdir -p "$REPORTE_DIR"

# Reporte en HTML
sqlite3 -html "$DB" "
    SELECT p.nombre AS Producto,
           SUM(v.cantidad) AS Vendidos,
           SUM(v.total) AS Ingresos
    FROM ventas v
    JOIN productos p ON v.producto_id = p.id
    WHERE v.fecha >= date('now', '-7 days')
    GROUP BY p.nombre
    ORDER BY Ingresos DESC
    LIMIT 10;
" > "$REPORTE_DIR/top_10_${FECHA}.html"

# Reporte en JSON para dashboard
sqlite3 -json "$DB" "
    SELECT strftime('%w', fecha) AS dia_semana,
           SUM(total) AS ingresos
    FROM ventas
    WHERE fecha >= date('now', '-7 days')
    GROUP BY dia_semana
    ORDER BY dia_semana;
" > "$REPORTE_DIR/semana_${FECHA}.json"

# Reporte resumen en texto
{
    echo "=== Reporte Semanal de Ventas ==="
    echo "Fecha: $FECHA"
    echo ""
    echo "Ingresos totales:"
    sqlite3 "$DB" "SELECT printf('$%.2f', SUM(total)) FROM ventas
                   WHERE fecha >= date('now', '-7 days');"
    echo ""
    echo "Transacciones:"
    sqlite3 "$DB" "SELECT COUNT(*) FROM ventas
                   WHERE fecha >= date('now', '-7 days');"
} > "$REPORTE_DIR/resumen_${FECHA}.txt"

echo "Reportes generados en $REPORTE_DIR"

Migration helper — Esquemas versionados

#!/bin/bash
# migrate.sh — Sistema de migraciones para SQLite

DB="/var/data/app.db"
MIGRATIONS_DIR="/usr/local/share/migraciones"
CURRENT=$(sqlite3 "$DB" "PRAGMA user_version;")

echo "Versión actual del esquema: $CURRENT"

for mig in $(ls "$MIGRATIONS_DIR"/*.sql | sort); do
    version=$(basename "$mig" | cut -d'_' -f1)

    if [[ "$version" -gt "$CURRENT" ]]; then
        echo "Aplicando migración $version: $mig"
        sqlite3 "$DB" < "$mig"
        sqlite3 "$DB" "PRAGMA user_version = $version;"
        echo "  ✓ Migración $version aplicada"
    fi
done

echo "Esquema actualizado a versión $(sqlite3 "$DB" 'PRAGMA user_version;')"

Archivos de migración siguen el patrón 001_crear_usuarios.sql, 002_añadir_email.sql:

-- 001_crear_usuarios.sql
CREATE TABLE IF NOT EXISTS usuarios (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    nombre TEXT NOT NULL
);

Credenciales y seguridad

.my.cnf para MySQL

Crea el archivo con permisos restrictivos:

cat > ~/.my.cnf <<'EOF'
[client]
user = root
password = SuperSegura123
host = localhost
EOF
chmod 600 ~/.my.cnf

Para múltiples entornos:

# ~/.my.cnf
[client]
user = root

[client_produccion]
user = admin
password = ProdPass456
host = db.prod.example.com

[client_testing]
user = tester
password = TestPass789
host = db.test.example.com

Uso:

# Usar perfil por defecto
mysql -e "SELECT 1;"

# Especificar sección
mysql --defaults-group-suffix=_produccion -e "SHOW DATABASES;"

mysql_config_editor

# Configurar (pide la contraseña de forma segura)
mysql_config_editor set --login-path=produccion \
    --host=db.prod.example.com \
    --user=admin \
    --password

# Usar
mysql --login-path=produccion -e "SELECT * FROM usuarios;"

# Verificar
mysql_config_editor print --all

# Eliminar
mysql_config_editor remove --login-path=produccion

.pgpass para PostgreSQL

# ~/.pgpass
# host:puerto:base:usuario:password
localhost:5432:mi_base:postgres:MiPass
192.168.1.100:5432:*:admin:SupraSecret

chmod 600 ~/.pgpass

Variables de entorno temporales

Para scripts que necesitan credenciales sin archivos permanentes:

#!/bin/bash
# Script con credenciales temporales

# Solo existen durante la ejecución del script
export PGPASSWORD="${PG_PASS:?Variable PG_PASS no definida}"
export MYSQL_PWD="${MYSQL_PASS:?Variable MYSQL_PASS no definida}"

# Las consultas se autentican con las variables
mysql -u admin -e "SELECT count(*) FROM usuarios;" mi_base
psql -U admin -d mi_base -c "SELECT count(*) FROM usuarios;"

# Al terminar el script, las variables desaparecen
# (no persisten más allá del proceso)

⚠️ Advertencia: las variables de entorno son visibles en /proc/PID/environ para el usuario root y en la tabla de procesos. En entornos compartidos, usa archivos de contraseñas con permisos 600.

Funciones profesionales

Funciones reutilizables para tu biblioteca personal (~/.bashrc o librería aparte):

# ──────────────────────────────────────────────
# Funciones profesionales para bases de datos
# ──────────────────────────────────────────────

# sqlite_query — Ejecuta consulta y devuelve resultado en el formato deseado
# Uso: sqlite_query <archivo.db> <consulta> [formato: column|json|csv|html]
sqlite_query() {
    local db="$1"
    local query="$2"
    local format="${3:-column}"

    if [[ ! -f "$db" ]]; then
        echo "Error: Base de datos '$db' no encontrada" >&2
        return 1
    fi

    case "$format" in
        json)   sqlite3 -json "$db" "$query" ;;
        csv)    sqlite3 -csv -header "$db" "$query" ;;
        html)   sqlite3 -html "$db" "$query" ;;
        column) sqlite3 -header -column "$db" "$query" ;;
        *)      sqlite3 "$db" "$query" ;;
    esac
}

# sqlite_to_csv — Exporta tabla/consulta a archivo CSV
# Uso: sqlite_to_csv <archivo.db> <consulta> <archivo_salida.csv>
sqlite_to_csv() {
    local db="$1"
    local query="$2"
    local output="$3"

    sqlite3 -csv -header "$db" "$query" > "$output"
    echo "Exportadas $(wc -l < "$output") líneas a $output"
}

# mysql_backup — Respaldo de base MySQL con rotación
# Uso: mysql_backup <base> [dir_destino]
mysql_backup() {
    local db="$1"
    local dest="${2:-/backups/mysql}"
    local file="${dest}/${db}_$(date +%F).sql.gz"

    mkdir -p "$dest"
    echo "Respaldando MySQL: $db → $file"
    mysqldump --single-transaction --routines --triggers "$db" | gzip > "$file"
    echo "Tamaño: $(du -h "$file" | cut -f1)"
}

# pg_database_list — Lista bases PostgreSQL no-template
# Uso: pg_database_list
pg_database_list() {
    psql -U postgres -t -A -X -c "
        SELECT datname FROM pg_database
        WHERE datistemplate = false
        ORDER BY datname;
    "
}

# db_export_json — Exporta cualquier consulta SQLite a JSON, opcionalmente
#                 filtrando/transformando con jq
# Uso: db_export_json <archivo.db> <consulta> [filtro_jq]
db_export_json() {
    local db="$1"
    local query="$2"
    local jq_filter="${3:-.}"

    if command -v jq &>/dev/null; then
        sqlite3 -json "$db" "$query" | jq "$jq_filter"
    else
        sqlite3 -json "$db" "$query"
    fi
}

# db_size — Muestra tamaño y estadísticas de una base SQLite
# Uso: db_size <archivo.db>
db_size() {
    local db="$1"

    if [[ ! -f "$db" ]]; then
        echo "Error: $db no existe" >&2
        return 1
    fi

    echo "=== Estadísticas de $db ==="
    echo "Tamaño:       $(du -h "$db" | cut -f1)"
    echo "Tablas:       $(sqlite3 "$db" '.tables' | wc -w)"
    echo "Filas totales:"
    for tabla in $(sqlite3 "$db" '.tables'); do
        count=$(sqlite3 "$db" "SELECT count(*) FROM \"$tabla\";")
        echo "  $tabla: $count filas"
    done
}

Script completo: gestor_contactos.sh

Un gestor de contactos completo en ~100 líneas usando SQLite, con operaciones CRUD y búsqueda:

#!/bin/bash
# ============================================================
# gestor_contactos.sh — Gestor de contactos con SQLite
#
# Descripción: CRUD completo de contactos usando SQLite
#              desde Bash, con sanitización de entrada.
#
# Autor:      Tutorial Bash
# Versión:    1.0.0
# Licencia:   MIT
# Uso:        ./gestor_contactos.sh [comando] [args...]
#
# Comandos:
#   add <nombre> [email] [tel] [grupo]  — Añadir contacto
#   search <termino>                     — Buscar contactos
#   list [grupo]                         — Listar contactos
#   delete <id>                          — Eliminar contacto
#   count [grupo]                        — Contar contactos
#   export <archivo.csv>                 — Exportar a CSV
#   import <archivo.csv>                 — Importar desde CSV
# ============================================================

set -euo pipefail

DB="${CONTACTS_DB:-$HOME/.contacts.db}"

# ──────────────────────────────────────────────
# Inicialización
# ──────────────────────────────────────────────
init_db() {
    sqlite3 "$DB" <<'EOF'
CREATE TABLE IF NOT EXISTS contactos (
    id        INTEGER PRIMARY KEY AUTOINCREMENT,
    nombre    TEXT NOT NULL,
    email     TEXT,
    telefono  TEXT,
    grupo     TEXT DEFAULT 'general',
    creado_en TEXT DEFAULT (datetime('now'))
);
EOF
}

# ──────────────────────────────────────────────
# Funciones
# ──────────────────────────────────────────────
sanitizar() {
    printf '%q' "$1"
}

cmd_add() {
    local nombre=$(sanitizar "${1:?Error: nombre requerido}")
    local email=$(sanitizar "${2:-}")
    local telefono=$(sanitizar "${3:-}")
    local grupo=$(sanitizar "${4:-general}")

    sqlite3 "$DB" \
        "INSERT INTO contactos (nombre, email, telefono, grupo)
         VALUES ($nombre, $email, $telefono, $grupo);"
    echo "✓ Contacto añadido: $1"
}

cmd_search() {
    local term=$(sanitizar "${1:?Error: término de búsqueda requerido}")

    sqlite3 -header -column "$DB" "
        SELECT id, nombre, email, telefono, grupo
        FROM contactos
        WHERE nombre LIKE '%' || $term || '%'
           OR email   LIKE '%' || $term || '%'
           OR telefono LIKE '%' || $term || '%'
        ORDER BY nombre;
    "
}

cmd_list() {
    local grupo=$(sanitizar "${1:-}")

    if [[ -z "$grupo" ]]; then
        sqlite3 -header -column "$DB" "
            SELECT id, nombre, email, telefono, grupo
            FROM contactos ORDER BY nombre;
        "
    else
        sqlite3 -header -column "$DB" "
            SELECT id, nombre, email, telefono, grupo
            FROM contactos WHERE grupo = $grupo
            ORDER BY nombre;
        "
    fi
}

cmd_delete() {
    local id="${1:?Error: ID requerido}"

    # Verificar que existe
    local existe
    existe=$(sqlite3 "$DB" "SELECT count(*) FROM contactos WHERE id = $id;")
    if [[ "$existe" -eq 0 ]]; then
        echo "Error: No existe contacto con ID $id" >&2
        return 1
    fi

    local nombre
    nombre=$(sqlite3 "$DB" "SELECT nombre FROM contactos WHERE id = $id;")
    sqlite3 "$DB" "DELETE FROM contactos WHERE id = $id;"
    echo "✓ Contacto eliminado: $nombre (ID: $id)"
}

cmd_count() {
    local grupo=$(sanitizar "${1:-}")

    if [[ -z "$grupo" ]]; then
        sqlite3 "$DB" "SELECT COUNT(*) || ' contactos en total' FROM contactos;"
    else
        sqlite3 "$DB" "
            SELECT COUNT(*) || ' contactos en grupo ' || $grupo
            FROM contactos WHERE grupo = $grupo;
        "
    fi
}

cmd_export() {
    local archivo="${1:?Error: nombre de archivo requerido}"

    sqlite3 -csv -header "$DB" "SELECT * FROM contactos ORDER BY id;" > "$archivo"
    echo "✓ Exportados $(wc -l < "$archivo") contactos a $archivo"
}

cmd_import() {
    local archivo="${1:?Error: nombre de archivo requerido}"

    if [[ ! -f "$archivo" ]]; then
        echo "Error: Archivo '$archivo' no encontrado" >&2
        return 1
    fi

    # Crear tabla temporal para importación
    sqlite3 "$DB" <<EOF
.mode csv
.import '$archivo' contactos_temp
EOF
    echo "✓ Importados datos desde $archivo"
}

# ──────────────────────────────────────────────
# Main
# ──────────────────────────────────────────────
main() {
    init_db

    local comando="${1:-help}"
    shift 2>/dev/null || true

    case "$comando" in
        add)    cmd_add "$@" ;;
        search) cmd_search "$@" ;;
        list)   cmd_list "$@" ;;
        delete) cmd_delete "$@" ;;
        count)  cmd_count "$@" ;;
        export) cmd_export "$@" ;;
        import) cmd_import "$@" ;;
        help|*)
            echo "Uso: $0 <comando> [args...]"
            echo ""
            echo "Comandos:"
            echo "  add <nombre> [email] [tel] [grupo]  — Añadir contacto"
            echo "  search <termino>                    — Buscar contactos"
            echo "  list [grupo]                        — Listar contactos"
            echo "  delete <id>                         — Eliminar contacto"
            echo "  count [grupo]                       — Contar contactos"
            echo "  export <archivo.csv>                — Exportar a CSV"
            echo "  import <archivo.csv>                — Importar desde CSV"
            ;;
    esac
}

main "$@"

Cómo usarlo:

chmod +x gestor_contactos.sh

# Añadir contactos
./gestor_contactos.sh add "Ana García" ana@email.com "555-0101" amigos
./gestor_contactos.sh add "Carlos López" carlos@email.com "555-0102" trabajo
./gestor_contactos.sh add "María Pérez" maria@email.com "555-0103" amigos

# Listar todos
./gestor_contactos.sh list

# Buscar
./gestor_contactos.sh search "Ana"

# Contar por grupo
./gestor_contactos.sh count amigos

# Exportar
./gestor_contactos.sh export mis_contactos.csv

# Eliminar
./gestor_contactos.sh delete 2

Errores comunes (10)

Error 1: No instalar sqlite3 y asumir que existe

# ❌ El script falla silenciosamente
sqlite3 mi.db "SELECT 1;"
# bash: sqlite3: command not found

# ✅ Verificar antes de usar
if ! command -v sqlite3 &>/dev/null; then
    echo "Error: sqlite3 no está instalado"
    echo "Instalar: sudo apt install sqlite3"
    exit 1
fi

Error 2: No sanitizar variables en consultas SQL

# ❌ SQL injection directa
NOMBRE="Juan'; DROP TABLE usuarios; --"
sqlite3 db.db "INSERT INTO usuarios (nombre) VALUES ('$NOMBRE');"

# ✅ Siempre sanitizar
sqlite3 db.db "INSERT INTO usuarios (nombre) VALUES ($(printf '%q' "$NOMBRE"));"

Error 3: Olvidar permisos en .my.cnf o .pgpass

# ❌ MySQL ignora .my.cnf con permisos incorrectos
chmod 644 ~/.my.cnf
# mysql: [Warning] Using a password on the command line interface can be insecure.

# ✅ Permisos estrictos
chmod 600 ~/.my.cnf
chmod 600 ~/.pgpass

Error 4: Confundir -c de psql con -e de mysql

# ❌ psql no tiene -e, falla
psql -U postgres -e "SELECT 1;"
# psql: invalid option -- 'e'

# ✅ psql usa -c para consultas inline
psql -U postgres -c "SELECT 1;"

# ❌ mysql no usa -c para consultas
mysql -u root -c "SELECT 1;"

# ✅ mysql usa -e
mysql -u root -e "SELECT 1;"

Error 5: No escapar caracteres especiales en heredocs SQL

# ❌ El shell expande $ dentro del heredoc
sqlite3 db.db <<EOF
INSERT INTO usuarios (nombre) VALUES ('$usuario');
EOF
# Si $usuario contiene comillas, se rompe

# ✅ Heredoc con comillas: 'EOF' evita expansión
sqlite3 db.db <<'EOF'
INSERT INTO usuarios (nombre) VALUES ('$usuario');
EOF
# Inserta literalmente '$usuario'

Error 6: Asumir que sqlite3 crea la tabla al importar CSV

# ❌ .import falla si la tabla no existe
sqlite3 db.db ".import --csv datos.csv usuarios"
# Error: table usuarios does not exist

# ✅ Crear tabla primero
sqlite3 db.db "CREATE TABLE IF NOT EXISTS usuarios (...);"
sqlite3 db.db ".import --csv datos.csv usuarios"

Error 7: No usar –single-transaction en mysqldump con InnoDB

# ❌ Respaldo inconsistente (tablas MyISAM o sin --single-transaction)
mysqldump -u root mi_base > backup.sql
# Los datos pueden cambiar durante el dump

# ✅ Consistencia garantizada (InnoDB)
mysqldump --single-transaction -u root mi_base > backup.sql

Error 8: Usar PGPASSWORD en scripts compartidos (seguridad)

# ❌ Cualquier usuario en el sistema puede verla
export PGPASSWORD="MiSuperPassword"
psql ...
# (visible en /proc/$$/environ y en ps aux)

# ✅ Usar .pgpass con permisos adecuados
echo "localhost:5432:*:postgres:MiSuperPassword" > ~/.pgpass
chmod 600 ~/.pgpass

Error 9: No usar -t -A en psql, obteniendo formato de tabla con bordes

# ❌ Formato humano, no parseable
psql -U postgres -c "SELECT id, nombre FROM usuarios;"
#  id |  nombre
# ----+---------
#   1 | Ana
#   2 | Carlos

# ✅ Formato parseable para scripts
psql -U postgres -t -A -c "SELECT id, nombre FROM usuarios;"
# 1|Ana
# 2|Carlos

Error 10: No verificar código de retorno de operaciones SQL

# ❌ El script continúa aunque la consulta falle
sqlite3 db.db "INSERT INTO usuarios (email) VALUES ('email_invalido');"
echo "Contacto añadido"  # Se ejecuta aunque haya fallado

# ✅ Verificar con set -e o chequeo explícito
set -euo pipefail  # Detiene el script en cualquier error
# O explícitamente:
if ! sqlite3 db.db "INSERT INTO usuarios (email) VALUES ('$email');"; then
    echo "Error al insertar usuario" >&2
    exit 1
fi

Resumen

ConceptoComando / SintaxisPropósito
SQLite CLIsqlite3 archivo.db "SQL;"Ejecuta consulta SQL sobre archivo .db
SQLite heredocsqlite3 db <<EOF ... EOFMúltiples consultas sin entrar al shell
.mode columnsqlite3 -columnSalida formateada en columnas
.mode csvsqlite3 -csvSalida CSV (ideal para exportar)
.mode jsonsqlite3 -jsonSalida JSON nativa (v3.33+)
.headers onsqlite3 -headerMuestra nombres de columna
.import –csv.import --csv archivo tablaCarga CSV en tabla existente
.dump.dump [tabla]Volca base/tabla a SQL
.backup.backup archivo.dbRespaldo consistente de SQLite
printf %qprintf '%q' "$var"Escapa variable para SQL (anti-injection)
MySQL -emysql -e "SQL;"Consulta inline en MySQL
MySQL –batch--batch --silent --rawModo scripting (sin bordes, sin cabeceras)
MySQL configmysql_config_editor setAlmacena credenciales de forma segura
mysqldumpmysqldump db > backup.sqlBackup de MySQL
psql -cpsql -c "SQL;"Consulta inline en PostgreSQL
psql -t -A-t -A -XModo scripting (tuplas, sin alinear, sin rc)
.pgpasshost:puerto:db:user:passAutenticación sin contraseña interactiva
pg_dumppg_dump db > backup.sqlBackup de PostgreSQL
VACUUMsqlite3 db "VACUUM;"Reconstruye DB liberando espacio
REINDEXsqlite3 db "REINDEX;"Reconstruye índices
ANALYZEsqlite3 db "ANALYZE;"Actualiza estadísticas del optimizador

Más información

  • «The Linux Programming Interface» de Michael Kerrisk — Capítulo sobre bases de datos y persistencia desde scripting
  • «Learning SQLite» de Mike Owens — La guía práctica de SQLite para desarrolladores
  • «Bash Cookbook» de Carl Albing — Recetas con bases de datos desde Bash
  • «High Performance MySQL» de Baron Schwartz — Estrategias de backup y mantenimiento
  • «PostgreSQL: Up and Running» de Regina Obe — Automatización y scripting con PostgreSQL

Deja una respuesta