Guardar el estado de Biblioteca en un fichero JSON, como en la lección anterior, funciona
bien mientras los datos son pequeños y solo un proceso los lee y escribe a la vez. Pero una
base de datos relacional resuelve problemas que un fichero no resuelve bien: consultas
eficientes sobre millones de filas, acceso concurrente de varios procesos sin corromper los
datos, e integridad garantizada por el propio motor. Esta lección presenta ADO.NET, el
conjunto de clases clásico de .NET para hablar directamente con una base de datos mediante SQL,
usando SQLite como motor de ejemplo. Verás cómo abrir una conexión, crear una tabla, insertar
datos de forma segura frente a inyección SQL, y leerlos de vuelta —y por qué, tras ver lo
trabajoso que resulta hacerlo todo a mano, la siguiente lección introduce Entity Framework para
automatizarlo.
Contenido
- Qué es ADO.NET y cuándo se usa directamente
- SQLite como base de datos de ejemplo y la cadena de conexión
SqliteConnection: abrir y cerrar la conexión conusing- Crear la tabla
LibrosconExecuteNonQuery - Insertar datos de forma segura con
SqliteParameter - Leer datos con
ExecuteReader - El catálogo de BiblioTech en SQLite, de principio a fin
- Qué es ADO.NET y cuándo se usa directamente
ADO.NET es el conjunto de clases de .NET, disponible desde sus primeras versiones, para comunicarse con una base de datos relacional escribiendo SQL directamente. Sus piezas principales son comunes a cualquier motor (SQL Server, PostgreSQL, SQLite, MySQL...), aunque cada uno aporta su propia implementación mediante un proveedor:
| Pieza de ADO.NET | Qué representa | Clase para SQLite |
|---|---|---|
| Conexión | Un canal abierto hacia la base de datos | SqliteConnection |
| Comando | Una sentencia SQL a ejecutar | SqliteCommand |
| Parámetro | Un valor que se sustituye de forma segura dentro del SQL | SqliteParameter |
| Lector | Un cursor de solo lectura sobre los resultados de un SELECT |
SqliteDataReader |
Escribir SQL a mano con ADO.NET da control total sobre la consulta exacta que se ejecuta, y es la base sobre la que están construidas herramientas de más alto nivel, como Entity Framework (siguiente lección). En el día a día, la mayoría de aplicaciones usan un ORM y rara vez tocan ADO.NET directamente; aun así, entender esta capa ayuda a comprender qué hace un ORM "por detrás", y sigue siendo la opción adecuada cuando se necesita el máximo control sobre el SQL exacto que se ejecuta (por ejemplo, una consulta muy optimizada para un caso concreto).
- SQLite como base de datos de ejemplo y la cadena de conexión
Esta lección usa SQLite, un motor de base de datos relacional que guarda toda la base de
datos en un único fichero, sin necesidad de instalar ni administrar un servidor aparte —ideal
para aprender y para aplicaciones pequeñas. El paquete NuGet necesario es
Microsoft.Data.Sqlite:
Toda conexión empieza con una cadena de conexión, un texto que describe dónde y cómo conectar. Para SQLite, basta con indicar la ruta del fichero:
Si bibliotech.db no existe todavía, SQLite lo crea automáticamente en el primer intento de
conexión; no hace falta ningún paso de instalación adicional más allá del paquete NuGet.
SqliteConnection: abrir y cerrar la conexión con using
SqliteConnection: abrir y cerrar la conexión con usingSqliteConnection implementa IDisposable (recuerda la lección de E/S de Archivos): abrir una
conexión reserva un recurso externo (un canal hacia el fichero de base de datos) que debe
cerrarse siempre, incluso si algo falla a mitad de camino. Por eso, igual que con
StreamReader/StreamWriter, se declara siempre con using:
using Microsoft.Data.Sqlite;
using SqliteConnection conexion = new SqliteConnection("Data Source=bibliotech.db");
conexion.Open();
Console.WriteLine($"Conexion abierta. Estado: {conexion.State}");
// conexion.Dispose() (que cierra la conexion) se llama automaticamente al final del bloque/metodoconexion.Open() es una llamada explícita: crear el objeto SqliteConnection no abre por sí
solo la conexión, solo la prepara; hasta que no se llama a Open(), no hay ningún canal
establecido hacia la base de datos.
- Crear la tabla
Libros con ExecuteNonQuery
Libros con ExecuteNonQueryCualquier sentencia SQL que no devuelva filas (CREATE TABLE, INSERT, UPDATE, DELETE) se
ejecuta con SqliteCommand.ExecuteNonQuery():
using SqliteConnection conexion = new SqliteConnection("Data Source=bibliotech.db");
conexion.Open();
using SqliteCommand comandoCrearTabla = conexion.CreateCommand();
comandoCrearTabla.CommandText =
"""
CREATE TABLE IF NOT EXISTS Libros (
Isbn TEXT PRIMARY KEY,
Titulo TEXT NOT NULL,
Autor TEXT NOT NULL,
Disponible INTEGER NOT NULL
)
""";
comandoCrearTabla.ExecuteNonQuery();
Console.WriteLine("Tabla Libros lista.");conexion.CreateCommand() crea un SqliteCommand ya asociado a esa conexión; CommandText es
el SQL a ejecutar (aquí, con una cadena literal multilínea de C# 11, """...""", cómoda para
sentencias largas). IF NOT EXISTS evita un error si el programa se ejecuta varias veces y la
tabla ya existía de una ejecución anterior. SQLite no tiene un tipo BOOLEAN nativo; por
convención, Disponible se guarda como INTEGER (0/1), y el propio proveedor
Microsoft.Data.Sqlite se encarga de convertir entre bool de C# y 0/1 de SQLite de forma
automática.
- Insertar datos de forma segura con
SqliteParameter
SqliteParameterEl error más peligroso al construir SQL a mano es concatenar directamente valores del programa dentro del texto SQL: abre la puerta a la inyección SQL, una vulnerabilidad grave.
| Concatenación directa (peligroso) | Parámetros (SqliteParameter, correcto) |
|
|---|---|---|
| Código | $"INSERT INTO Libros VALUES ('{isbn}', ...)" |
"INSERT INTO Libros VALUES ($isbn, ...)" + comando.Parameters.AddWithValue("$isbn", isbn) |
Si isbn contiene '; DROP TABLE Libros; -- |
El SQL resultante ejecuta órdenes no previstas | El valor se trata siempre como un dato literal, nunca como SQL |
| Seguridad | Vulnerable a inyección SQL | Seguro frente a inyección SQL |
using SqliteCommand comandoInsertar = conexion.CreateCommand();
comandoInsertar.CommandText =
"INSERT OR REPLACE INTO Libros (Isbn, Titulo, Autor, Disponible) VALUES ($isbn, $titulo, $autor, $disponible)";
comandoInsertar.Parameters.AddWithValue("$isbn", "978-84-376-0495-4");
comandoInsertar.Parameters.AddWithValue("$titulo", "Rayuela");
comandoInsertar.Parameters.AddWithValue("$autor", "Julio Cortazar");
comandoInsertar.Parameters.AddWithValue("$disponible", true);
comandoInsertar.ExecuteNonQuery();AddWithValue asocia cada marcador ($isbn, $titulo...) con un valor concreto de C#; el
proveedor se encarga de escaparlo y enviarlo por separado del texto SQL, de modo que el
contenido del valor nunca puede alterar la estructura de la sentencia. INSERT OR REPLACE
es una extensión de SQLite que inserta la fila si el Isbn (clave primaria) no existe, o la
sobrescribe si ya existía —útil para no tener que distinguir "alta" de "actualización" en este
ejemplo. Regla sin excepciones: cualquier valor que provenga del programa (entrada del
usuario, datos de otro sistema...) debe viajar siempre como parámetro, nunca concatenado
directamente en el texto SQL.
- Leer datos con
ExecuteReader
ExecuteReaderLas sentencias que sí devuelven filas (SELECT) se ejecutan con ExecuteReader(), que
devuelve un SqliteDataReader: un cursor de solo lectura y solo hacia delante sobre el
resultado, que se recorre con Read():
using SqliteCommand comandoSeleccionar = conexion.CreateCommand();
comandoSeleccionar.CommandText = "SELECT Isbn, Titulo, Autor, Disponible FROM Libros";
using SqliteDataReader lector = comandoSeleccionar.ExecuteReader();
while (lector.Read())
{
string isbn = lector.GetString(0);
string titulo = lector.GetString(1);
string autor = lector.GetString(2);
bool disponible = lector.GetBoolean(3);
Console.WriteLine($"{titulo} ({autor}) - ISBN {isbn} - Disponible: {disponible}");
}lector.Read() avanza al siguiente registro y devuelve false cuando ya no quedan más,
exactamente el mismo patrón de bucle while que StreamReader.ReadLine() en la lección
anterior. GetString(indice), GetBoolean(indice), GetInt32(indice)... acceden a cada
columna por su posición (empezando en 0) en el orden indicado en el SELECT; alternativas
como GetOrdinal("Titulo") permiten obtener esa posición a partir del nombre de columna, más
resistente si el orden del SELECT cambia con el tiempo.
- El catálogo de BiblioTech en SQLite, de principio a fin
Uniendo todo lo anterior, así se guarda y se recupera el catálogo completo de libros de
BiblioTech (por simplicidad, esta lección se centra en Libro; una tabla Revistas aparte
seguiría exactamente el mismo patrón):
void GuardarLibrosEnSqlite(List<Libro> libros, string cadenaConexion)
{
using SqliteConnection conexion = new SqliteConnection(cadenaConexion);
conexion.Open();
using SqliteCommand comandoCrearTabla = conexion.CreateCommand();
comandoCrearTabla.CommandText =
"""
CREATE TABLE IF NOT EXISTS Libros (
Isbn TEXT PRIMARY KEY,
Titulo TEXT NOT NULL,
Autor TEXT NOT NULL,
Disponible INTEGER NOT NULL
)
""";
comandoCrearTabla.ExecuteNonQuery();
foreach (Libro libro in libros)
{
using SqliteCommand comandoInsertar = conexion.CreateCommand();
comandoInsertar.CommandText =
"INSERT OR REPLACE INTO Libros (Isbn, Titulo, Autor, Disponible) VALUES ($isbn, $titulo, $autor, $disponible)";
comandoInsertar.Parameters.AddWithValue("$isbn", libro.Isbn);
comandoInsertar.Parameters.AddWithValue("$titulo", libro.Titulo);
comandoInsertar.Parameters.AddWithValue("$autor", libro.Autor);
comandoInsertar.Parameters.AddWithValue("$disponible", libro.Disponible);
comandoInsertar.ExecuteNonQuery();
}
}
List<Libro> CargarLibrosDesdeSqlite(string cadenaConexion)
{
List<Libro> libros = new List<Libro>();
using SqliteConnection conexion = new SqliteConnection(cadenaConexion);
conexion.Open();
using SqliteCommand comandoSeleccionar = conexion.CreateCommand();
comandoSeleccionar.CommandText = "SELECT Isbn, Titulo, Autor, Disponible FROM Libros";
using SqliteDataReader lector = comandoSeleccionar.ExecuteReader();
while (lector.Read())
{
string isbn = lector.GetString(0);
string titulo = lector.GetString(1);
string autor = lector.GetString(2);
bool disponible = lector.GetBoolean(3);
Libro libro = new Libro(titulo, autor, isbn);
if (!disponible)
{
libro.Prestar();
}
libros.Add(libro);
}
return libros;
}List<Libro> catalogo = new List<Libro>
{
new Libro("Rayuela", "Julio Cortazar", "978-84-376-0495-4"),
new Libro("Ficciones", "Jorge Luis Borges", "978-84-376-0496-1")
};
string cadenaConexion = "Data Source=bibliotech.db";
GuardarLibrosEnSqlite(catalogo, cadenaConexion);
List<Libro> catalogoRecuperado = CargarLibrosDesdeSqlite(cadenaConexion);
Console.WriteLine(catalogoRecuperado.Count); // 2Compara el tamaño de este código con el de la lección de Serialización: aquí ha hecho falta
escribir a mano el SQL de creación de tabla, el de inserción con sus parámetros uno a uno, el de
selección, y el mapeo manual columna a columna hacia Libro. Funciona, y da control total sobre
el SQL exacto, pero es trabajo repetitivo que crecería mucho al añadir Socios y Prestamos
(con sus relaciones entre tablas). Ese trabajo repetitivo es exactamente lo que resuelve un
ORM (Object-Relational Mapper), el tema de la siguiente lección: Entity Framework.
Errores Comunes y Consejos
- Concatenar valores directamente en el SQL (
$"... WHERE Isbn = '{isbn}'"): abre la puerta a inyección SQL. Usa siempreSqliteParameter(víaAddWithValueoAdd) para cualquier valor que no sea parte fija de la sentencia. - No cerrar la conexión: sin
using, una conexión abierta y nunca cerrada agota, con el tiempo, el número de conexiones disponibles hacia la base de datos. Declara siempreSqliteConnection,SqliteCommandySqliteDataReaderconusing. - Acceder a una columna por una posición equivocada en
GetString/GetInt32/...: si el orden de las columnas delSELECTno coincide con los índices usados al leer elSqliteDataReader, se obtienen datos de la columna equivocada sin que el compilador lo detecte.GetOrdinal("NombreColumna")es más robusto que confiar en índices fijos si elSELECTpuede cambiar. - Olvidar
IF NOT EXISTSen elCREATE TABLE: sin él, ejecutar el programa una segunda vez contra el mismo fichero de base de datos lanzaría un error porque la tabla ya existiría. - Consejo: para cualquier aplicación real algo más allá de un ejemplo de aprendizaje, un ORM como Entity Framework (siguiente lección) reduce drásticamente este código repetitivo y evita errores manuales de mapeo; reserva ADO.NET directo para consultas muy concretas que necesiten el máximo control sobre el SQL exacto ejecutado.
Ejercicios
-
Escribe el código para crear una tabla
Sociosen SQLite con columnasId(INTEGER PRIMARY KEY) yNombre(TEXT NOT NULL), usandoCREATE TABLE IF NOT EXISTSyExecuteNonQuery. -
Escribe el código para insertar un
Socioen la tablaSociosdel ejercicio anterior, usandoSqliteParameter(conAddWithValue) para los dos valores, nunca concatenación directa. -
Escribe una función que lea todos los registros de la tabla
SociosconExecuteReader, reconstruya unSociopor cada fila, y devuelva unList<Socio>con todos ellos.
Soluciones
using SqliteCommand comando = conexion.CreateCommand();
comando.CommandText =
"""
CREATE TABLE IF NOT EXISTS Socios (
Id INTEGER PRIMARY KEY,
Nombre TEXT NOT NULL
)
""";
comando.ExecuteNonQuery();
Socio socio = new Socio(1, "Ana Martinez");
using SqliteCommand comando = conexion.CreateCommand();
comando.CommandText = "INSERT OR REPLACE INTO Socios (Id, Nombre) VALUES ($id, $nombre)";
comando.Parameters.AddWithValue("$id", socio.Id);
comando.Parameters.AddWithValue("$nombre", socio.Nombre);
comando.ExecuteNonQuery();
List<Socio> CargarSociosDesdeSqlite(SqliteConnection conexion)
{
List<Socio> socios = new List<Socio>();
using SqliteCommand comando = conexion.CreateCommand();
comando.CommandText = "SELECT Id, Nombre FROM Socios";
using SqliteDataReader lector = comando.ExecuteReader();
while (lector.Read())
{
int id = lector.GetInt32(0);
string nombre = lector.GetString(1);
socios.Add(new Socio(id, nombre));
}
return socios;
}
Conclusión
En esta lección has usado ADO.NET clásico para hablar directamente con una base de datos
SQLite: abrir una conexión con SqliteConnection, ejecutar sentencias sin resultado con
ExecuteNonQuery, insertar datos de forma segura frente a inyección SQL con SqliteParameter,
y leer resultados con ExecuteReader. El catálogo de libros de BiblioTech ya puede vivir en una
base de datos relacional real, no solo en un fichero de texto o JSON.
También has comprobado, de primera mano, lo repetitivo que resulta este enfoque: SQL escrito a mano para cada operación, parámetros añadidos uno a uno, y un mapeo manual columna a columna hacia cada propiedad del objeto. La siguiente lección, Entity Framework, introduce un ORM que automatiza casi todo este trabajo: mapea las clases del dominio de BiblioTech a tablas, genera el SQL por ti, y permite consultar con LINQ —la misma herramienta que ya conoces desde el Módulo 4— en lugar de SQL escrito a mano.
Curso de Programación en C#
Módulo 1: Introducción a C#
- Introducción a C#
- Configuración del Entorno de Desarrollo
- Programa Hola Mundo
- Sintaxis y Estructura Básica
- Variables y Tipos de Datos
- Arrays y Cadenas de Texto
Módulo 2: Estructuras de Control
Módulo 3: Programación Orientada a Objetos
- Clases y Objetos
- Métodos
- Constructores y Destructores
- Herencia
- Polimorfismo
- Encapsulamiento
- Abstracción
- Structs y Records: Tipos por Valor y por Referencia
Módulo 4: Conceptos Avanzados de C#
- Interfaces
- Delegados y Eventos
- Pattern Matching y Características Modernas de C#
- Genéricos
- Colecciones
- LINQ (Consulta Integrada en el Lenguaje)
- Programación Asíncrona
Módulo 5: Trabajando con Datos
- Entrada/Salida de Archivos
- Serialización
- Conectividad con Bases de Datos
- Entity Framework
- Trabajo con JSON y Consumo de APIs REST
Módulo 6: Temas Avanzados
- Reflexión
- Atributos
- Programación Dinámica
- Gestión de Memoria y Recolección de Basura
- Multihilo y Programación Paralela
Módulo 7: Construcción de Aplicaciones
- Formularios de Windows
- WPF (Windows Presentation Foundation)
- ASP.NET Core
- Blazor
- Xamarin y .NET MAUI
Módulo 8: Mejores Prácticas y Patrones de Diseño
- Estándares de Codificación y Mejores Prácticas
- Patrones de Diseño
- Inyección de Dependencias e Inversión de Control
- Pruebas Unitarias
- Revisión y Refactorización de Código
