Panel użytkownika
Nazwa użytkownika:
Hasło:
Nie masz jeszcze konta?
Autor: Grzegorz Bazior, doktorant AGH w Krakowie
Inne artykuły

[C++, SOCI] Wieloplatformowa biblioteka do obsługi różnych baz danych

[artykuł] Artykuł opisuje bibliotekę C++ do obsługi baz danych takich jak np. MySQL, MS SQL, MariaDB, PostgreSQL itp.

Krótkie wprowadzenie

Większość programów musi w jakiś sposób pamiętać pewne szczegóły, dlatego w pewnym momencie, zamiast operować na zwykłych plikach bezpośrednio, decydujemy się na użycie bazy danych. Baz danych jest bardzo wiele i nie ma zestawienia, które by porównywało wszystkie bazy danych tak szczegółowo, żebyśmy mogli w oparciu o nie wybrać dla każdego użytkownika zastosowania.
Popularnym sa Systemy Zarządzania Relacyjną Bazą Danych (ang. Relational Database Management System, RDBMS), które dostarczają serwer bazy danych, do którego mogą sie łączyć klienci. Popularnymi a równocześnie bezpłatnymi są MySQL, PostgreSQL, MariaDB. Z kolei wg raportu firmy Gartner z 2011 roku popularność płatnych ma się następująco Oracle (48.8%), baza IBM (20.2%), Microsoft (17.0%), SAP including Sybase (4.6%), Teradata (3.7%).
W przypadku gdy zależy nam na szybkości, możemy się pokusić o stosowanie baz NoSQLowych. Z kolei jeśli dana aplikacja nie potrzebuje łączyć się z serwerem bazy danych, ani nie musi obsługiwać wielu użytkowników, możemy pokusić się o zastosowanie prostej bazy danych SQLite, która trzyma całą bazę w jednym pliku, lub MySQL Embedded.
Możliwości jest wiele, każdy zespół musi podjąć decyzję, której bazy danych używać. W tym artykule jednak opiszę biblioteki o interfejsie pozwalającym na obsługę wielu różnych baz danych przy pomocy języka zapytań SQL.

Kiedy nie należy czytać tego artykułu

Jeśli nasza aplikacja jest bardzo mocno zakorzeniona na bazie danych, oraz ma używać konkretnych jej funkcjonalności specyficznych dla danej bazy, lub ma używać najwydajniejsze mechanizmy to nie należy używać interfejsu wspólnego dla wielu baz danych, takiego jaki teraz opiszę. Dotyczy to nawet używania procedur i funkcji w SQLu.
Poza tym jeśli ktoś jest zwolennikiem mniejszej liczby bibliotek w projekcie, a używa już biblioteki z dużą ilością funkcjonalności (np. Qt lub POCO) zawierającą interfejs do naszej bazy danych, sugeruję użyć interfejsów z tych bibliotek.

Zestawienie bibliotek do zarządzania wieloma bazami danych

Przed wyborem biblioteki natrafiłem na kilka bibliotek OpenSource do obsługi wielu baz danych:
Nazwaobsługiwane bazy danychkomentarz
ODB: C++ Object-Relational Mapping (ORM)MySQL, SQLite, PostgreSQL, Oracle i Microsoft SQL Server OpenSource, obsługa 5 baz danych, dobra dokumentacja, wygodne sterowanie bazą przy pomocy
#praga
 i typów użytkownika
Biblioteka SOCIDB2, Firebird, MySQL, ODBC (generic backend), Oracle, PostgreSQL, SQLite3 obsługa 7 baz danych, dobra dokumentacja, Boost Software Licence, autorstwa Polaków pracujących w CERNie, rozwijana ciągle od 2004 roku
Biblioteka OTLOracle, MS SQL Server, DB2, Informix, TimesTen, MAX/DB, PostgreSQL, MySQL, Sybase, SQLite, MS ACCESS, Firebirdpolecana na StackOverflow i wciąż rozwijana, jeden nagłówek, na swojej stronie chwalą się niewielkim narzutem w stosunku do używania natywnego API baz danych 10-15%, obsługa 12 baz danych, wg mnie natomiast mają słabą dokumentację
sqlpp11MySQL, Sqlite3, PostgreSQL, ODBC (eksperymentalne)licencja BSD 2, prezentowana na konferencjach CppCon, obsługa 4 baz danych, minusem jest konieczność ściągania oddzielnie connectorów do każdej z baz
Poco::DBMySQL, ODBC, SQLite, MongoDBlicencja Boost Software License 1.0, Poco jest bardzo dużą biblioteką, dostarczającą bardzo wiele funkcjonalności (prawie jak druga biblioteka Boost), obsługa 4 baz danych, dokumentacja dla modułów bazodanowych jest trochę zaniedbana
Biblioteka QTIBM DB2, Borland InterBase, MySQL (również MySQL Embedded), Oracle Call Interface Driver, Open Database Connectivity (ODBC) - Microsoft SQL Server i inne kompatybilne z ODBC, PostgreSQL, SQLite 2, SQLite 3, Sybase Adaptive Server (przestarzałe od Qt 4.7)obsługa 9 baz danych, niewątpliwie dobra dokumentacja, jednakże QT to olbrzymia biblioteka
W oparciu o powyższe dane zdecydowałem się opisać bibliotekę http://soci.sourceforge.net/. Zacznę od ciekawostki - nazwa SOCI wzięła się od Simple Oracle Call Interface, bo na początku obsługiwała jedynie bazy Oracle'a. Z czasem biblioteka zaczęła obsługiwać również inne bazy, a nazwa została.

Instalacja biblioteki

Zanim zaczniemy instalować bibliotekę

Zacznę od tego, że wpierw musimy zainstalować oficjalne biblioteki do obsługi naszej własnej bazy danych, a dopiero potem możemy przejść dalej:
Artykuł będzie skoncentrowany na użyciu SQLite 3 w SOCI. Można go pobrać na stronie SQLite.org, do budowy z kolei może służyć CMake. Chociaż zważywszy, że jest to tylko jeden plik źródłowy, możemy zbudować go ręcznie.

Instalacja SOCI

Mając pobraną odpowiednią bazę danych i konieczne do jej obsługi biblioteki, możemy pobrać bibliotekę SOCI. Jeśli chcemy zrobić to ręcznie możemy albo pobrać z repozytorium:
git clone git://github.com/SOCI/soci.git
, albo pobrać którąś z wersji stabilnych.
Projekt ma dostarczonego CMake'a, dlatego najprościej zbudować go klasycznie:
cd ${SOCI_DIR}; mkdir build; cd build
cmake .. && make && sudo make install
Zainstalowane wcześniej biblioteki natywne do obsługi naszej bazy danych powinny zostać wykryte. Jeśli tak nie jest należy ustawić ścieżkę do nagłówków, czy też biblioteki. Możemy też wyłączyć dynamiczne budowanie testów i biblioteki, jak też wskazać ścieżki do biblioteki boost, ewentualnie zmienić domyślny generator:
...
cmake .. -G "Unix makefiles" -DSOCICXX11=ON -DSOCI_SHARED=ON -DSOCI_TESTS=OFF
Wszystkie konfiguracyjne opcje znajdziemy, pisząc:
cmake .. -LA
Więcej informacji na temat instalacji w dokumentacji.
Budując bibliotekę, może nas interesować fakt licencji. Przykładowo MySQL w momencie linkowania statycznego narzuca nam licencję GPL (czyli, że nasz produkt również musi być co najmniej GPL). Z drugiej strony kilku doświadczonych programistów działających na cpp0x.pl natrafiło na problemy z działaniem podczas linkowania dynamicznego pod Windowsem, używając MySQLa jako bazy danych.

Różnice dla różnych baz danych

Mając już zbudowaną bibliotekę i wszystkie zależności, przejdźmy do głównych różnic w używaniu różnych baz danych z poziomu SOCI. Zasadniczo wystarczy wybór odpowiedniego nagłówka oraz odpowiednie argumenty kostruktora klasy
soci::session
 służącego do połączenia z bazą danych:
Baza danychnagłówekkonstruktor klasy
soci::session
SQLite 3
#include<soci-sqlite3.h>
session sql( sqlite3, "database_filename.db" );
MySQL
#include<soci-mysql.h>
session sql( mysql, "db=test user=root password='Ala ma kota'" );
Postgresql
#include<soci-postgresql.h>
session sql( postgresql, "dbname=mydb" );
Oracle
#include<soci-oracle.h>
session sql( oracle, "service=orcl user=scott password=tiger" );
bazy ODBC
#include<soci-odbc.h>
session sql( odbc, "filedsn=c:\\my.dsn" );
Firebird
#include <soci-firebird.h>
session sql( firebird, "service=/usr/local/firbird/db/test.fdb user=SYSDBA password=masterkey" );
Oczywiście wiele z baz ma pewne dodatkowe opcje konstrukcji, aby je poznać, trzeba sprawdzić w dokumentacji, gdyż od tego momentu skupię się na przykładzie SQLite

Użycie biblioteki SOCI

Zacznijmy od przykładu użycia prostych zapytań SQL (tworzenie tabeli, dodawanie danych,
SELECT*
) w bibliotece SOCI:
C/C++
#include <iostream>
#include <string>
#include <exception>
#include <soci/soci.h>
#include <soci/sqlite3/soci-sqlite3.h>

using namespace soci;
using namespace std;

int main()
{
    try
    {
        soci::session sql( sqlite3, "pracownicy.db" );
       
        sql << "CREATE TABLE IF NOT EXISTS Pracownicy("
        "ID     INT PRIMARY KEY NOT NULL,"
        "Imie   TEXT            NOT NULL,"
        "Wiek   INT             NOT NULL,"
        "Adres  TEXT,"
        "Pensja REAL);";
        sql << "INSERT INTO Pracownicy (ID, Imie, Wiek, Adres, Pensja) VALUES (1, 'Tadeusz', 24, 'Krakow', 2000.01 ); ";
        sql << "INSERT INTO Pracownicy (ID, Imie, Wiek, Adres, Pensja) VALUES (2, 'Symeon', 25, 'Texas', 15000.00 ); ";
        sql << "INSERT INTO Pracownicy (ID, Imie, Wiek, Adres, Pensja) VALUES (3, 'Rodion', 23, 'Norway', 20000.00 );";
       
        soci::rowset < soci::row > rows = sql.prepare << "SELECT * FROM Pracownicy";
       
        for( auto & row: rows )
        {
            for( std::size_t i = 0; i != row.size(); ++i )
            {
                const column_properties & props = row.get_properties( i );
               
                cout << props.get_name() << " = ";
               
                switch( props.get_data_type() )
                {
                case dt_string:
                    cout << row.get < std::string >( i ) << '\n';
                    break;
                case dt_double:
                    cout << row.get < double >( i ) << '\n';
                    break;
                case dt_integer:
                    cout << row.get < int >( i ) << '\n';
                    break;
                    default:
                    cerr << "(unknown type!)\n";
                }
            }
        }
    }
    catch( const soci_error & e )
    {
        cerr << "Error: " << e.what() << '\n';
    }
}
dla powyższego kodu mamy następujący wydruk:
ID = 1
Imie = Tadeusz
Wiek = 24
Adres = Krakow
Pensja = 2000.01
ID = 2
Imie = Symeon
Wiek = 25
Adres = Texas
Pensja = 15000.0
ID = 3
Imie = Rodion
Wiek = 23
Adres = Norway
Pensja = 20000.0
Dla porównania kod o takiej samej funkcjonalności i identycznym wydruku w SQLite3:
C/C++
#include <iostream>
#include <sqlite/sqlite3.h>
using namespace std;


void excecuteSqlStatement( const char * sql, sqlite3 * db );

int main()
{
    sqlite3 * db;
    if( sqlite3_open( "pracownicy.db", & db ) )
    {
        cerr << "Can't open database: " << sqlite3_errmsg( db ) << endl;
        return 1;
    }
   
    const char * sql = "CREATE TABLE IF NOT EXISTS Pracownicy("
    "ID     INT PRIMARY KEY NOT NULL,"
    "Imie   TEXT            NOT NULL,"
    "Wiek   INT             NOT NULL,"
    "Adres  TEXT,"
    "Pensja REAL);";
    excecuteSqlStatement( sql, db );
   
    sql = "INSERT INTO Pracownicy (ID, Imie, Wiek, Adres, Pensja) VALUES (1, 'Tadeusz', 24, 'Krakow', 2000.01 ); "
    "INSERT INTO Pracownicy (ID, Imie, Wiek, Adres, Pensja) VALUES (2, 'Symeon', 25, 'Texas', 15000.00 ); "
    "INSERT INTO Pracownicy (ID, Imie, Wiek, Adres, Pensja) VALUES (3, 'Rodion', 23, 'Norway', 20000.00 );";
    excecuteSqlStatement( sql, db );
   
    sql = "SELECT * from Pracownicy";
    excecuteSqlStatement( sql, db );
   
    sqlite3_close( db );
}

int callback( void * /*notUsed*/, int argc, char ** argv, char ** azColName )
{
    for( int i = 0; i < argc; ++i )
    {
        cout << azColName[ i ] << " = " <<( argv[ i ] ? argv[ i ]: "NULL" ) << endl;
    }
    return 0;
}

void excecuteSqlStatement( const char * sql, sqlite3 * db )
{
    char * zErrMsg = nullptr;
    if( sqlite3_exec( db, sql, callback, nullptr, & zErrMsg ) != SQLITE_OK )
    {
        cerr << "SQL error: " << zErrMsg << endl;
        sqlite3_free( zErrMsg );
    }
}

Zapytania do bazy danych przy pomocy biblioteki SOCI

Widzieliśmy już przykład, trzeba się jednak przyjrzeć bardziej dogłębnie tworzeniu zapytań do bazy.
Operujemy na zapytaniach SQLowych przy pomocy klasy
soci::session
. Możemy zwyczajnie utworzyć zapytanie i przekazać je do tejże klasy:
sql << "DROP TABLE Pracownicy";
.
Możemy też przesyłać zmienne jak do strumienia:
C/C++
string tableName = "Pracownicy";
sql << "DROP TABLE " << tableName;

int id = 123;
sql << "DELETE FROM Pracownicy WHERE id = " << id;

Lepiej nie podawać zmiennych tekstowych jak do strumienia, gdyż wtedy narażamy się na SQL Injection, dlatego bardziej zalecanym sposobem przesyłania zmiennych do zapytań jest:
C/C++
int number = 7;
sql << "INSERT INTO Liczby(val) VALUES(:myVariableWithAnyName)", use( number );
Wartości może być więcej:
C/C++
string firstName = "John";
int personId = 7, personAge = 18;

sql << "INSERT INTO Pracownicy(id, imie, wiek) VALUES(:id, :fn, :ln)", use( personId ), use( firstName ), use( personAge );
Dla czytelności możemy wskazać, której zmiennej w zapytaniu odpowiada która zmienna w kodzie:
C/C++
string firstName = "John";
int personId = 7, personAge = 18;
sql << "INSERT INTO Pracownicy(id, imie, wiek) VALUES(:id, :name, :age)", use( firstName, "name" ), use( personAge, "age" ), use( personId, "id" );
string addr = "...";
sql << "UPDATE Pracownicy SET adres = :addr, adres_korespondencyjny = :addr WHERE id = 7", use( addr, "addr" );

Podając zmienne do zapytania musimy mieć pewność, że będą one istnieć w czasie zadawania zapytania. Dla poniższego kodu nie mamy takiej pewności:
C/C++
string getNameFromSomewhere();

sql << "INSERT INTO Pracownicy(name) values(:n)", use( getNameFromSomewhere() );
.
Dlatego lepiej używać wtedy zmiennych lokalnych. Oczywiście jeśli zmienna lokalna będzie const może się pojawić błąd, gdyż niektóre bazy traktują parametry jako wejściowo-wyjściowe (np. procedury w Oraclowym PL/SQLu).

Pobieranie danych z bazy

Poza wstawianiem do bazy potrzebujemy z niej odczytywać. Możemy to zrobić w następujący sposób:
C/C++
int count;
sql << "SELECT COUNT(*) FROM Pracownicy", into( count );

string name;
sql << "SELECT imie FROM Pracownicy WHERE id = 7", into( name );

Obsługa nulli

W bazie danych każda krotka, która nie jest oznaczona jako
NOT NULL
 może nie mieć podanej wartości (tzn. mieć wartość
null
). Do obsługi nulli w SOCI używa się typu
soci::indicator
 w następujący sposób:
C/C++
string name;
indicator ind;
sql << "SELECT imie FROM Pracownicy WHERE id = 7", into( name, ind );

if( sql.got_data() )
{
    switch( ind )
    {
    case i_ok:
        // dane zwrócono bez problemu
        break;
    case i_null:
        // istnieje taka osoba w bazie, ale bez imienia (w miejscu imienia jest null)
        break;
    case i_truncated:
        // zwrócono tylko część imienia, ze względu na zbyt mały buffor (nie dotyczy typu std::string)
        break;
    }
}
else
{
    // brak osoby o takim ID w bazie
}
W razie gdy nie podamy do funkcji zmiennej typu
soci::indicator
, a będzie null, to zostanie wyrzucony wyjątek. Dlatego warto go używać tego typu.

Zmienna indicator może też być przydatna, gdy chcemy wstawić wartość null do bazy danych:
C/C++
int id = 7;
string name;
indicator ind = i_null;
sql << "INSERT INTO Pracownicy(id, imie) VALUES(:id, :name)", use( id ), use( name, ind );

Odczyt wielu danych

Gdy chcemy odczytać wiele danych, używamy zamiast "pojedynczych" zmiennych kontenerów, podobnie potrzebujemy również kontener typu
soci::indicator
. Wczytywanie równocześnie wielu danych pozwala serwerowi bazy danych na optymalizacje:
C/C++
size_t maximumNumberOfElement = 100;
vector < string > names( maximumNumberOfElement );
vector < indicator > inds;
sql << "SELECT imie FROM Pracownici WHERE id = 7", into( names, inds );

Jeśli chcemu obsługiwać wartości nullowe z bazy danych w lepszy sposób możemy używać
boost::optional < std::string >
, ale do tego trzeba zbudować SOCI z opcją
WITH_BOOST
. Domyślnie ta opcja będzie zaznaczona, jeśli biblioteka boost zostanie wykryta
Poza tym możemy, wstawiając dane do bazy z vectora, podać zakresy od-do, które mają być wstawione:
C/C++
int main()
{
    try
    {
        soci::session sql( sqlite3, "pracownicy.db" );
        sql << "CREATE TABLE IF NOT EXISTS Names(Name TEXT NOT NULL);";
       
        constexpr size_t maximumRows2Fetch = 20;
        std::vector < string > womenNames = { "Aleksandra", "Beata", "Cecylia", "Diana", "Ela" };
        std::vector < string > names2( maximumRows2Fetch );
        sql << "INSERT INTO Names VALUES(:v);", use( menNames );
        std::size_t insertPositionFrom = 1;
        std::size_t insertPositionTo = 3;
        sql << "INSERT INTO Names VALUES(:v);", use( womenNames, insertPositionFrom, insertPositionTo );
       
        sql << "SELECT Name FROM Names;", into( names2 );
        copy( begin( names2 ), end( names2 ), ostream_iterator < string >( cout, "\n" ) );
    }
    catch( const exception & e )
    {
        cerr << "Error: " << e.what() << '\n';
    }
}
Niestety w SQLite nie jest wspierane dodawanie wielu wartości, podając zakres od-do.

Odczytywanie całych wierszy

Aby odczytać cały wiersz możemy się posłużyć typem
soci::row
, który poza wartościami będzie znał również nazwy pól.

Wspierane typy

Zanim jednak przejdziemy do przykładu, przedstawię obsługiwane przez bibliotekę SOCI typy:
Typ w C++ typy w bazie danych
char
wartości znakowe
short, int, unsigned long, long long, double
wartości liczbowe
char *, char[], std::string
wartości tekstowe
std::tm
wartości daty i czasu
soci::statement
zagnieżdżone wyrażenia i kursory w PL/SQL
soci::blob
obiekty binarne (ang. Binary Large Objects)
soci::row_id
identyfikatory wiersza

Jeśli z kolei chcemy pobrać wiele wartości równocześnie, możliwe są następujące przeładowania dla:
soci::use
 i
soci::into
:
  • std::vector < char >
  • std::vector < short >
  • std::vector < int >
  • std::vector < unsigned long >
  • std::vector < long long >
  • std::vector < double >
  • std::vector < std::string >
  • std::vector < std::tm >

Odczyt całego wiersza robimy w następujący sposób:
C/C++
soci::row r;
sql << "SELECT * FROM Pracownicy", into( r );

for( std::size_t i = 0; i != r.size(); ++i )
{
    const column_properties & props = r.get_properties( i );
   
    cout << props.get_name() << ": ";
   
    if( r.get_indicator( i ) != soci::i_null )
    {
        switch( props.get_data_type() )
        {
        case dt_string:
            cout << r.get < std::string >( i ) << "\n";
            break;
        case dt_double:
            cout << r.get < double >( i ) << "\n";
            break;
        case dt_integer:
            cout << r.get < int >( i ) << "\n";
            break;
        case dt_long_long:
            cout << r.get < long long >( i ) << "\n";
            break;
        case dt_unsigned_long_long:
            cout << r.get < unsigned long long >( i ) << "\n";
            break;
        case dt_date:
            std::tm when = r.get < std::tm >( i );
            cout << asctime( & when ) << "\n";
            break;
        case dt_blob:
            cout << "(file)\n";
            break;
        default:
            cerr << "(unknown type!)\n";
        }
    }
    else
    {
        cout << "(null)\n";
    }
}

W razie rzutowania
soci::row::get < T >(...)
 na zły typ zostanie wyrzucony wyjątek
std::bad_cast
. Mamy następujące specjalizacje dla funkcji
soci::row::get < T >(...)
:
SOCI Data Typespecjalizacja row::get<T>
dt_double
double
dt_integer
int
dt_long_long
long long
dt_unsigned_long_long
unsigned long long
dt_string
std::string
dt_date
std::tm

Możemy też wyjmować dane z wiersza
soci::row
 jak ze strumienia:
C/C++
row r;
sql << "SELECT name, address, age FROM persons WHERE id = 123", into( r );

string name, address;
int age;

r >> name >> address >> age;

Aby wyjąć całą tabelę wierszy, możemy użyć (kompletny kod został przedstawiony w pierwszym przykładzie z SOCI, który przedstawiłem):
C/C++
soci::rowset < soci::row > rows = sql.prepare << "SELECT * FROM Pracownicy";

for( auto & row: rows )
{
    for( std::size_t i = 0; i != row.size(); ++i )
    {
        //...
    }
}

Operacje na dużych obiektach

Poza typami pierwotnymi zdarza się, że w bazie musimy umieścić również pliki tekstowe lub binarne. Poza tym wiele baz danych może nie zezwalać na bardzo długi tekst jako normalną krotkę w bazie (tutaj należy sprawdzić w dokumentacji danej bazy danych). Do operacji na dużych obiektach używamy typu
soci::blob
 w następujący sposób:
C/C++
#include <iostream>
#include <fstream>
#include <string>
#include <exception>
#include <soci/soci.h>
#include <soci/sqlite3/soci-sqlite3.h>

using namespace std;
using namespace soci;

soci::blob fileIntoDBObject( const char * fileName, soci::session & sql )
{
    ifstream file( fileName, ios::in | ios::binary );
    if( !file )
    {
        throw std::invalid_argument( "Problem with opening file '" s + fileName + "' for reading!" );
    }
   
    std::vector < unsigned char > buffer2( std::istreambuf_iterator < char >( file ), { } );
    soci::blob b( sql );
    b.write( 0, reinterpret_cast < const char *>( buffer2.data() ), buffer2.size() );
    return b;
}
void blobFromDBIntoFile( const char * fileName, blob & b )
{
    ofstream file( fileName, ios::out | ios::binary );
    if( !file )
    {
        throw std::invalid_argument( "Problem with opening file '" s + fileName + "' for writing!" );
    }
   
    const auto blobLength = b.get_len();
    string buffer;
    buffer.reserve( blobLength );
    b.read( 0, & buffer[ 0 ], blobLength );
    file.write( & buffer[ 0 ], blobLength );
}

int main()
{
    try
    {
        soci::session sql( sqlite3, "pracownicy.db" );
       
        sql << "CREATE TABLE IF NOT EXISTS Pracownicy("
        "ID INT PRIMARY KEY     NOT NULL,"
        "Imie           TEXT    NOT NULL,"
        "Wiek           INT     NOT NULL,"
        "Adres          TEXT,"
        "Pensja         REAL,"
        "CV             BLOB);";
       
        { /// save file to database:
            soci::blob cv2Insert = fileIntoDBObject( "CVs/from/mail/CV_worker2.pdf", sql );
            sql << "UPDATE Pracownicy SET CV=(:cv) WHERE id=2;", use( cv2Insert );
        }
        { /// load file from database:
            soci::blob cvFromDatabase( sql );
            indicator ind;
            sql << "SELECT CV FROM Pracownicy WHERE id=2;", into( cvFromDatabase, ind );
            if( ind == i_ok )
            {
                blobFromDBIntoFile( "path/to/CVs/CV_worker2_fromDatabase.pdf", cvFromDatabase );
            }
            else
            {
                cerr << "Problem with file -not found!\n";
            }
        }
    }
    catch( const exception & e )
    {
        cerr << "Error: " << e.what() << endl;
    }
}
Jeśli wszystko pójdzie w porządku, nie będzie żadnego wydruku, natomiast pojawi się nam we wskazanym miejscu plik, który był włożony do bazy, po czym został odczytany. Oczywiście aby ten kod działał, należy mieć odpowiednią kolumnę w bazie danych typu
BLOB
.

Własne typy użytkownika

W C++ często piszemy obiektowo, więc wygodnie jest móc wstawiać i wczytywać nasz typ bezpośrednio do i z bazy danych, bez zabawy w pojedyncze typy. W SOCI da się to zrobić, a ponadto jest to nieinwazyjne, czyli nie wymaga modyfikacji kodu klasy! Aby to zrobić należy dokonać specjalizacji szablonu struktury
soci::type_conversion < Person >
, definiując w nim dwie metody:
soci::type_conversion < Person >::from_base
 i
soci::type_conversion < Person >::to_base
. Przedstawię to dla struktury Person, chociaż dla prostoty pominę plik (kolumne "CV"):
C/C++
#include <iostream>
#include <string>
#include <string_view>
#include <exception>
#include <soci/soci.h>
#include <soci/sqlite3/soci-sqlite3.h>

using namespace std;
using namespace soci;


struct Employee
{
    Employee() = default;
    Employee( int id, const string_view name, int age, const string_view address, double salary = { } )
        : id( id )
         , name( name )
         , age( age )
         , address( address )
         , salary( salary )
    { }
   
    int id = { };
    std::string name;
    int age = { };
    std::string address;
    double salary = { };
};

std::ostream & operator <<( std::ostream & o, const Employee & employee )
{
    return o << "Employee(" << employee.id << "): " << employee.name
    << ", address: " << employee.address << ", age: " << employee.age << ", earns: " << employee.salary;
}


namespace soci
{
    template <>
    struct type_conversion < Employee >
    {
        typedef values base_type;
       
        static void from_base( values const & v, indicator /* ind */, Employee & p )
        {
            p.id = v.get < int >( "ID" );
            p.name = v.get < std::string >( "Imie", "" ); // (1)
            p.age = v.get < int >( "Wiek", 0 );
            if( v.get_indicator( "Adres" ) == i_null ) // (2)
            {
                p.address = "(bezdomny)";
            }
            else
            {
                p.address = v.get < std::string >( "Adres" );
            }
            p.salary = v.get < double >( "Pensja", 0.);
        }
       
        static void to_base( const Employee & p, values & v, indicator & ind )
        {
            v.set( "ID", p.id );
            v.set( "Imie", p.name, p.name.empty() ? i_null
                : i_ok ); // (3)
            v.set( "Wiek", p.age, p.age == - 1 ? i_null: i_ok );
            v.set( "Adres", p.address, p.address == "(bezdomny)" ? i_null: i_ok );
            v.set( "Pensja", p.salary, p.salary == 0.? i_null: i_ok );
            ind = i_ok;
        }
    };
}

int main()
{
    try
    {
        soci::session sql( sqlite3, "pracownicy.db" );
        sql << "CREATE TABLE IF NOT EXISTS Pracownicy("
        "ID INT PRIMARY KEY  NOT NULL,"
        "Imie           TEXT,"
        "Wiek           INT,"
        "Adres          TEXT,"
        "Pensja         REAL);";
       
        constexpr int idOfNewEmployee = 6;
        {
            Employee newEmployee { idOfNewEmployee, "Izydor", 76, "Sewilla", 636.44 };
            sql << "INSERT INTO Pracownicy(ID, Imie, Wiek, Adres, Pensja) "
            "VALUES(:ID, :Imie, :Wiek, :Adres, :Pensja)", use( newEmployee );
        }
        {
            Employee employee;
            sql << "SELECT * FROM Pracownicy WHERE ID = :ID;", use( idOfNewEmployee ), into( employee );
            cout << "Employee from DB:     " << employee << endl;
        }
        {
            Employee employee;
            employee.id = idOfNewEmployee;
            employee.name = "Ignacy";
            employee.address = "Antiochia";
            sql << "UPDATE Pracownicy SET Imie = :Imie, Adres = :Adres "
            "WHERE ID = :ID", use( employee ); // (4)
        }
        {
            Employee employee;
            sql << "SELECT * FROM Pracownicy WHERE ID = :ID;", use( idOfNewEmployee ), into( employee );
            cout << "From DB after update: " << employee << endl;
        }
    }
    catch( const exception & e )
    {
        cerr << "Error: " << e.what() << endl;
    }
}
Kod zasadniczo jest prosty, wymaga jednak paru wyjaśnień, oznaczonych w komentarzu:
(1)pole "Imie" może mieć wartość
null
, więc przed dokonaniem rzutowania na
std::string
 trzeba sprawdzić, czy nie mamy do czynienia z
null
. Właśnie po to jest drugi argument, aby w razie natrafienia na
null
 wstawić alternatywną wartość, w tym wypadku pusty
std::string
(2)jw. problem z nullem, natomiast tutaj dokonujemy wpierw sprawdzenia, czy mamy do czynienia z nullem, a dopiero później, zależnie od wyniku sprawdzenia, wstawiamy odpowiednią wartość
(3)również problem z wartością null, ale w drugą stronę - czyli wcześniej wpisywaliśmy do std::string brak wartości, teraz trzeba z tego braku wartości wstawić do bazy null
(4)jak widać ze struktury pobierane są wyłącznie wartości, które wyszczególniliśmy w zapytaniu, nie wszystkie
Przy mapowaniu obiektów musimy w SQLu podawać dokładne identyfikatory pól, z uwzględnieniem wielkości liter, gdyż bez tego to nie będzie działać. Przykładowo dla tabeli jw.:
CREATE TABLE IF NOT EXISTS Pracownicy(
ID INT PRIMARY KEY  NOT NULL,
    Imie           TEXT,
    Wiek           INT,
    Adres          TEXT,
    Pensja         REAL);
Jeśli wykonujemy SQL np:
C/C++
//...
Employee newEmployee { idOfNewEmployee, "Izydor", 76, "Sewilla", 636.44 };
sql << "INSERT INTO Pracownicy(ID, Imie, Wiek, Adres, Pensja) "
"VALUES(:ID, :Imie, :Wiek, :Adres, :Pensja)", use( newEmployee );
to on zadziała, a np. taki kod już nie zadziała:
C/C++
//...
Employee newEmployee { idOfNewEmployee, "Izydor", 76, "Sewilla", 636.44 };
sql << "INSERT INTO Pracownicy(ID, Imie, Wiek, Adres, Pensja) "
"VALUES(:ID, :Imie, :wiek, :Adres, :Pensja)", use( newEmployee );
a dokładniej nie wstawi wieku do bazy. Dzieje się tak dlatego, że dopasowania z funkcji
soci::type_conversion < Employee >::from_base
 są wrażliwe na wielkość liter.

Obsługa czasu i daty

W bazach danych możemy trzymać również inne typy, SOCI dostarcza jeszcze funkcjonalność do obsługi daty i czasu. Niestety z poziomu C++ przechwytujemy czas z bazy przy pomocy struktury języka C
std::tm
, poniżej przykład użycia:
C/C++
#include <iostream>
#include <string>
#include <chrono>
#include <exception>
#include <soci/soci.h>
#include <soci/sqlite3/soci-sqlite3.h>

using namespace soci;
using namespace std;

struct Employee
{
    Employee() = default;
    Employee( int id, const string_view name )
        : id( id )
         , name( name )
    {
        auto currentTime = std::chrono::system_clock::now();
        std::time_t currentTimeAsTimeT = std::chrono::system_clock::to_time_t( currentTime );
       
        contractExpiration = firstLoginTime = * localtime( & currentTimeAsTimeT );
        contractExpiration.value().tm_year++;
    }
   
    int id = { };
    std::string name;
    optional < tm > firstLoginTime, contractExpiration;
};

std::ostream & operator <<( std::ostream & o, const Employee & employee )
{
    return o << "Employee(" << employee.id << "): " << employee.name
    << ", first loggin: " <<( employee.firstLoginTime ? asctime( & employee.firstLoginTime.value() )
        : "never" ) << "\tworks until: " <<( employee.contractExpiration ? asctime( & employee.contractExpiration.value() )
        : "not working" );
}

namespace soci
{
    template <>
    struct type_conversion < Employee >
    {
        typedef values base_type;
       
        static void from_base( values const & v, indicator /* ind */, Employee & p )
        {
            p.id = v.get < int >( "ID" );
            p.name = v.get < std::string >( "Imie" );
            p.firstLoginTime = v.get_indicator( "PierwszeLogowanie" ) == i_ok ? v.get < tm >( "PierwszeLogowanie" )
                : optional < tm >
            { };
            p.contractExpiration = v.get_indicator( "WaznoscUmowy" ) == i_ok ? v.get < tm >( "WaznoscUmowy" )
                : optional < tm >
            { };
        }
       
        static void to_base( const Employee & p, values & v, indicator & ind )
        {
            v.set( "ID", p.id );
            v.set( "Imie", p.name, p.name.empty() ? i_null
                : i_ok );
            v.set( "PierwszeLogowanie", p.firstLoginTime.value(), p.firstLoginTime ? i_ok
                : i_null );
            v.set( "WaznoscUmowy", p.contractExpiration.value(), p.contractExpiration ? i_ok
                : i_null );
            ind = i_ok;
        }
    };
}

int main()
{
    try
    {
        soci::session sql( sqlite3, "pracownicy.db" );
       
        sql << "CREATE TABLE IF NOT EXISTS Pracownicy("
        "ID                INT PRIMARY KEY     NOT NULL,"
        "Imie              TEXT                NOT NULL,"
        "PierwszeLogowanie DATETIME,"
        "WaznoscUmowy      DATE);";
        sql << "DELETE FROM Pracownicy;";
       
        {
            Employee employee( 1, "Jacek Soplica" );
            sql << "INSERT INTO Pracownicy(ID, Imie, PierwszeLogowanie, WaznoscUmowy) "
            "VALUES (:ID, :Imie, :PierwszeLogowanie, :WaznoscUmowy); ", use( employee );
        }
        {
            Employee employee;
            sql << "SELECT * FROM Pracownicy;", into( employee );
            cout << employee << endl;
        }
    }
    catch( const soci_error & e )
    {
        cerr << "Error: " << e.what() << " category: " << e.get_error_category() << '\n';
    }
}
Wydruk:
Employee(1): Jacek Soplica, first loggin: Fri Dec 13 23:56:53 2019
        works until: Sun Dec 13 23:56:53 2020
Dla polepszenia humoru dodam, że praca z czasem może być wygodniejsza, jeśli włączymy obsługę biblioteki boost podczas budowania SOCI, ale o tym później.

Wygodniejsze używanie baz danych z poziomu SOCI

Zasadniczo przebrnęliśmy w bezwzględne podstawy, aby używać biblioteki. Teraz jednak pojawią się dodatkowe ułatwienia - zarówno w użyciu bazy danych, jak i kwestie optymalizacyjne.

Optymalizacja powtarzalnych zapytań

Zacznę od najprostrzego przykładu:
C/C++
#include <iostream>
#include <string>
#include <chrono>
#include <exception>
#include <soci/soci.h>
#include <soci/sqlite3/soci-sqlite3.h>

using namespace soci;
using namespace std;
using namespace std::chrono;


void runMultipleQueriesWithVariables( soci::session & sql, size_t queriesCount )
{
    string name;
    int age;
    double salary;
    for( size_t i = 0; i < queriesCount; ++i )
    {
        name = "Janusz " + to_string( i );
        age = rand() % 100;
        salary = static_cast < double >( rand() ) / RAND_MAX * 10000;
        sql << "INSERT INTO Pracownicy(ID, Imie, Wiek, Pensja) VALUES (:id, :name, :age, :salary); ", use( i ), use( name ), use( age ), use( salary );
    }
}
void runMultipleQueriesWithStreams( soci::session & sql, size_t queriesCount )
{
    string name;
    int age;
    double salary;
    for( size_t i = 0; i < queriesCount; ++i )
    {
        name = "Janusz " + to_string( i );
        age = rand() % 100;
        salary = static_cast < double >( rand() ) / RAND_MAX * 10000;
        sql << "INSERT INTO Pracownicy(ID, Imie, Wiek, Pensja) VALUES (" << i << ", '" << name << "'," << age << "," << salary << ");";
    }
}
void runMultiplePreparedQueries( soci::session & sql, size_t queriesCount )
{
    string name;
    int age, i;
    double salary;
    statement st =( sql.prepare << "INSERT INTO Pracownicy(ID, Imie, Wiek, Pensja) VALUES (:id, :name, :age, :salary);",
    use( i ), use( name ), use( age ), use( salary ) );
    for( i = 0; i < queriesCount; ++i )
    {
        constexpr bool exhangeData = true;
        st.execute( exhangeData );
    }
}

void testFunctionExecutionTime( const char * textPrefix, void( * function )( soci::session &, size_t ), soci::session & sql )
{
    auto startingTime = steady_clock::now();
    {
        constexpr size_t queriesCount = 1000;
        function( sql, queriesCount );
    }
    const auto elapsedTimeInSeconds = std::chrono::duration < double >( steady_clock::now() - startingTime ).count();
    cout << textPrefix << ", the function took seconds: " << elapsedTimeInSeconds << endl;
   
    sql << "DELETE FROM Pracownicy;";
}

int main()
{
    try
    {
        soci::session sql( sqlite3, "pracownicy.db" );
       
        sql << "CREATE TABLE IF NOT EXISTS Pracownicy("
        "ID INT PRIMARY KEY     NOT NULL,"
        "Imie           TEXT    NOT NULL,"
        "Wiek           INT     NOT NULL,"
        "Pensja         REAL);";
       
        testFunctionExecutionTime( "not prepared with variables", runMultipleQueriesWithVariables, sql );
        testFunctionExecutionTime( "not prepared with streams", runMultipleQueriesWithStreams, sql );
        testFunctionExecutionTime( "prepared query", runMultiplePreparedQueries, sql );
       
        sql << "DELETE TABLE Pracownicy;";
    }
    catch( const exception & e )
    {
        cerr << "Error: " << e.what() << '\n';
    }
}
Wydruk, który wydaje się przeczyć temu, co pisałem wcześniej o przyspieszaniu kolejnych zapytań:
not prepared with variables, the function took seconds: 42.0187
not prepared with streams, the function took seconds: 28.5683
prepared query, the function took seconds: 33.6227
Na moją obronę jest wpis w dokumentacji, że nie wszystkie bazy danych obsługują optymalizację powtarzających się zapytań. Proszę się też nie obawiać - na Twoim komputerze te kilka transakcji będzie szybsze (obecnie piszę na starej hybrydzie laptopa z tabletem).

Odczytywanie z bazy po jednej wartości

Bardziej znane też jako używanie kursorów w bazie danych. Aby to zrobić przy pomocy SOCI używamy funkcji statement::fetch. Przykład wzbogacę o wcześniej omówione mapowania obiektów:
C/C++
#include <iostream>
#include <string>
#include <exception>
#include <soci/soci.h>
#include <soci/sqlite3/soci-sqlite3.h>

using namespace soci;
using namespace std;

struct Employee
{
    Employee() = default;
    Employee( int id, const string_view name, int age, double salary = { } )
        : id( id )
         , name( name )
         , age( age )
         , salary( salary )
    { }
   
    int id = { };
    std::string name;
    int age = { };
    double salary = { };
};

std::ostream & operator <<( std::ostream & o, const Employee & employee )
{
    return o << "Employee(" << employee.id << "): " << employee.name << ", age: " << employee.age << ", earns: " << employee.salary;
}

namespace soci
{
    template <>
    struct type_conversion < Employee >
    {
        typedef values base_type;
       
        static void from_base( values const & v, indicator /* ind */, Employee & p )
        {
            p.id = v.get < int >( "ID" );
            p.name = v.get < std::string >( "Imie", "" );
            p.age = v.get < int >( "Wiek", 0 );
            p.salary = v.get < double >( "Pensja", 0.);
        }
       
        static void to_base( const Employee & p, values & v, indicator & ind )
        {
            v.set( "ID", p.id );
            v.set( "Imie", p.name, p.name.empty() ? i_null
                : i_ok );
            v.set( "Wiek", p.age, p.age == - 1 ? i_null: i_ok );
            v.set( "Pensja", p.salary, p.salary == 0.? i_null: i_ok );
            ind = i_ok;
        }
    };
}

void runMultipleQueriesWithVariables( soci::session & sql, size_t queriesCount )
{
    for( size_t i = 0; i < queriesCount; ++i )
    {
        string name = "Janusz " + to_string( i );
        int age = rand() % 100;
        double salary = static_cast < double >( rand() ) / RAND_MAX * 10000;
        sql << "INSERT INTO Pracownicy(ID, Imie, Wiek, Pensja) VALUES (:id, :name, :age, :salary); ", use( i ), use( name ), use( age ), use( salary );
    }
}

int main()
{
    try
    {
        soci::session sql( sqlite3, "pracownicy.db" );
       
        sql << "CREATE TABLE IF NOT EXISTS Pracownicy("
        "ID INT PRIMARY KEY     NOT NULL,"
        "Imie           TEXT    NOT NULL,"
        "Wiek           INT     NOT NULL,"
        "Pensja         REAL);";
        sql << "DELETE FROM Pracownicy;";
       
        runMultipleQueriesWithVariables( sql, 5 );
       
        {
            string name;
            int age, id;
            double salary;
            statement st =( sql.prepare << "SELECT ID, Imie, Wiek, Pensja FROM Pracownicy;", into( id ), into( name ), into( age ), into( salary ) );
           
            st.execute();
            while( st.fetch() )
            {
                Employee employee( id, name, age, salary );
                cout << employee << '\n';
            }
        }
       
        {
            Employee employee;
            statement st =( sql.prepare << "SELECT ID, Imie, Wiek, Pensja FROM Pracownicy;", into( employee ) );
            st.execute();
            while( st.fetch() )
            {
                cout << employee << '\n';
            }
        }
    }
    catch( const exception & e )
    {
        cerr << "Error: " << e.what() << '\n';
    }
}
Wydruk:
Employee(0): Janusz 0, age: 41, earns: 5635.85
Employee(1): Janusz 1, age: 34, earns: 8087.41
Employee(2): Janusz 2, age: 69, earns: 4798.73
Employee(3): Janusz 3, age: 78, earns: 8959.62
Employee(4): Janusz 4, age: 62, earns: 7466.05
Employee(0): Janusz 0, age: 41, earns: 5635.85
Employee(1): Janusz 1, age: 34, earns: 8087.41
Employee(2): Janusz 2, age: 69, earns: 4798.73
Employee(3): Janusz 3, age: 78, earns: 8959.62
Employee(4): Janusz 4, age: 62, earns: 7466.05

Użycie kursorów bazy danych - odczyt po jednej wartości

Nie wiem, czy rzuciła Ci się w oczy, Drogi Czytelniku, niedogodność z wczytywaniem wielu wartości do
std::vector
, związana z tym, że musimy podać maksymalną ilość elementów do odczytania. Jest na to rozwiązanie przy użyciu kursorów. Pozwolę sobie jednak na wrzucenie jedynie funkcji main, reszta kodu jw.:
C/C++
int main()
{
    try
    {
        soci::session sql( sqlite3, "pracownicy.db" );
       
        sql << "CREATE TABLE IF NOT EXISTS Pracownicy("
        "ID INT PRIMARY KEY     NOT NULL,"
        "Imie           TEXT    NOT NULL,"
        "Wiek           INT     NOT NULL,"
        "Pensja         REAL);";
       
        runMultipleQueriesWithVariables( sql, 100 );
       
        constexpr size_t maximumRows2Fetch = 20;
        std::vector < string > employeesNames( maximumRows2Fetch ); // (1)
        std::vector < double > employeesSalaries( maximumRows2Fetch );
        std::vector < indicator > employeesSalariesIndicators( maximumRows2Fetch ); // (2)
        statement st =( sql.prepare << "SELECT Imie, Pensja FROM Pracownicy;", into( employeesNames ), into( employeesSalaries, employeesSalariesIndicators ) );
        st.execute();
        while( st.fetch() )
        {
            for( size_t i = 0; i < employeesNames.size(); ++i )
            {
                cout << employeesNames[ i ] << " earns: "
                <<( employeesSalariesIndicators[ i ] == i_ok ? employeesSalaries[ i ]: 0 ) << '\n';
            }
            cout << endl;
           
            employeesNames.resize( maximumRows2Fetch ); // (3)
            employeesSalaries.resize( maximumRows2Fetch );
        }
        sql << "DROP TABLE Pracownicy;";
    }
    catch( const exception & e )
    {
        cerr << "Error: " << e.what() << '\n';
    }
}
Wydruk (częściowy):
Janusz 0 earns: 5635.85
Janusz 1 earns: 8087.41
Janusz 2 earns: 4798.73
Janusz 3 earns: 8959.62
Janusz 4 earns: 7466.05
Janusz 5 earns: 8589.43
Janusz 6 earns: 5135.35
Janusz 7 earns: 149.846
Janusz 8 earns: 3644.52
Janusz 9 earns: 1658.99
Janusz 10 earns: 4456.92
Janusz 11 earns: 46.6933
Janusz 12 earns: 3778.8
Janusz 13 earns: 5711.84
Janusz 14 earns: 6071.66
Janusz 15 earns: 6630.45
Janusz 16 earns: 3521.23
Janusz 17 earns: 6076.85
Janusz 18 earns: 8026.06
Janusz 19 earns: 3019.5

...

Janusz 80 earns: 1940.67
Janusz 81 earns: 6267.59
Janusz 82 earns: 1978.51
Janusz 83 earns: 1233.25
Janusz 84 earns: 7431.26
Janusz 85 earns: 9410.69
Janusz 86 earns: 3363.14
Janusz 87 earns: 7330.85
Janusz 88 earns: 7079.99
Janusz 89 earns: 7472.15
Janusz 90 earns: 1444.75
Janusz 91 earns: 610.065
Janusz 92 earns: 8526.26
Janusz 93 earns: 1156.04
Janusz 94 earns: 142.521
Janusz 95 earns: 4545.12
Janusz 96 earns: 6861.48
Janusz 97 earns: 738.853
Janusz 98 earns: 2019.41
Janusz 99 earns: 2903.53
Tutaj parę kwestii wymaga wyjaśnienia:
(1)odczytujemy wiele wartości równocześnie do vectora, jednakże musimy ustawić maksymalną ilość elementów do wczytania. Jest to co prawda niewygodne, ale tak zrobili twórcy, w razie pobrania mniejszej ilości elementów z bazy rozmiar vectora będzie jej odpowiadał
(2)jeśli chcemy odróżnić wartości będące nullami potrzebujemy mieć vector<indicator>, w którym również ustawiamy maksymalną ilość elementów, oczywiście powinna odpowiadać rozmiarowi vectora elementów
(3)po wczytaniu elementów będzie w vectorze tyle elementów, ile było, ale nie więcej niż wcześniejszy rozmiar vectora. Dlatego po każdej iteracji musimy ponownie ustawić rozmiar vectora na taki, jaki był wcześniej

Powtarzanie tego samego zapytania dla różnych zmiennych

Zapewne Tobie, Drogi Czytelniku, jak i mnie nie spodobało się to, że definiujemy zmienne i podajemy na sztywno do przygotowanego zapytania. Okazuje się, że można zmienne "podmienić" w już przygotowanym zapytaniu. Robi się to przy pomocy: 
soci::statement::exchange
 i
soci::statement::define_and_bind
, a potem tylko trzeba odłączyć powiązanie przy pomocy:
soci::statement::bind_clean_up()
. Oto przykład:
C/C++
#include <iostream>
#include <string>
#include <chrono>
#include <exception>
#include <soci/soci.h>
#include <soci/sqlite3/soci-sqlite3.h>

using namespace soci;
using namespace std;
using namespace std::chrono;

struct Representative
{
    Representative() = default;
    Representative( int id, const string_view name, int age )
        : id( id )
         , name( name )
         , age( age )
    { }
   
    int id = { };
    std::string name;
    int age = { };
};

std::ostream & operator <<( std::ostream & o, const Representative & representative )
{
    return o << "Representative(" << representative.id << "): " << representative.name << ", age: " << representative.age;
}

namespace soci
{
    template <>
    struct type_conversion < Representative >
    {
        typedef values base_type;
       
        static void from_base( values const & v, indicator /* ind */, Representative & p )
        {
            p.id = v.get < int >( "ID" );
            p.name = v.get < std::string >( "Imie", "" );
            p.age = v.get < int >( "Wiek", 0 );
        }
       
        static void to_base( const Representative & p, values & v, indicator & ind )
        {
            v.set( "ID", p.id );
            v.set( "Imie", p.name, p.name.empty() ? i_null
                : i_ok );
            v.set( "Wiek", p.age, p.age == - 1 ? i_null: i_ok );
            ind = i_ok;
        }
    };
}

int main()
{
    try
    {
        soci::session sql( sqlite3, "pracownicy.db" );
       
        sql << "CREATE TABLE IF NOT EXISTS Poslowie("
        "ID INT PRIMARY KEY     NOT NULL,"
        "Imie           TEXT    NOT NULL,"
        "Wiek           INT     NOT NULL);";
       
        soci::statement st =( sql.prepare << "INSERT INTO Poslowie(ID, Imie, Wiek) VALUES(:ID, :Imie, :Wiek)" );
        {
            int id = 0;
            string name( "Janusz Korwin-Mikke" );
            size_t age = 77;
           
            st.exchange( soci::use( id ) );
            st.exchange( soci::use( name ) );
            st.exchange( soci::use( age ) );
           
            st.define_and_bind();
            st.execute( true );
            st.bind_clean_up();
        }
        {
            int id = 1;
            string name( "Dobromir Sosmierz" );
            size_t age = 43;
           
            st.exchange( soci::use( id ) );
            st.exchange( soci::use( name ) );
            st.exchange( soci::use( age ) );
           
            st.define_and_bind();
            st.execute( true );
            st.bind_clean_up();
        }
       
        st =( sql.prepare << "INSERT INTO Poslowie(ID, Imie, Wiek) VALUES(:ID, :Imie, :Wiek)" );
        {
            Representative representative( 2, "Grzegorz Braun", 52 );
           
            st.exchange( soci::use( representative ) );
           
            st.define_and_bind();
            st.execute( true );
            st.bind_clean_up();
        }
        {
            Representative representative( 3, "Krzysztof Bosak", 37 );
           
            st.exchange( soci::use( representative ) );
           
            st.define_and_bind();
            st.execute( true );
            st.bind_clean_up();
        }
       
        {
            string name;
            int age, id;
            statement st =( sql.prepare << "SELECT ID, Imie, Wiek FROM Poslowie;", into( id ), into( name ), into( age ) );
           
            st.execute();
            while( st.fetch() )
            {
                Representative representative( id, name, age );
                cout << representative << '\n';
            }
        }
    }
    catch( const exception & e )
    {
        cerr << "Error: " << e.what() << '\n';
    }
}
Na wyjaśnienie zasługuje jedynie fakt, dlaczego mamy dwukrotnie utworzone to samo zapytanie po raz pierwszy dla dwóch osób, potem dla kolejnych dwóch. Jest tak dlatego, że nie można dla tego samego zapytania mieszać różnych sposobów dodawania: albo dodajemy każdą zmienną z osobna (int, string, ...), albo dodajemy je z istniejącego obiektu użytkownika (u nas klasa Representative). Oto spodziewany wydruk:
Representative(0): Janusz Korwin-Mikke, age: 77
Representative(1): Dobromir Sosmierz, age: 43
Representative(2): Grzegorz Braun, age: 52
Representative(3): Krzysztof Bosak, age: 37

Transakcje

Zagadnienie równocześnie ważne, jak i proste, dlatego zacznę od przykładu:
C/C++
#include <iostream>
#include <string>
#include <exception>
#include <soci/soci.h>
#include <soci/sqlite3/soci-sqlite3.h>

using namespace soci;
using namespace std;

struct Employee
{
    Employee() = default;
    Employee( int id, const string_view name, int age, double salary = { } )
        : id( id )
         , name( name )
         , age( age )
         , salary( salary )
    { }
   
    int id = { };
    std::string name;
    int age = { };
    double salary = { };
};

std::ostream & operator <<( std::ostream & o, const Employee & employee )
{
    return o << "Employee(" << employee.id << "): " << employee.name << ", age: " << employee.age << ", earns: " << employee.salary;
}

namespace soci
{
    template <>
    struct type_conversion < Employee >
    {
        typedef values base_type;
       
        static void from_base( values const & v, indicator /* ind */, Employee & p )
        {
            p.id = v.get < int >( "ID" );
            p.name = v.get < std::string >( "Imie", "" );
            p.age = v.get < int >( "Wiek", 0 );
            p.salary = v.get < double >( "Pensja", 0.);
        }
       
        static void to_base( const Employee & p, values & v, indicator & ind )
        {
            v.set( "ID", p.id );
            v.set( "Imie", p.name, p.name.empty() ? i_null
                : i_ok );
            v.set( "Wiek", p.age, p.age == - 1 ? i_null: i_ok );
            v.set( "Pensja", p.salary, p.salary == 0.? i_null: i_ok );
            ind = i_ok;
        }
    };
}

int main()
{
    try
    {
        soci::session sql( sqlite3, "pracownicy.db" );
       
        sql << "CREATE TABLE IF NOT EXISTS Pracownicy("
        "ID INT PRIMARY KEY     NOT NULL,"
        "Imie           TEXT    NOT NULL,"
        "Wiek           INT     NOT NULL,"
        "Pensja         REAL);";
        sql << "DELETE FROM Pracownicy;";
       
        {
            transaction t( sql );
            Employee employee( 1, "Janusz", 77, 10000 );
            sql << "INSERT INTO Pracownicy(ID, Imie, Wiek, Pensja) VALUES (:ID, :Imie, :Wiek, :Pensja); ", use( employee );
            t.rollback();
        }
        {
            transaction t( sql );
            Employee employee( 2, "Grzegorz", 52, 8000 );
            sql << "INSERT INTO Pracownicy(ID, Imie, Wiek, Pensja) VALUES (:ID, :Imie, :Wiek, :Pensja); ", use( employee );
            t.commit();
        }
        {
            transaction t( sql );
            Employee employee( 3, "Dobromir", 43, 9500 );
            sql << "INSERT INTO Pracownicy(ID, Imie, Wiek, Pensja) VALUES (:ID, :Imie, :Wiek, :Pensja); ", use( employee );
        }
       
        {
            Employee employee;
            statement st =( sql.prepare << "SELECT * FROM Pracownicy;", into( employee ) );
            st.execute();
            while( st.fetch() )
            {
                cout << employee << '\n';
            }
        }
    }
    catch( const exception & e )
    {
        cerr << "Error: " << e.what() << '\n';
    }
}
Wydruk:
Employee(2): Grzegorz, age: 52, earns: 8000
Jak widzimy w SOCI transakcja to klasa, która ma metody do zatwierdzenia lub odrzucenia transakcji. Poza tym widzimy, że domyślnie w destruktorze jest wołana
soci::transaction::rollback()
. Z przydatnych funkcji składowych mamy jeszcze
soci::transaction::begin()
, która rozpoczyna nową transakcję.

Funkcje do modyfikacji zapytań

Możemy też używać funkcji/funktorów/lambd do wykonywania zapytań. Muszą one zwracać wartość typu
std::string
 oraz przyjmować argument takiegoż typu. Aby zarejestrować funkcję, używamy funkcji
soci::session::set_query_transformation()
 przed zapytaniem, przykładowo:
C/C++
std::string lessThan10( std::string query )
{
    return query + " WHERE price < 10";
}
// ...
session sql( sqlite3, "database_filename.db" );
sql.set_query_transformation( lessThan10 );
sql << "DELETE FROM item";
Możemy też używać funktorów i lambd:
C/C++
struct OrderBy
    : std::unary_function < std::string, std::string >
{
    OrderBy( std::string const & orderByWhat )
        : orderByWhat( orderByWhat )
    { }
   
    result_type operator ()( argument_type query ) const
    {
        return query + " ORDER BY " + orderByWhat;
    }
   
    std::string orderByWhat;
};
// ...
char const * query = "SELECT * FROM Towary";
sql.set_query_transformation( OrderBy( "cena" );
sql << query;
sql.set_query_transformation( OrderBy( "id" );
sql << query;

int age = 25;
sql.set_query_transformation(
[ & age ]( std::string const & query ) {
    return query + " WHERE age > '" + to_string( age ) + "'";
} );
sql << "SELECT * FROM Pracownicy";
Wg dokumentacji używanie funktorów do modyfikacji zapytań może się okazać pomocny w sytuacjach:
  • gdy operujemy na różnych przestrzeniach nazw w bazie danych dla baz np. testowych i produkcyjnych
  • gdy chcemy dodać wskazówki optymalizacyjne dla niektórych baz
  • gdy chcemy dodać globalnie dodatkowe sprawdzenia zapytań

Procedury

W poważniejszych aplikacjach bazodanowych są bardzo przydatne, gdyż ograniczają transfer między bazą danych a naszym programem. Niestety SQLite domyślnie nie obsługuje procedur, chociaż są dodatki do SQLite, które to robią. Zasadniczo to ma swoje uzasadnienie, gdyż procedury z założenia mają dokonywać operacji na bazie bez konieczności transferu danych między serwerem bazy danych a aplikacją. Jednakże w przypadku SQLite nasza aplikacja ma dostęp do plików bazodanowych jak serwer bazy danych, dlatego można przyznać rację twórcom, że tego nie dostarczyli.
Jednakże jeśli już jesteśmy przy temacie procedur, pozwolę sobie zacytować kod z dokumentacji:
C/C++
soci::session sql(...);

sql << "CREATE OR REPLACE PROCEDURE copyInput2Output(output OUT VARCHAR2, input IN VARCHAR2) AS "
"BEGIN "
"output := input; "
" END;";

std::string in( "Pieniadz fiducjarny" ), out;
procedure proc =( sql.prepare << "copyInput2Output(:output, :input)", use( out, "output" ), use( in, "input" ) );
proc.execute( true );
cout << "Result from procedure: " << out << endl;
Wg dokumentacji można też stosować składnie procedur specyficzną dla danego serwera bazodanowego.

Obsługa błędów

Cała obsługa błędów odbywa się w oparciu o wyjątki klasy soci::soci_error lub jej pochodnych, dziedziczący po
std::runtime_error
. Jeśli chcemy, możemy poza funkcją
soci::soci_error::what()
 poznać kategorię błędu przy pomocy:
soci::soci_error::get_error_category()
. Na chwilę obecną mamy możliwe następujące kategorie:
C/C++
enum error_category
{
    connection_error,
    invalid_statement,
    no_privilege,
    no_data,
    constraint_violation,
    unknown_transaction_state,
    system_error,
    unknown
};
Jednakże nie wszystkie bazy danych obsługują wszystkie kategorie błędów. Niektóre backendy baz danych mają również inne rodzaje wyjątków, dziedziczące po
soci::soci_error
, m.in.
mysql_soci_error
,
postgresql_soci_error
,
oracle_soci_error
; szczegóły w dokumentacji.
W temacie obsługi błędów wspomnę, że są mechanizmy, które mogą powiadomić m.in. o rozłączeniu z bazą danych, jest to interfejs
soci::failover_callback
. Jednakże dotyczy to PostgreSQLa i Oracle'a, więc pominę ten temat, zainteresowanych odsyłam do dokumentacji.

Logowanie zapytań do pliku

Jeśli byśmy chcieli zapamiętywać wszystkie zapytania, jest też taka możliwość.

Proste logowanie

Najprościej jest zarejestrować strumień w klasie soci::session, robimy to przy pomocy:
void soci::session::set_log_stream( std::ostream * s );
. Z kolei aby pobrać strumień lub ostatnie zapytanie, możemy użyć odpowiednio:
std::ostream * soci::session::get_log_stream();
 i
std::string get_last_query() const;
. Jeśli byśmy w pewnym momencie chcieli zaprzestać logowania, możemy wywołać funkcję ustawiającą strumień z argumentem
nullptr
. Do kolejnych istotnych informacji należy fakt, że logowanie odbywa się tuż przez zapytaniem. Dlatego jest to idealne do sprawdzania, przy których zapytaniach wystąpił błąd.
Uwaga: każde przygotowane zapytanie jest logowane tylko jednokrotnie!
Czas na przykład funkcji main, gdyż tylko tam są potrzebne zmiany (reszta kodu jak w przykładzie z transakcjami):
C/C++
int main()
{
    soci::session sql;
    try
    {
        sql.open( sqlite3, "pracownicy.db" );
       
        ofstream file( "commands.txt" );
        sql.set_log_stream( & file );
       
        sql << "DROP TABLE Pracownicy;";
        sql << "CREATE TABLE IF NOT EXISTS Pracownicy(" "CREATE TABLE IF NOT EXISTS Pracownicy("\
         "ID INT PRIMARY KEY     NOT NULL," "ID INT PRIMARY KEY     NOT NULL,"\
         "Imie           TEXT    NOT NULL," "Imie           TEXT    NOT NULL,"\
         "Wiek           INT     NOT NULL," "Wiek           INT     NOT NULL,"\
         "Pensja         REAL);";
        sql << "DELETE FROM Pracownicy;";
       
        {
            Employee employee( 1, "Janusz", 77, 10000 );
            sql << "INSERT INTO Pracownicy(ID, Imie, Wiek, Pensja) "
            "VALUES (:ID, :Imie, :Wiek, :Pensja); ", use( employee );
        }
        {
            Employee employee( 1, "Grzegorz", 52, 8000 );
            sql << "INSERT INTO Pracownicy(ID, Imie, Wiek, Pensja) "
            "VALUES (:ID, :Imie, :Wiek, :Pensja); ", use( employee );
        }
    }
    catch( const soci_error & e )
    {
        cerr << "Error: " << e.what() << " category: " << e.get_error_category()
        << ", last command: " << sql.get_last_query() << '\n';
    }
}
   
Myślę, że odnośnie wydruku nie są potrzebne dodatkowe wyjaśnienia:
Error: sqlite3_statement_backend::loadOne: UNIQUE constraint failed: Pracownicy.
ID while executing "INSERT INTO Pracownicy(ID, Imie, Wiek, Pensja) VALUES (:ID,
:Imie, :Wiek, :Pensja); " with :()=<value>, :ID=1, :Imie="Grzegorz", :Wiek=52, :
Pensja=8000. category: 7, last command: INSERT INTO Pracownicy(ID, Imie, Wiek, P
ensja) VALUES (:ID, :Imie, :Wiek, :Pensja);
Zawartość pliku do loggowania:
DROP TABLE Pracownicy;
CREATE TABLE IF NOT EXISTS Pracownicy(ID INT PRIMARY KEY     NOT NULL,Imie           TEXT    NOT NULL,Wiek           INT     NOT NULL,Pensja         REAL);
DELETE FROM Pracownicy;
INSERT INTO Pracownicy(ID, Imie, Wiek, Pensja) VALUES (:ID, :Imie, :Wiek, :Pensja);
INSERT INTO Pracownicy(ID, Imie, Wiek, Pensja) VALUES (:ID, :Imie, :Wiek, :Pensja);

Bardziej zaawansowany logger

Możemy też napisać własną klasę loggera, którą podamy do obiektu sesji. W tym celu musimy odziedziczyć po klasie
soci::logger_impl
 oraz przeładować dwie funkcje składowe (jak w przykładzie):
C/C++
#include <iostream>
#include <string>
#include <exception>
#include <soci/soci.h>
#include <soci/sqlite3/soci-sqlite3.h>

using namespace soci;
using namespace std;

class ConsoleLogger
    : public soci::logger_impl
{
public:
    virtual void start_query( const std::string & query )
    {
        cout << "----->" << query << "<-----" << endl;
    }
   
private:
    virtual logger_impl * do_clone() const
    {
        return new ConsoleLogger;
    }
};


int main()
{
    soci::session sql;
    try
    {
        sql.open( sqlite3, "pracownicy.db" );
        sql.set_logger( new ConsoleLogger );
       
        sql << "DROP TABLE Pracownicy;";
    }
    catch( const soci_error & e )
    {
        cerr << "Error: " << e.what() << " category: " << e.get_error_category()
        << ", last command: " << sql.get_last_query() << '\n';
    }
}
Oraz wydruk:
----->DROP TABLE Pracownicy;<-----


Większa wygoda - użycie biblioteki boost w SOCI

Zapewne zwróciłeś, Drogi Czytelniku, uwagę na opcję w CMake'u włączającą użycie boost'a. Teraz przeanalizujemy, co nam to daje. Mimo iż nie jest używanych wiele funkcjonalności tej biblioteki, to i tak zwiększa to bardzo wygodę z korzystania. Wygoda to użycie
boost::optional
,
boost::tuple
,
boost::fusion::vector
,
boost::gregorian::date
.
UWAGA: poza odpowiednimi nagłówkami należy przed włączeniem biblioteki SOCI dodać linijkę:
#define SOCI_USE_BOOST
!
Dzięki
boost::optional
 możemy uniknąć używania typu
soci::indicator
. Możemy też korzystać z agregatów na dane
boost::tuple
 lub
boost::fusion::vector
, zamiast podawać wszystkich zmiennych oddzielnie lub tworzyć własne typy. Niestety od C++ 14 mamy
std::optional
, a od C++11 mamy
std::tuple
, które wspierane jeszcze nie są. Kolejną zaletą jest możliwość użycia typu
boost::gregorian::date
 do obsługi dat. Następnym udogodnieniem jest fakt, że możemy te wszystkie funkcjonalności łączyć (tuple, które zawiera optional<x>). Te wszystkie możliwości widać w przykładzie:
C/C++
#include <iostream>
#include <string>
#include <exception>
#include <boost/optional.hpp>
#include <boost/tuple/tuple.hpp>
#include <boost/fusion/tuple.hpp>
#include <boost/date_time/gregorian/gregorian.hpp>

#define SOCI_USE_BOOST
#include <soci/soci.h>
#include <soci/sqlite3/soci-sqlite3.h>

using namespace std;
using namespace soci;

int main()
{
    try
    {
        soci::session sql( sqlite3, "pracownicy.db" );
       
        sql << "CREATE TABLE IF NOT EXISTS Pracownicy("
        "ID                INT PRIMARY KEY     NOT NULL,"
        "Imie              TEXT,"
        "Wiek              INT,"
        "Pensja            REAL,"
        "PierwszeLogowanie DATETIME);";
        sql << "DELETE FROM Pracownicy;";
       
        int id = 1;
        boost::optional < std::string > name( "Jacek Soplica" );
        boost::optional < int > age;
        boost::optional < double > salary = 0;
       
        sql << "INSERT INTO Pracownicy(ID, Imie, Wiek, Pensja) "
        "VALUES (:ID, :Imie, :Wiek, :Pensja); ", use( id ), use( name ), use( age ), use( salary );
       
        boost::tuple < int, std::string, int, double > person = boost::make_tuple( 2, "Jan Nowak", 44, 3000 );
        sql << "INSERT INTO Pracownicy(ID, Imie, Wiek, Pensja) "
        "VALUES (:ID, :Imie, :Wiek, :Pensja); ", use( person );
       
        boost::fusion::vector < int, std::string, int, double > person2 { 3, "Jerzy Kowalski", 46, 3301.2 };
        sql << "INSERT INTO Pracownicy(ID, Imie, Wiek, Pensja) "
        "VALUES (:ID, :Imie, :Wiek, :Pensja); ", use( person2 );
       
        boost::gregorian::date firstLogin( 2019, boost::gregorian::Dec, 16 );
        sql << "UPDATE Pracownicy SET PierwszeLogowanie = :firstLogin WHERE ID = :id;", use( firstLogin ), use( 2 );
       
        soci::rowset < soci::row > rows = sql.prepare << "SELECT * FROM Pracownicy";
       
        for( auto & row: rows )
        {
            for( std::size_t i = 0; i != row.size(); ++i )
            {
                const column_properties & props = row.get_properties( i );
               
                cout << props.get_name() << " = ";
               
                switch( props.get_data_type() )
                {
                case dt_string:
                    cout << row.get < std::string >( i ) << '\n';
                    break;
                case dt_double:
                    cout << row.get < double >( i ) << '\n';
                    break;
                case dt_integer:
                    {
                        auto value = row.get < boost::optional < int >>( i );
                        if( value )
                        {
                            cout << value.value() << '\n';
                        }
                        else
                        {
                            cout << "(null)\n";
                        }
                        break;
                    }
                case dt_date:
                    {
                        auto value = row.get < boost::optional < boost::gregorian::date >>( i );
                        if( value )
                        {
                            cout << boost::gregorian::to_iso_extended_string( value.value() ) << '\n';
                        }
                        else
                        {
                            cout << "(null)\n";
                        }
                        break;
                    }
                    default:
                    cerr << "(unknown type!)\n";
                }
            }
        }
    }
    catch( const soci_error & e )
    {
        cerr << "Error: " << e.what() << '\n';
    }
}
Wydruk:
ID = 1
Imie = Jacek Soplica
Wiek = (null)
Pensja = 0
PierwszeLogowanie = (null)
ID = 2
Imie = Jan Nowak
Wiek = 44
Pensja = 3000
PierwszeLogowanie = 2019-12-16
ID = 3
Imie = Jerzy Kowalski
Wiek = 46
Pensja = 3301.2
PierwszeLogowanie = (null)


Inne interfejsy do obsługi baz danych w ramach SOCI

Nie do wiary, a jednak - to co opisałem to interfejs nazwany w dokumentacji "sugar", który ma mieć składnię maksymalnie podobną do wbudowanego SQLa (ang. embedded SQL). Kolejnym interfejsem jest interfejs core, który to umożliwia to, co my wcześniej robiliśmy, ale przy użyciu soci::statement -krok po kroku ręcznie (nawet te niewidoczne kroki), z założenia ma to być interfejs do pisania własnych interfejsów. Wreszcie jest jeszcze interfejs simple, który to ma wszystko, co my robiliśmy, wykonuje w sposób binarnie kompatybilny z językiem C (
extern "C"
). Dzięki temu możemy używać biblioteki też z poziomu innych języków programowania, w tym C.

Funkcjonalności poza standardowym SQLem

Biblioteka SOCi dostarcza też pewne funkcjonalności, które są dostarczane przez API wielu baz danych, ale nie są sprecyzowane w ramach standardu SQL. Oczywiście nie wszystkie bazy mogę je obsługiwać, niemniej jednak przytoczę kilka z nich. Jedną z funkcjonalności jest pobieranie informacji o ostatnim indeksie wstawionego elementu
soci::session::get_last_insert_id
 lub o najwyższym poziomie sekwencji:
soci::session::get_next_sequence_value
. Kolejną niestandardową, aczkolwiek przydatną informacją, jest informacja o ilości zmienionych wierszy (
INSERT
 lub
DELETE
 lub
UPDATE
), poniżej przykład:
C/C++
#include <iostream>
#include <string>
#include <exception>
#include <soci/soci.h>
#include <soci/sqlite3/soci-sqlite3.h>

using namespace soci;
using namespace std;

struct Employee
{
    Employee() = default;
    Employee( int id, const string_view name, int age, double salary = { } )
        : id( id )
         , name( name )
         , age( age )
         , salary( salary )
    { }
   
    int id = { };
    std::string name;
    int age = { };
    double salary = { };
};

std::ostream & operator <<( std::ostream & o, const Employee & employee )
{
    return o << "Employee(" << employee.id << "): " << employee.name << ", age: " << employee.age << ", earns: " << employee.salary;
}

namespace soci
{
    template <>
    struct type_conversion < Employee >
    {
        typedef values base_type;
       
        static void from_base( values const & v, indicator /* ind */, Employee & p )
        {
            p.id = v.get < int >( "ID" );
            p.name = v.get < std::string >( "Imie", "" );
            p.age = v.get < int >( "Wiek", 0 );
            p.salary = v.get < double >( "Pensja", 0.);
        }
       
        static void to_base( const Employee & p, values & v, indicator & ind )
        {
            v.set( "ID", p.id );
            v.set( "Imie", p.name, p.name.empty() ? i_null
                : i_ok );
            v.set( "Wiek", p.age, p.age == - 1 ? i_null: i_ok );
            v.set( "Pensja", p.salary, p.salary == 0.? i_null: i_ok );
            ind = i_ok;
        }
    };
}

int main()
{
    try
    {
        soci::session sql( sqlite3, "pracownicy.db" );
       
        sql << "CREATE TABLE IF NOT EXISTS Pracownicy(" "CREATE TABLE IF NOT EXISTS Pracownicy("\
         "ID INT PRIMARY KEY     NOT NULL," "ID INT PRIMARY KEY     NOT NULL,"\
         "Imie           TEXT    NOT NULL," "Imie           TEXT    NOT NULL,"\
         "Wiek           INT     NOT NULL," "Wiek           INT     NOT NULL,"\
         "Pensja         REAL);";
        sql << "DELETE FROM Pracownicy;";
       
        long long idHighwater;
        sql.get_last_insert_id( "Pracownicy", idHighwater );
        Employee employee( idHighwater + 1, "Janusz", 77, 10000 );
        statement st =( sql.prepare << "INSERT INTO Pracownicy(ID, Imie, Wiek, Pensja) "
        "VALUES (:ID, :Imie, :Wiek, :Pensja); ", use( employee ) );
        st.execute( true );
       
        sql.get_last_insert_id( "Pracownicy", idHighwater );
        employee = Employee( idHighwater + 1, "Grzegorz", 52, 8000 );
        st.execute( true );
       
        sql.get_last_insert_id( "Pracownicy", idHighwater );
        employee = Employee( idHighwater + 1, "Dobromir", 43, 9500 );
        st.execute( true );
       
        cout << "Affected rows: " << st.get_affected_rows() << endl;
       
       
        st =( sql.prepare << "SELECT * FROM Pracownicy;", into( employee ) );
        st.execute();
        while( st.fetch() )
        {
            cout << employee << '\n';
        }
    }
    catch( const soci_error & e )
    {
        cerr << "Error: " << e.what() << '\n';
    }
}
Wydruk:
Affected rows: 1
Employee(1): Janusz, age: 77, earns: 10000
Employee(2): Grzegorz, age: 52, earns: 8000
Employee(3): Dobromir, age: 43, earns: 9500
Nie wszystkie bazy danych, z obsługiwanych przez SOCI, wspierają powyższe funkcje, przed ich użyciem odsyłam do dokumentacji.

Kwestie wielowątkowości

Zasadniczo wielowątkowość w SOCI polega na posiadaniu wielu sesji do bazy danych, w tym celu nawet została dostarczona klasa
soci::connection_pool
. Pojedyncza klasa
soci::session
 nie jest bezpieczna pod względem wielowątkowym. Wszystkie wątpliwości rozwiąże przykład:
C/C++
#include <iostream>
#include <chrono>
#include <string>
#include <future> // std::async()
#include <exception>
#include <soci/soci.h>
#include <soci/sqlite3/soci-sqlite3.h>

using namespace soci;
using namespace std;
using namespace std::chrono;

void runMultipleQueries( soci::session & sql, size_t queriesCount )
{
    auto startingTime = steady_clock::now();
   
    for( size_t i = 0; i < queriesCount; ++i )
    {
        string name = "Janusz " + to_string( i );
        int age = rand() % 100;
        double salary = static_cast < double >( rand() ) / RAND_MAX * 10000;
        sql << "INSERT INTO Pracownicy(Imie, Wiek, Pensja) "
        "VALUES (:name, :age, :salary); ", use( name ), use( age ), use( salary );
    }
   
    const auto elapsedTimeInSeconds = std::chrono::duration < double >( steady_clock::now() - startingTime ).count();
    cout << __FUNCTION__ << " took seconds: " << elapsedTimeInSeconds << endl;
}

int main()
{
    try
    {
        soci::session sql( sqlite3, "pracownicy.db" );
        sql << "DROP TABLE Pracownicy;";
        sql << "CREATE TABLE IF NOT EXISTS Pracownicy(" "CREATE TABLE IF NOT EXISTS Pracownicy("\
         "ID     INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," "ID     INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,"\
         "Imie   TEXT    NOT NULL," "Imie   TEXT    NOT NULL,"\
         "Wiek   INT     NOT NULL," "Wiek   INT     NOT NULL,"\
         "Pensja REAL);";
       
        size_t queriesCount = 100;
        {
            auto startingTime = steady_clock::now();
           
            runMultipleQueries( sql, queriesCount );
            runMultipleQueries( sql, queriesCount );
            runMultipleQueries( sql, queriesCount );
           
            const auto elapsedTimeInSeconds = std::chrono::duration < double >( steady_clock::now() - startingTime ).count();
            cout << "All queries single thread took seconds: " << elapsedTimeInSeconds << endl;
        }
       
        {
            const size_t poolSize = 3;
            connection_pool pool( poolSize );
            for( size_t i = 0; i != poolSize; ++i )
            {
                session & sql = pool.at( i );
                sql.open( sqlite3, "pracownicy.db" );
            }
           
            {
                auto startingTime = steady_clock::now();
               
                session sql1( pool ), sql2( pool ), sql3( pool );
                auto task1 = std::async( std::launch::async, & runMultipleQueries, ref( sql1 ), queriesCount );
                auto task2 = std::async( std::launch::async, & runMultipleQueries, ref( sql2 ), queriesCount );
                auto task3 = std::async( std::launch::async, & runMultipleQueries, ref( sql3 ), queriesCount );
               
                task1.wait();
                task2.wait();
                task3.wait();
               
                const auto elapsedTimeInSeconds = std::chrono::duration < double >( steady_clock::now() - startingTime ).count();
                cout << "All queries multiple threads took seconds: " << elapsedTimeInSeconds << endl;
            }
        }
       
        int count;
        sql << "SELECT COUNT(*) FROM Pracownicy;", into( count );
        cout << "Employees's count: " << count << endl;
    }
    catch( const soci_error & e )
    {
        cerr << "Error: " << e.what() << '\n';
    }
}
Zasadniczo bezpieczne pod względem wielowątkowym jest użycie
soci::session::session( soci::connection_pool & )
. Niestety aby użyć poprawnie wielowątkowo SQLite, trzeba jeszcze podjąć pewne kroki opisane w dokumentacji.

Używanie funkcji bazodanowych w SOCI

Bazy danych, poza funkcjonalnością bazodanową, dostarczają wielu funkcji do manipulacji danymi i funkcji pomocniczych, z których można skorzystać przy pomocy SQLa. Jednakże sposób dostępu do tych funkcji jest różny w różnych bazach, raz wystarczy
SELECT X();
, a np. w przypadku baz Oracle konieczne jest
SELECT X() FROM DUAL;
. Na szczęście SOCI dostarcza funkcję
soci::session::get_dummy_from_clause()
 zwracającą odpowiednią końcówkę do użycia SQLowej funkcji pomocniczej. Poniżej przykład użycia funkcji pomocniczej SQLite z poziomu SQLa przy użyciu biblioteki SOCI:
C/C++
#include <iostream>
#include <string>
#include <exception>
#include <soci/soci.h>
#include <soci/sqlite3/soci-sqlite3.h>

using namespace soci;
using namespace std;

int main()
{
    try
    {
        soci::session sql( sqlite3, "baza.db" );
       
        string date;
        sql << "SELECT strftime('%H:%M:%S %d.%m.%Y','now','-1 day')" + sql.get_dummy_from_clause(), into( date );
        cout << "current time = " << date << endl;
       
        string text2Trim = "   tekst na srodku   ", trimmedText;
        sql << "SELECT TRIM(:text2Trim)", use( text2Trim ), into( trimmedText );
        cout << "Trimmed text: >>>>" << trimmedText << "<<<<\n";
    }
    catch( const soci_error & e )
    {
        cerr << "Error: " << e.what() << '\n';
    }
    remove( "baza.db" );
}
A oto wydruk:
current time = 17:45:42 14.12.2019
Trimmed text: >>>>tekst na srodku<<<<
Niestety nawet jeśli nie wrzucamy nic do bazy, to i tak plik o danej nazwie jest tworzony. Możemy go usunąć ręcznie.

Dostęp do metadanych - nazwy tabeli i kolumn

Jest to kolejna rzecz, którą różne bazy danych dostarczają przy pomocy różnych zapytań, na szczęście SOCI dostarcza również i tę funkcjonalność:
C/C++
int main()
{
    try
    {
        soci::session sql( sqlite3, "baza.db" );
       
        std::string tableName;
        statement st =( sql.prepare_table_names(), into( tableName ) );
        /** alternatywnie:
        std::vector<std::string> tableNames(100);
        sql.get_table_names(), into(tableNames); **/
       
       
        st.execute();
        while( st.fetch() )
        {
            cout << "table: " << tableName << " contains columns:\n";
            soci::column_info ci;
            soci::statement st2 =( sql.prepare_column_descriptions( tableName ), into( ci ) );
            st2.execute();
            while( st2.fetch() )
            {
                cout << "\t" << ci.name << " (" << ci.type << ")" << '\n';
            }
        }
    }
    catch( const soci_error & e )
    {
        cerr << "Error: " << e.what() << '\n';
    }
}
Dodam, że struktura
soci::column_info
 poza użytymi polami zawiera jeszcze wiele pól do sprawdzenia szczegółów poszczególnych kolumn.
Niestety w mojej wersji SQLite 3 powyższy kod nie działa, generując następujący wydruk:
table: sqlite_sequence contains columns:
Error: sqlite3_statement_backend::prepare: no such table: information_schema.columns while preparing "select column_name as "COLUMN_NAME", data_type as "DATA_TYPE", character_maximum_length as "CHARACTER_MAXIMUM_LENGTH", numeric_precision as "NUMERIC_PRECISION", numeric_scale as "NUMERIC_SCALE", is_nullable as "IS_NULLABLE" from information_schema.columns where table_schema = 'public' and table_name = :t" with :t="sqlite_sequence".
Rozwiązaniem jest znalezienie w dokumentacji SQLite komendy na wyświetlenie kolumn z tabeli, jest to następujące zapytanie:
PRAGMA table_info(table_name);

Data Definition Language (DDL) w SOCI

Jest to zarządzanie tabelami (tworzenie, zmienianie i usuwanie) przy pomocy funkcji, zamiast SQLa. Soci dostarcza też taką funkcjonalność. Poza tym dostarcza bardzo wygodną funkcję:
soci::session::nvl()
, która zwraca wartość nie będącą nullem ze zbioru wartości. Niestety w różnych bazach funkcja o tej funkcjonalności nazywa się inaczej. W naszym przypadku jest przydatna, gdyż dzięki niej nie będziemy musieli używać
soci::indicator
. Czas na przykład (tylko funkcja main):
C/C++
int main()
{
    try
    {
        soci::session sql( sqlite3, "pracownicy.db" );
       
        sql.drop_table( "Pracownicy" );
       
        sql.create_table( "Pracownicy" )
        .column( "ID", soci::dt_integer )( "NOT NULL" )
        .column( "Imie", soci::dt_string )( "NOT NULL" )
        .column( "Wiek", soci::dt_integer )( "NOT NULL" )
        .column( "Pensja", soci::dt_double )
        .primary_key( "Pracownicy_primaryKey", "ID" );
       
        sql.add_column( "Pracownicy", "Premia", dt_double, /*precision=*/ 2 );
        sql.drop_column( "Pracownicy", "Premia" );
       
        Employee employee( 1, "Andrzej Kowalski", 60 );
        statement st =( sql.prepare << "INSERT INTO Pracownicy(ID, Imie, Wiek, Pensja) "
        "VALUES (:ID, :Imie, :Wiek, :Pensja); ", use( employee ) );
        st.execute( true );
       
        employee = Employee( 2, "Grzegorz", 52, 8000 );
        st.execute( true );
       
        employee = Employee( 3, "Dobromir", 43, 9500 );
        st.execute( true );
       
        string name;
        double salary;
        st =( sql.prepare << "SELECT Imie, " + sql.nvl() + "(Pensja, 0) FROM Pracownicy;", into( name ), into( salary ) );
        st.execute();
        while( st.fetch() )
        {
            cout << name << " earns " << salary << '\n';
        }
    }
    catch( const soci_error & e )
    {
        cerr << "Error: " << e.what() << '\n';
    }
}
Wydruk w konsoli:
Andrzej Kowalski earns 0
Grzegorz earns 8000
Dobromir earns 9500

Polskie znaki

Ta kwestia zależy już od wybranej bazy danych, trzeba znaleźć tam sposób kodowania znaków. W przypadku SQLite na Windowsie dla pliku źródłowego (*.cpp) o kodowaniu UTF-8 bez problemu wpisujemy do bazy polskie znaki i odczytujemy je przy kodowaniu UTF-8.

SQLite3 w SOCI - i co z tego?

Biblioteka obudowywuje wiele funkcjonalności bazy, dlatego z pozoru może się wydawać to nieistotne, jednakże każda baza ma swoją dodatkową funkcjonalność.

Uchwyty do backendu

Wspólny interfejs dla wielu baz danych nie jest w stanie dostarczyć wszystkich funkcjonalności obsługiwanych baz danych. Jeśli jednak będziemy musieli zrobić coś specjalistycznego dla danej bazy danych mamy możliwość w SOCI uzyskać uchwyt do originalnej bazy danych:
C/C++
#include <iostream>
#include <string>
#include <exception>
#include <soci/soci.h>
#include <soci/sqlite3/soci-sqlite3.h>

using namespace soci;
using namespace std;

int main()
{
    soci::session sql;
    try
    {
        sql.open( sqlite3, "pracownicy.db" );
        sqlite3_session_backend * backend = dynamic_cast < sqlite3_session_backend *>( sql.get_backend() );
        cout << "Get all table names: " << backend->get_table_names_query() << endl;
        const char * query = "DROP TABLE Poslowie;";
        sqlite3_exec( backend->conn_, query, nullptr, nullptr, nullptr );
    }
    catch( const sqlite3_soci_error & e )
    {
        cerr << "Error: " << e.what() << " category: " << e.get_error_category()
        << ", last command: " << sql.get_last_query() << ", sqlite result code: " << e.result() << '\n';
    }
}
Jak widać, otrzymując uchwyt do backendu mamy pewne dodatkowe funkcjonalności, których nie będę opisywał, jak w tym przypadku pozyskanie komendy wyświetlającej wszystkie tabele bazy danych:
Get all table names: select name as "TABLE_NAME" from sqlite_master where type =
 'table'

Konfiguracja sesji bazy danych

Znamy następujący sposób połączenia:
soci::session sql( sqlite3, "plik.db" );
, ale możemy przy połączeniu podać też inne parametry:
soci::session sql( sqlite3, "db=pracownicy.db timeout=2 shared_cache=true" );
, można też:
W celu wyjaśnienia tych parametrów odsyłam do dokumentacji SQLite: timeout, shared_cache,
synchronous.

Poza tym mamy również możliwość dynamicznego wskazania bazy danych:
soci::session sql( "sqlite3", "plik.db" );
 lub nawet:
soci::session sql( "sqlite3://db=plik.db" );
, oczywiście wtedy musimy mieć biblioteki skompilowane dynamicznie. Daje nam to możliwość dynamicznej podmiany bazy danych. Kolejną wygodą jest fakt, że biblioteki dynamiczne możemy wskazać przy pomocy zmiennej środowiskowej SOCI_BACKENDS_PATH, która to może zawierać wiele ścieżek rozdzielonych dwukropkiem (":"). Szczegóły co do połączenia z bazą danych.
 

Inne rodzaje dowiązywania zmiennych

SQLite wspiera też inne dowiązania zmiennych, niż te, z którymi się zetknęliśmy do tej pory, nie tylko
:nazwa
, Poniżej kod przedstawiający inne możliwości:
C/C++
#include <iostream>
#include <string>
#include <exception>
#include <soci/soci.h>
#include <soci/sqlite3/soci-sqlite3.h>

using namespace soci;
using namespace std;

int main()
{
    try
    {
        soci::session sql( sqlite3, "pracownicy.db" );
       
        sql << "CREATE TABLE IF NOT EXISTS Pracownicy("
        "ID INT PRIMARY KEY     NOT NULL,"
        "Imie           TEXT    NOT NULL,"
        "Wiek           INT     NOT NULL,"
        "Adres          TEXT,"
        "Pensja         REAL);";
       
        int id = 1, age = 44;
        string name = "Jan Nowak", address = "Jezioro";
        double salary = 4000;
        sql << "INSERT INTO Pracownicy (ID, Imie, Wiek, Adres, Pensja) VALUES (:id, :name, :age, :address, :salary);", use( id ), use( name ), use( age ), use( address ), use( salary );
        sql << "INSERT INTO Pracownicy (ID, Imie, Wiek, Adres, Pensja) VALUES ($id, $name, $age, $address, $salary);", use( ++id ), use( name ), use( age ), use( address ), use( salary );
        sql << "INSERT INTO Pracownicy (ID, Imie, Wiek, Adres, Pensja) VALUES (@id, @name, @age, @address, @salary);", use( ++id ), use( name ), use( age ), use( address ), use( salary );
        sql << "INSERT INTO Pracownicy (ID, Imie, Wiek, Adres, Pensja) VALUES (?, ?, ?, ?, ?);", use( ++id ), use( name ), use( age ), use( address ), use( salary );
       
        statement st =( sql.prepare << "SELECT * FROM Pracownicy;", into( id ), into( name ), into( age ), into( address ), into( salary ) );
        st.execute();
        while( st.fetch() )
        {
            cout << id << ": " << name << " age: " << age << " lives: " << address << ", earns: " << salary << '\n';
        }
    }
    catch( const sqlite3_soci_error & e )
    {
        cerr << "Error: " << e.what() << " category: " << e.get_error_category()
        << ", last command: " << sql.get_last_query() << ", SQLite result code: " << e.result() << '\n';
    }
}
Wydruk:
1: Jan Nowak age: 44 lives: Jezioro, earns: 4000
2: Jan Nowak age: 44 lives: Jezioro, earns: 4000
3: Jan Nowak age: 44 lives: Jezioro, earns: 4000
4: Jan Nowak age: 44 lives: Jezioro, earns: 4000

Dodatki

Do przeglądu bazy danych polecam SQLite Studio.
Bardzo skrupulatny tutorial SQLa w SQLite.
Prezentacja- szybki przegląd biblioteki SOCI, dla przypomnienia.[/h2][/h2]