En una publicación reciente escribía sobre cómo crear una base de datos ligera en un proyecto CMake C++ con SQLite3 de una forma sencilla con la ayuda de Conan, y teniendo en cuenta que la biblioteca de SQLite3 tiene un gran mantenimiento que la conserva actualizada.

SOCI en Conan (CCI)

Recientemente (enero 2021) fue aceptada una solicitud de incorporación de cambios a la rama principal del Conan Center Index (CCI) en la cual se incluye la biblioteca SOCI versión 4.0.1; esta es una biblioteca de acceso a bases de datos que soporta SQLite, MySQL, Oracle SQL, entre otras.

Si bien SOCI tiene características de Object-Relational Mapping (ORM) bastante llamativas1, así como soporte de integración con la STL, el enfoque en esta publicación es usarla para hacer real esa ilusión de sentencias SQL incrustadas en C++ combinando SOCI con fmt2 y algunas características básicas de C++ moderno, tales como tuplas, structured binding, deducción de tipos, y raw string literals con delimitadores (multi-line string literals).

Si adicional a lo anterior se usa un framework de pruebas como Catch2 y una IDE como CLion, se forma una combinación bastante robusta con la cual se puede crear, manipular, probar, y analizar una base de datos incrustada de una manera eficiente.

Configuración

En este ejemplo se utilizan las siguientes bibliotecas:

Nombre Versión
soci 4.0.1
sqlite 3.33.0
fmt 7.1.3
catch2 2.13.4

El uso de SOCI está atado a alguno de los backend que soporta, en este caso SQLite3.

SOCI en Conan no tiene paquetes de binarios pre compilados debido a la cantidad de combinaciones de backends posibles, por lo tanto se requiere definir las opciones específicas que aplican para SQLite3, así como indicarle a Conan que se debe compilar el paquete.

Conan

El archivo conanfile.txt debe contener los paquetes requeridos y las opciones de SOCI:

# conanfile.txt
[requires]
  soci/4.0.1
  fmt/7.1.3
  catch2/2.13.4

[options]
  soci:shared       = True
  soci:with_sqlite3 = True

[generators]
  cmake_find_package

Para que Conan compile el paquete SOCI desde el directorio ./build se le agrega el argumento --build=soci al comando de instalación, de la siguiente manera:

conan install .. --build=soci

En general, se puede usar la opción de compilar los paquetes que hagan falta:

conan install .. --build=missing

La versión 4.0.1 de SOCI maneja internamente la instalación de la dependencia SQLite3 al seleccionar la opción soci::with_sqlite3=True.

CMake

El generador de conan cmake_find_package permite la integración con el comando find_package de cmake, por lo cual enlazar los paquetes de fmt y soci es similar a como se haría si el paquete estuviera instalado en el sistema, y no por conan:

# CMakeLists.txt
# ...
set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} ${CMAKE_BINARY_DIR})
find_package(fmt REQUIRED)
find_package(soci REQUIRED)
#...
target_link_libraries(${PROJECT_NAME}
  PRIVATE fmt::fmt soci::soci)
#...

Sentencias SQL

Este ejemplo consiste en crear una tabla, insertarle valores, y finalmente consultarlos.

CREATE TABLE tabla1
(
    id      INTEGER,
    nombre  VARCHAR2(100),
    balance REAL
);

INSERT INTO tabla1 (id, nombre, balance)
VALUES (7, 'Juan' , 100.20),
       (9, 'Juana', 200.10);

SELECT id, nombre, balance FROM tabla1 WHERE id = 9;

SQL en C++

Los anteriores son tres pasos sencillos para probar la facilidad del uso de SOCI. A continuación se aprecian los mismos pasos en el proyecto C++, los cuales no están optimizados, pero son suficientes para demostrar el objetivo.

Primero se deben incluir los archivos de cabecera requeridos:

#include <fmt/format.h>
#include <soci/soci.h>
#include <soci/sqlite3/soci-sqlite3.h>
#include <string>
#include <tuple>

Ahora se indican el nombre de la base de datos, el nombre de la tabla, se define el backend y se crea una sesión con este:

const auto& connectString{"../database1.sqlite3"};
const auto& table1{"tabla1"};
const soci::backend_factory& backEnd = *soci::factory_sqlite3();
soci::session sql(backEnd, connectString);

Se define una función para crear la tabla. Asumiendo que se tiene una función de validación de su existencia, basta con insertar en la sesión de SOCI un flujo crudo con la sentencia SQL a ejecutar pasándole el nombre de la tabla:

void createTable(const std::string& tableName)
{
  if (!tableExists(tableName))
  {
    sql << fmt::format(R"(
CREATE TABLE {0} 
(
    id      INTEGER,
    nombre  VARCHAR2(100),
    balance REAL
);)", tableName);
  }
}

De manera similar se crea una función para llenar la tabla, en este caso el flujo crudo está delimitado por EOL:

void insertInto(const std::string& tableName)
{
  if (tableExists(tableName))
  {
    sql << fmt::format(R"EOL(
INSERT INTO {0} (id, nombre, balance)
VALUES (7, 'Juan' , 100.20),
       (9, 'Juana', 200.10);
)EOL", tableName);
  }
}

Finalmente, haciendo uso de la funcionalidad static binding3 de SOCI se pueden recuperar los valores de una fila que cumpla determinada condición y almacenarlos directamente en variables de la función:

std::tuple<int, std::string, double> getValues(const std::string& tableName, int idToFind)
{
  int id = 0;
  std::string name{};
  double balance = 0.0;

  sql << fmt::format("SELECT id, nombre, balance FROM {0} WHERE id = {1}", 
                     tableName, 
                     idToFind),
         soci::into(id), soci::into(name), soci::into(balance);

  return {id, name, balance};
}

Pruebas con Catch2

Catch2 es una biblioteca para pruebas. Hace falta indicarle un punto de entrada para comenzar a usarlo. Los casos de prueba son: Probar la conexión correcta con la base de datos, crear la tabla, insertarle dos filas a la tabla, recuperar los valores para ID=9:

#define CATCH_CONFIG_MAIN 
#include <catch2/catch.hpp>

//... Los demás #include, y funciones auxiliares

TEST_CASE("should be connected")
{
  CHECK( sql.is_connected() ); 
}

TEST_CASE("should create a table")
{
  createTable(table1);
  CHECK( tableExists(table1) );
}

TEST_CASE("should insert two rows into table")
{
  insertInto(table1);
  CHECK( getNumberOfRows(table1) == 2 );
}

TEST_CASE("should get a row given an id value")
{
  const int idToFind = 9;
  const auto [id, name, balance] = getValues(table1, idToFind);

  CHECK( id == 9);
  CHECK( name == "Juana");
  CHECK( balance == 200.10);
}

Fuentes


Deja un comentario