Skip to content

Database migrations

Migrations let you version the database schema together with the application source code. Since a schema change almost always requires a matching code change, the framework treats migrations as part of the project's version control.

Typical scenarios: add a new table during development, create an index to speed up queries after going to production, rename a column, or transform data to match a new schema.

C Web Framework ships a migrate CLI utility, compiled alongside cpdy, that lets you:

  • Create new migrations.
  • Apply migrations.

Migrations can not only change the database schema but also transform the data itself — the full database layer is available inside a migration.

How it works

Each migration is a C source file compiled into a shared library (.so) and loaded by the migrate utility at runtime. The file defines a single function:

c
int up(const char* dbid);

The dbid parameter is a database identifier from config.json (e.g. postgresql.p1). The function must run SQL statements through dbquery and return 1 on success or 0 on failure. A successfully applied migration is recorded in a migration bookkeeping table so it never runs again.

There is no migration rollback (no down command): the utility only creates and applies migrations. This is intentional — design migrations so they can be safely layered on top of previous ones, without relying on a rollback.

Creating migrations

To create a new migration, run the create command:

bash
./exec/migrate create add_users_table ../config.json ../app/migrations/s1
ArgumentDescription
add_users_tablemigration name (used in the file name)
../config.jsonpath to the configuration file
../app/migrations/s1target directory where the migration file is created

A file named YYYY-MM-DD_HH-MM-SS_add_users_table.c appears in the target directory, pre-filled with a template:

c
#include <stdlib.h>

#include "dbquery.h"
#include "dbresult.h"

int up(const char* dbid) {
    dbresult_t* result = dbquery(dbid, "", NULL);

    int res = dbresult_ok(result);

    dbresult_free(result);

    return res;
}

Fill the body of up() with the SQL statements that change the database structure. Query parameters are passed as the third argument to dbquery — an array_t* (or NULL when there are none). Example migration creating a news table:

c
#include <stdlib.h>

#include "dbquery.h"
#include "dbresult.h"

int up(const char* dbid) {
    dbresult_t* result = dbquery(dbid,
        "CREATE TABLE news"
        "("
            "id    bigserial     NOT NULL PRIMARY KEY, "
            "name  varchar(100)  NOT NULL DEFAULT '', "
            "text  text          NOT NULL DEFAULT '' "
        ")"
    , NULL);

    int res = dbresult_ok(result);

    dbresult_free(result);

    return res;
}

Table names and schemas

In the examples above, news is created in the default schema. To name the schema explicitly (as the project's own migrations do — public.user, public.permission), just qualify the table: "CREATE TABLE public.news ...".

Applying migrations

To bring the database up to date, use the up command. The number of migrations is set by an optional argument — either a number or the all keyword:

bash
# apply all pending migrations
./exec/migrate up all ../config.json postgresql.p1 s1

# apply the next 2 migrations
./exec/migrate up 2 ../config.json postgresql.p1 s1

# no count given — applies exactly 1 migration
./exec/migrate up ../config.json postgresql.p1 s1
ArgumentDescription
upapply-migrations command
all | Nhow many to apply (all — every pending one; a number — exactly N)
../config.jsonpath to the configuration file
postgresql.p1database identifier <driver>.<host_id> from the databases section
s1migration set identifier (subdirectory)

If no count is given, exactly one migration is applied.

The utility scans the ./migrations/<server_id>/ directory (relative to the current working directory), loads each .so via dlopen, and for migrations not yet present in the migration table calls their up() function. Each successfully completed migration is recorded as a new row in migration, letting the tool tell applied and pending migrations apart.

Execution order

Files are sorted by name in ascending order, so migrations are applied strictly in the order they were created (by the timestamp embedded in the file name).

Identifiers

Database (dbid)

The format is <driver>.<host_id>, where host_id comes from config.json:

dbidDriverEntry in config.json
postgresql.p1PostgreSQLdatabases.postgresql[].host_id = "p1"
mysql.m1MySQLdatabases.mysql[].host_id = "m1"
redis.r1Redisdatabases.redis[].host_id = "r1"
sqlite.db1SQLitedatabases.sqlite[].host_id = "db1"

A migration is applied only to the database whose dbid you pass to up.

Migration set (server_id)

server_id (s1, s2, …) is a subdirectory holding an independent set of migrations (each compiled separately — see app/migrations/<server_id>/). It lets you maintain several independent migration chains within a single project.

The migration table

On the first up run, the utility automatically creates a migration bookkeeping table in the target database. It contains at least:

ColumnTypePurpose
versiontextmigration file name (unique version identifier)
apply_timebigintUnix timestamp of when it was applied

Before executing a migration, the utility checks whether its version is already in this table — applied migrations are skipped, making up all idempotent and safe to call repeatedly.

Released under the MIT License.