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

  1. Qué es ADO.NET y cuándo se usa directamente
  2. SQLite como base de datos de ejemplo y la cadena de conexión
  3. SqliteConnection: abrir y cerrar la conexión con using
  4. Crear la tabla Libros con ExecuteNonQuery
  5. Insertar datos de forma segura con SqliteParameter
  6. Leer datos con ExecuteReader
  7. El catálogo de BiblioTech en SQLite, de principio a fin

  1. 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).

  1. 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:

dotnet add package 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:

string cadenaConexion = "Data Source=bibliotech.db";

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.

  1. SqliteConnection: abrir y cerrar la conexión con using

SqliteConnection 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/metodo

conexion.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.

  1. Crear la tabla Libros con ExecuteNonQuery

Cualquier 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.

  1. Insertar datos de forma segura con SqliteParameter

El 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.

  1. Leer datos con ExecuteReader

Las 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.

  1. 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); // 2

Compara 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 siempre SqliteParameter (vía AddWithValue o Add) 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 siempre SqliteConnection, SqliteCommand y SqliteDataReader con using.
  • Acceder a una columna por una posición equivocada en GetString/GetInt32/...: si el orden de las columnas del SELECT no coincide con los índices usados al leer el SqliteDataReader, 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 el SELECT puede cambiar.
  • Olvidar IF NOT EXISTS en el CREATE 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

  1. Escribe el código para crear una tabla Socios en SQLite con columnas Id (INTEGER PRIMARY KEY) y Nombre (TEXT NOT NULL), usando CREATE TABLE IF NOT EXISTS y ExecuteNonQuery.

  2. Escribe el código para insertar un Socio en la tabla Socios del ejercicio anterior, usando SqliteParameter (con AddWithValue) para los dos valores, nunca concatenación directa.

  3. Escribe una función que lea todos los registros de la tabla Socios con ExecuteReader, reconstruya un Socio por cada fila, y devuelva un List<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#

Módulo 2: Estructuras de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Conceptos Avanzados de C#

Módulo 5: Trabajando con Datos

Módulo 6: Temas Avanzados

Módulo 7: Construcción de Aplicaciones

Módulo 8: Mejores Prácticas y Patrones de Diseño

Módulo 9: Proyecto Final

© Copyright 2026. Todos los derechos reservados