A Minimalist Design

Sometimes just enough is exactly the right abstraction.

Back in 2011 at ST Engineering, during the development of a virtual campus, our client team urgently needed a data access solution. That pressure dictated minimalism: a declarative abstraction over the mechanics of ADOX.

I had one week. The result was a simple DSL.


Declaring an Entity

A database entity could be declared in a few lines:

class Server : public ClientDatabase::DataEntity {
public:
    __int(Id)
    __wstring(Name)

    __table(Servers)
    __key(Id, int, false)
};

class Servers : public ClientDatabase::Table<Server> {};

That's it, full CRUD out of the box.

The declaration contains the information the framework needs: Server has an integer identifier and a name, is stored in the Servers table, and uses Id as its key.

Application code can then simply use the entity:

const unique_ptr<Servers> servers(Servers::SelectAll());

for (const auto& server : *servers) {
    wcout << server->GetName() << '\n';
}

The important distinction is between declaration and mechanism. Application code describes the data model; the framework handles the repetitive mechanics of persistence.

Macros such as __int, __wstring, __table and __key form a small declarative vocabulary.

The C++ preprocessor is effectively being used as a lightweight code generator, turning those declarations into the plumbing required by the framework.


Extending the Vocabulary

Tables and entities can be extended with new operations composed from properties. The preprocessor will expand them into functions named after the operation and properties:

// composing operations on a data entity
class Dude:public ClientDatabase::DataEntity {
public:
    __int(Id)
    __wstring(Name)
    __datetime(DOB)

    __table(Dudes)
    __key(Id,int,true)

    __updateby1(Dudes, Name, std::wstring)          // expands to: UpdateByName
    __updateby2(Dudes, Id, int, Name, std::wstring) // expands to: UpdateByIdName
};

// composing operations on a table
class Dudes:public ClientDatabase::Table<Dude> {
public:
    __deleteby1(Name, std::wstring)           // expands to: DeleteByName
    __selectby2(Id, int, Name, std::wstring)  // expands to: SelectByIdName
    __selectin(Dudes, Name)                   // expands to: SelectInName    
};

Now we can make use of these new operations:

const auto people = unique_ptr<Dudes>(Dudes::SelectAll());

Dudes::DeleteByName(bobby->GetName());

const std::vector<std::wstring> gang { L"Bobby", L"Fred", L"Mary", L"Tracy", L"Chris" };
const auto dudes = unique_ptr<Dudes>(Dudes::SelectInName(gang));

Looking back, I can see plenty of room for refinement.

But given the urgency, it was sufficient.

I have made this letter longer than usual because I have not had time to make it shorter.

Blaise Pascal

Declaring a Query

The same approach extends beyond single-table CRUD to queries:

// define the entity the query will use
class QApplication : public ClientDatabase::Entity {
public:
    __int(Id)
    __wstring(Module)
    __bool(OfflineAccess)
    __wstring(App)
    __wstring(Type)
    __wstring(Server)
};

// define the query with required operations
class ApplicationQuery : public ClientDatabase::Query<QApplication> {
public:
    __query(ApplicationQuery, GetAll, GET_ALL_SQL);
    __queryby1(ApplicationQuery, GetByServerId, GET_BY_SERVER_ID_SQL, ServerId, int);
};

The SQL can project data across several related tables:

const char* GET_BY_SERVER_ID_SQL =
    "SELECT "
    "ApplicationModules.ApplicationId AS [Id], "
    "ApplicationModules.Name AS [Module], "
    "ApplicationModules.OfflineAccess AS [OfflineAccess], "
    "Applications.Name AS [App], "
    "ApplicationModuleTypes.Name AS [Type], "
    "Servers.Name AS [Server] "
    "FROM ((ApplicationModules "
    "INNER JOIN Applications "
        "ON ApplicationModules.ApplicationId = Applications.Id) "
    "INNER JOIN ApplicationModuleTypes "
        "ON ApplicationModules.ModuleTypeId = ApplicationModuleTypes.Id) "
    "INNER JOIN Servers "
        "ON Applications.ServerId = Servers.Id "
    "WHERE Applications.ServerId = ? "
    "ORDER BY Applications.Name ASC;";
    
const char* GET_ALL_SQL = "SELECT ..."; // etc.

Any parameters in the SQL:

WHERE Applications.ServerId = ?

Must have a corresponding declaration in the related query operation:

ServerId, int

__queryby1 describes what is required: a query operation named GetByServerId, using the specified SQL, with an integer parameter named ServerId.

The framework turns that declaration into the machinery needed to execute the query and materialise a collection of QApplication objects.

The resulting application code remains straightforward:

const unique_ptr<ApplicationQuery> apps(ApplicationQuery::GetByServerId(1));

for (const auto& app : *apps) {
    wcout << app->GetApp()    << L" - " << app->GetModule() << L'\n';
}

As Simple as Possible, but Not Too Simple

Despite the simplicity, there was a surprising amount of work under the hood.

The framework needed macros, templated functions, entities, keys, properties, CRUD operations, parameterised queries, database configuration and indirection, builders, and adapters. It had to wrap ADOX COM objects.

The programming model became:

declare the entity → declare the query → use it.


The Design in Action

Around two years after leaving the project, I happened to meet one of my former colleagues. He told me that the team had liked the approach so much that they had adopted it for subsequent project work.

The reason was pleasingly simple: they found it extremely easy to use.

That taught me a lesson: abstraction should be judged partly by the cognitive load it removes from its consumers, not by the sophistication of the machinery that produced it.

Here's a short video of it in action: