SQLite-API

Introduction

The library helps serialize or deserialize a C++ class using SQLite in the backend. Its usage is simple and yet it helps the user avoid all details related to the database operations. Serializing or deserializing data is an internal feature of many applications and handling the same using SQLite involves writing DDL and DML related code for SQL. This framework does all that behind the scenes, without the user to worry much about the implementation details.

Background

The code has been written for diverse use cases. It assumes that the user won’t have to write any code related to SQLite. Still, the user shall have the flexibility of deciding as to which properties/fields to serialize and how?

Using the code

There are two ways we can use the API. One is by using a class from the API called Catalogue that can help us manipulate data in the database directly. Following is the sample code;

    
          Catalogue freqTbl("bit.db", "freqtbl", schmTbl);
          freq.Set("Freq", 1090.8);
          freq.Set("Type", 0);
          freq.Save();
      
          freq.Set("Freq", 978.5);
          freq.Set("Type", 1);
          freq.Save();

Here bit.db is the name of the database, freqtbl is the name of table and schmTbl is the schema for the table. The schema, schmTbl, can be defined as under;

schema = 
          {
              {"Freq", PLAT_DBL},
              {"Type", PLAT_DBL}
          };

Pre-caching the schema of tables

When using the Catalogue class, we may not want to provide schema every time we need to do something with the data. So there is a helper class called DBCache that can be used to register all tables and schemas to be used in the app. And later, when required, the user can simply grab an instance of Catalogue class from the singleton of DBCache class, for the table that requires any data manipulation. Following is how tables and schemas can be registered with DBCache

            DBCache::Select("c1", "test.db");
            DBCache::Current()
            .AddTable("bit", // Re-create table (if schema changed)
            {   {"Type", PLAT_BOOL},
                {"Result", PLAT_BOOL}, 
                {"Val", PLAT_DBL},
                {"I", PLAT_DBL},
                {"Q", PLAT_DBL},
                {"Time", PLAT_INT},
                {"Date", PLAT_INT},
                {"Run", PLAT_INT}
            });
            .AddTable("limtbl",
            {   {"MinLim", PLAT_DBL},
                {"MaxLim", PLAT_DBL},
                {"Type", PLAT_INT}
            });

Here, c1 is the name of the cache. The Select function makes the cache named ‘c1’ the current cache that can be simply retrieved using the singleton, DBCache::Current(). The AddTable functions take table name and schema as the only two arguments. Following is how a table can be retrieved from DBCache to perform an INSERT operation.

            // Insert
            up_catalogue bit;
            if (DBCache::Current().Get("bit", bit)) // If tableName exists then
            {
                bit->Set("Type", 0); // Set data
                bit->Set("Result", true);
                bit->Set("Val", 1000);
                bit->Set("I", 0);
                bit->Set("Q", 0);
                bit->Set("Time", static_cast(time_t(NULL)));
                bit->Set("Run", 1);
                bit->Save(); // Save data to database
    
                bit->Set("Type", 1);
                bit->Set("Result", false);
                bit->Set("Val", 300);
                bit->Set("I", 0);
                bit->Set("Q", 0);
                bit->Set("Time", static_cast(time_t(NULL)));
                bit->Set("Run", 2);
                bit->Save();
            }

There are a couple of interesting features of DBCache. It saves the schema of the table as a hash in the database in a separate table called sys_cache. And then every time DBCache is initialized, it looks for any changes in the schema and if there are any, it acts as per the chosen policy; either raise an exception or delete the old table and create a new one. DBCache also helps control resources in a way that we can have a separate cache for individual modules. This way we only register the tables that are required for the respective modules.

Creating Matsers in database

There is a provision to add masters to the database when the application starts. Such masters, once added, are only checked for schema integrity everytime the app starts. Adding a master is simply a quick and easy way to add a table and data together in one step. A master is expected to be a read-only entity, although there isn’t a provision as of now to really keep it read-only. Following is how a master can be added using DBCache

        DBCache::Select("c1", "test.db");
        Master mstFreqTbl("freqtbl", // Re-create master table (if existing table schema changed) in database
        {
            {"Freq", PLAT_DBL},
            {"Mod", PLAT_INT},
            {"Type", PLAT_INT},
            {"Datetime", PLAT_DT} //Added new datetime type
        });

        mstFreqTbl
        // #0 Tx & Rx Synthesizer in MHz, Rx IQ Demodulator, Tx IQ Modulator, Tx IQ Gain Bal
        .Add(0.200, 10, 0, Datetime::Current()) //Saving current datetime
        .Add(29.9999, 10, 0, Datetime::Current())
        .Add(30, 10, 0, Datetime::Current())
        .Add(500, 10, 0, Datetime::Current())
        .Add(1100, 10, 0, Datetime::Current())
        .Add(2200, 10, 0, Datetime::Current())
        // #1 Tx Reference Tuning Freq in GHz, Cal Detector
        .Add(.5, 10, 1, Datetime::Current())
        .Add(1.100, 10, 1, Datetime::Current())
        .Add(2.200, 10, 1, Datetime::Current());

        DBCache::Current().AddMaster(mstFreqTbl); // Add master table with data

Converting a class into a persistable entity

The second way to perform any database manipulations is to derive from the PersistableBase class from the API and use its protected/public functions. Here is how the same can be accomplished:

To serialize/deserialize any class, there are four simple steps that we need to follow. First is to derive the class in question from the PersistableBase class. Second is to override the functiononSetData(). The third is to override the function onGetData(Columns& cols). And finally, override the getSchema() function. Deriving from the PersistableBase class renders the class in quetion with the primary functions to Create/Read/Update/Delete data as required. Let’s assume there is a class called AppData that we need to serialize.

class AppData
      {
      
      private:
          int m_userId = 0;
          string m_key;
          string m_value;
      };

We’ll need to derive it from PersistableBase class;

class AppData: public PersistableBase
      {...

And then we’ll need to override the three functions to support db operations;

    /**
           * The three functions that need to be created 
           * in the class that has to be serialized. 
           * All three functions are called internally 
           * and should not be required to be called 
           * from outside at any point of time. 
          */
          schema getschema() const override;  //to define schema
          void onSetData() override;            //to get data from object into db
          void onGetData(Columns& cols) override;   //to get data from db into object
          /*----------------------------------------------*/

These three functions (getSchemaonSetDataonGetData) are really just a declaration that the author of any data class, like AppData, needs to make as to what data from the class needs to be pushed to the DB and how the same data shall be used to populate any new object of that type. onSetData() is called internally from PersistableBase class when Save() is called on any data class deriving from PersistableBase. Inside the onSetData() functions, it is assumed that the author of the data class has used the protected set functions to push data from the class to a data collector class (Columns) which would then process the data as required. And the onGetData(Columns) function is also called internally from PersistableBase at the time of creation of new objects when user fetches data from DB. This function also assumes that the author of the deriving data class has used the Columns object passed in its argument to populate the data class. These two functions use the getSchema function to maintain what would be the schema of the table where data is saved or retrieved from. The complete code for the appdata.h file hence becomes;

#include "persistablebase.h"
      using namespace std;
      
      class AppData: public PersistableBase
      {
      
      private:
          int m_userId = 0;
          string m_key;
          string m_value;
      
      public:
          //A constructor that defines what database and the table in it, the data will go into 
          AppData(const char* dbName, const char* tableName);
      
          //Another that just pre defines what table or database the object will always save to
          AppData::AppData(const int userId, const string &key, const string &value):
          PersistableBase{"tel.db", "data1"}
          {
                m_userId = userId;
                m_key = key;
                m_value = value;
          }  
      
          /**
           * The three functions that need to be created 
           * in the class that has to be serialized. 
           * All three functions are called internally 
           * and should not be required to be called 
           * from outside at any point of time. 
          */
          schema getschema() const override;  //to define schema
          void onSetData() override;            //to get data from object into db
          void onGetData(Columns& cols) override;   //to get data from db into object
          /*----------------------------------------------*/
      };

After we have setup the pre-requisites, we can create the object as;

      AppData anySetting("app.db", "data1");

Or just; AppData anySettings;

Save in DB

We can save the object as;

Using Catalogue from DBCache

    //Insert
      up_catalogue freq;
          if (DBCache::Current().Get("freqtbl", freq))
          {
              freq->Set("Freq", 1090.8);
              freq->Set("Type", 0);
              freq->Save();
      
              freq->Set("Freq", 978.5);
              freq->Set("Type", 1);
              freq->Save();
          }

Using PersistableBase

    //Insert
          AppData distance(1, "distance", "25Ft");
          distance.Save();
      
          AppData port(1, "port", "A");
          port.Save();

Read from DB

We can retrieve the objects from database as;

Using Catalogue from DBCache

    //Select
      up_catalogue limit;
          if (DBCache::Current().Get("limtbl", limit))
          {
              vector veclimit = limit->Select("Type = 0");
              for(Catalogue& limiCat: veclimit)
              {
                  cout << limiCat.getDouble("MaxLim") << endl;
                  //Or
                  double minLim;
                  if(limiCat.get(minLim, "MinLim"))
                  {
                      cout << minLim << endl;
                  }
              }
          }

Using PersistableBase

    //Select
          auto all = PersistableBase::Select(AppData());
          for(auto ad: all){
              cout << ad.key() << ":" << ad.value() << endl;
          }

Read selectively

We can also apply basic filter to the list of items we want to fetch from DB. To filter data selection, simply pass the SQL string that is required to create the WHERE clause (but, without the WHERE keyword).

            //SELECT (filtered) (Catalogue class has wrapper function)
                  vector filtered = PersistableBase::Select(AppData(), "key = 'distance' AND value = '25Ft'");

The other way is to use the inbuilt API for filters. Filter object can be passed as second argument to the PersistableBase::Select(...) function. We need to call apply_filter(const char* key, t value) from filter.h. For example, to apply filter to the list so as to get only rows with key equal to “distance”, we need to pass apply_filter("key", string("distance")) as second argument of the PersistableBase::Select(...) function. To apply multiple filters separated by logical operators, we can simply go on adding more apply_filter(...) after dot and the logical operator required in the clause. An example can clarify better the syntax.

      //Select (filtered)(Catalogue class has wrapper function)
            auto selected = PersistableBase::Select(AppData("app.db", "data1"),
                  apply_filter("key", string("distance")    // first filter
                  .AND()                                    // logical operator
                  .apply_filter("value", string("25Ft")));  // second filter and so on...
            
            for(auto ad: selected){
                  cout << ad.key() << ":" << ad.value() << endl;
            }
      

Apart from AND() the only other logical operators available as of now are OR() and NOT(). The apply_filter(...) function has third (optional) argument that is an enum (FilterOperatorEnum). It is the operator acting between the two arguments of apply_filter(...). By defualt, it is EQ(==). The other options are; LT(<)LTQ(<=)GT(>)GTQ(>=) and NQ(!)

Update existing records in DB

We can update any object right back in the database as;

    //Update(Catalogue class has wrapper function)
          AppData& ad = all.at(5);
          ad.setKey("stars");
          ad.Update();

Delete records in DB

And we can delete any object in the database as;

    //Delete(Catalogue class has wrapper function)
          all.at(10).Remove();

The library has an PersistableBase class that offers public interface for the basic DB operations (Save/Update/Delete/List). This class derives from the Column class. The Column class is quite interesting in more than one ways. Firstly, this is the class that actually holds data for the class either when it is moving from the class to do or when it moves from DB to class. Hence, this class has to be capable of holding multiple types of data. It uses four separate templated data collections to hold this data. And then there is Table class that is a kind of controller for the DB class, which is a wrapper around the actual sqlite3 API. The function of the Table class is to provide standard interface that is identifiable with database opeartions. This class, creates SQL strings using a helper library (SqlScriptHelper) and passes the same to the DB class. All databse related functions are written in DB class. All SQL quries creating code is written in SqlScriptHelper class. The public interface to basic db operations are all provided in Table class. And to access those functions easily, PersistableBase class provides some virtual functions, that are called internally when Save, Update or List are run. And PersistableBase is the class we can derive from to make a class serializable/deserializable.

Points of Interest

There is an interesting feature that the current code leads to. Although the code right now handles only the simple data types, but with a small enhancement in code, we can easily make it handle composite types or user-defined types. This would mean that any object can be saved in SQLite DB by just deriving from PersistableBase even if the properties/fields we are trying to serialize are complex user-defined types.

      //UserData has both, a simple type (int m_userId), and a composite type (Appdata appData)
            class UserData: public PersistableBase //Derives from PersistableBase
            {
      
            private:
                int m_userId = 0;
                AppData appData //Already derives from PersistableBase
            };

The only pre requisite is that the composite fields should also derive from PersistableBase.

This does work as required but a preferred way of using the library would be via deriving from PersistableBase class. That would bring the benefits of encapsulation and create a standard implementation across the client app.