Data Flow¶
This page walks through the complete pipeline from a .wlite model file to executable SQL. Every call to wlite_migrate or wlite_diff goes through these stages.
Stage 1: Parse¶
The parser reads a .wlite file and produces an in-memory WlSchema. The schema contains tables, columns, check constraints, unique constraints, foreign keys, indexes, views, and triggers.
What the parser handles¶
- Tokenization: keywords, identifiers, strings, numbers, operators
- Recursive descent: model blocks, field declarations, constraints, indexes
- Comment stripping: line comments (
#) and block comments (/* */) - Error reporting: line number and column for syntax errors
The parser is a hand-written recursive descent parser. It does not use lex or yacc. This keeps the dependency count at zero and makes error messages clear.
WlSchema structure¶
typedef struct WlSchema {
uint32_t version; // schema version number
WlTable *tables; // array of tables
size_t table_count; // number of tables
WlIndex *indexes; // array of indexes
size_t index_count; // number of indexes
WlView *views; // array of views
size_t view_count; // number of views
WlTrigger *triggers; // array of triggers
size_t trigger_count; // number of triggers
char *model_name; // model name string
int model_version; // model version integer
} WlSchema;
Each WlTable contains an array of WlColumn (columns), an array of WlCheck (check constraints), an array of WlUnique (unique constraints), and an array of WlForeignKey (foreign key constraints).
Example¶
Given this .wlite file:
model User {
table "users"
field id integer {
primary_key
autoincrement
}
field name text {
not_null
}
}
The parser produces a WlSchema with:
- 1 table:
users - 2 columns:
id(INTEGER, PK, AUTOINCREMENT),name(TEXT, NOT NULL) - 0 indexes, 0 views, 0 triggers
Parser error messages¶
When the parser encounters a syntax error, it returns a detailed error with the line number, column, and a description of what was expected. This makes it easy to locate and fix problems in model files.
The parser does not attempt to recover from errors. The first syntax error stops parsing and returns an error code. This is a deliberate choice: partial schemas are dangerous because they can produce unexpected migrations.
Stage 2: Introspect¶
The introspector reads the live SQLite database and produces a WlSchema in the same format as the parser output.
What the introspector reads¶
sqlite_masterfor table definitionsPRAGMA table_info()for column details (name, type, nullable, default)PRAGMA index_list()andPRAGMA index_info()for index detailsPRAGMA foreign_key_list()for foreign key detailsPRAGMA table_xinfo()for generated columns
The introspector handles all SQLite quirks. It normalizes type strings, resolves default value expressions, and detects the various ways SQLite encodes primary keys, autoincrement, and generated columns.
Type normalization¶
SQLite stores types as strings. The introspector normalizes them for comparison:
| Stored type | Normalized to |
|---|---|
INT, INTEGER, INT4, BIGINT, SIGNED |
INTEGER |
BOOLEAN, BOOL, TINYINT |
INTEGER |
REAL, DOUBLE, FLOAT, NUMERIC |
REAL |
TEXT, VARCHAR, CHAR, CLOB |
TEXT |
DATETIME, TIMESTAMP |
TEXT |
BLOB |
BLOB |
This normalization means changing INT to INTEGER in your model does not trigger a migration.
Constraint detection¶
The introspector reads constraints from multiple sources:
- Primary keys from
PRAGMA table_info()(thepkcolumn) - Unique constraints from
PRAGMA index_list()(theuniqueflag) - Foreign keys from
PRAGMA foreign_key_list() - Check constraints parsed from the
CREATE TABLESQL insqlite_master
This is necessary because SQLite does not have a single pragma that returns all constraints. The introspector assembles them from several sources.
Example¶
If the live database has:
The introspector produces a WlSchema with:
- 1 table:
users - 3 columns:
id(INTEGER, PK, AUTOINCREMENT),name(TEXT, NOT NULL),email(TEXT, nullable)
Stage 3: Diff¶
The diff engine compares the model schema against the database schema and produces a WlDiff.
Diff operations¶
| Operation | Meaning |
|---|---|
WL_DIFF_ADD_TABLE |
New table to create |
WL_DIFF_DROP_TABLE |
Table to remove |
WL_DIFF_RENAME_TABLE |
Table to rename |
WL_DIFF_ADD_COLUMN |
New column to add |
WL_DIFF_DROP_COLUMN |
Column to remove |
WL_DIFF_RENAME_COLUMN |
Column to rename |
WL_DIFF_ALTER_COLUMN |
Column type, nullability, default, or comment change |
WL_DIFF_ADD_INDEX |
New index to create |
WL_DIFF_DROP_INDEX |
Index to remove |
WL_DIFF_ALTER_INDEX |
Index definition change |
WL_DIFF_ADD_CHECK |
New check constraint to add |
WL_DIFF_DROP_CHECK |
Check constraint to remove |
WL_DIFF_ADD_UNIQUE |
New unique constraint to add |
WL_DIFF_DROP_UNIQUE |
Unique constraint to remove |
WL_DIFF_ADD_FKEY |
New foreign key to add |
WL_DIFF_DROP_FKEY |
Foreign key to remove |
WL_DIFF_ALTER_TABLE_OPTIONS |
Table options change (STRICT, WITHOUT ROWID) |
WL_DIFF_ALTER_VIEW |
View definition change |
WL_DIFF_ALTER_TRIGGER |
Trigger definition change |
WL_DIFF_REBUILD_TABLE |
Table requires full rebuild |
Classification¶
Each difference is classified by severity:
- Additive: new tables, columns, indexes (safe, non-destructive)
- Subtractive: dropping tables or columns (destructive, requires confirmation)
- Alterative: changing column properties (may require rebuild)
- Rebuild: changes SQLite's ALTER TABLE cannot express
The classification drives both the plan output and the optional confirmation step. Additive changes can be applied silently. Subtractive changes should always be reviewed.
Column comparison¶
Columns are compared on five properties:
- Type: after normalization, types must match
- Nullability: NULL vs NOT NULL
- Default: semantically equivalent defaults are treated as equal
- Generated: stored vs virtual, and the generation expression
- Comment: optional comment text
If any property differs, the column is marked as altered. The planner decides whether the alteration can be done with ALTER TABLE or requires a rebuild.
Constraint comparison¶
Constraints are compared by their structural properties:
- Primary keys: column list and sort order
- Unique constraints (
WlUnique): column list - Foreign keys (
WlForeignKey): target table, target column, ON DELETE, ON UPDATE - Check constraints (
WlCheck): expression text
Any change to a constraint triggers a rebuild. There is no way to ALTER a constraint in SQLite without rebuilding the table.
Example¶
Model has: id, name, email, created_at
Database has: id, name, email
Diff result:
- WL_DIFF_ADD_COLUMN: created_at (DATETIME, NOT NULL, DEFAULT CURRENT_TIMESTAMP)
Stage 4: Plan¶
The planner converts the diff into an ordered WlPlan of executable SQL statements.
Planning rules¶
- Ordering: Tables are created before columns are added. Indexes are created after tables.
- Dependencies: Foreign key targets are created before referencing tables.
- Rebuilds: Table rebuilds are expanded into a sequence: create staging, copy data, drop old, rename.
- Collapse: Multiple rebuilds on the same table are collapsed into one.
- Views and triggers: Created after all tables and indexes exist.
WlPlan structure¶
typedef struct WlPlan {
WlPlanStep *steps; // array of SQL statements
size_t step_count; // number of steps
char *schema_hash_before; // FNV-1a hash of schema before migration
char *schema_hash_after; // FNV-1a hash of schema after migration
} WlPlan;
Each WlPlanStep contains a SQL string, a rollback SQL string, the target table name, a detail description, a safety classification, and an is_non_atomic flag.
Step types¶
| Step type | Purpose |
|---|---|
WL_PLAN_CREATE_TABLE |
Create a new table |
WL_PLAN_DROP_TABLE |
Drop an existing table |
WL_PLAN_RENAME_TABLE |
Rename a table |
WL_PLAN_ADD_COLUMN |
Add a column to an existing table |
WL_PLAN_DROP_COLUMN |
Remove a column from a table |
WL_PLAN_RENAME_COLUMN |
Rename a column |
WL_PLAN_ALTER_COLUMN |
Change a column property |
WL_PLAN_REBUILD_TABLE |
Part of a table rebuild sequence |
WL_PLAN_CREATE_INDEX |
Create a new index |
WL_PLAN_DROP_INDEX |
Drop an existing index |
WL_PLAN_ADD_CHECK |
Add a check constraint (via rebuild) |
WL_PLAN_DROP_CHECK |
Drop a check constraint (via rebuild) |
WL_PLAN_ADD_UNIQUE |
Add a unique constraint (via rebuild) |
WL_PLAN_DROP_UNIQUE |
Drop a unique constraint (via rebuild) |
WL_PLAN_ADD_FKEY |
Add a foreign key (via rebuild) |
WL_PLAN_DROP_FKEY |
Drop a foreign key (via rebuild) |
WL_PLAN_CUSTOM_SQL |
Execute custom SQL |
Example¶
For the diff above (adding created_at), the plan is:
One step. No rebuild needed.
Stage 5: Migrate¶
The migration engine executes the plan within a transaction.
What migrate does¶
- Begin a transaction
- Execute each step in order
- After each step, verify the result (optional, controlled by options)
- Record a checksum of the final schema state
- Commit the transaction (or rollback on error)
Checksums¶
After migration, libwlite computes an FNV-1a hash of the schema state. This hash is stored in the plan's schema_hash_after field and is used by wl_schema_verify to verify that the database matches the model without re-introspecting.
Error handling¶
If any step fails, the entire migration is rolled back. The error includes the failing SQL statement and the SQLite error message.
Dry run¶
You can call wlite_diff instead of wlite_migrate to see the plan without executing it. This is useful for reviewing changes before applying them.
WlPlan *plan = NULL;
wlite_result r = wlite_diff(db, model, &plan);
if (r == WLITE_OK) {
for (size_t i = 0; i < wlite_plan_count(plan); i++) {
printf("%s\n", plan->steps[i].sql);
}
}
wl_plan_free(plan);
Complete example¶
#include <wlite/wlite.h>
int main(void) {
wlite_model *model = NULL;
wlite_db *db = NULL;
// Stage 1: Load model
wlite_model_load_file("schema.wlite", &model);
// Stage 2-5: Open DB and migrate
wlite_open("app.db", &db);
wlite_migrate(db, model);
// Or just preview (stages 2-4 only)
WlPlan *plan = NULL;
wlite_diff(db, model, &plan);
printf("Migration has %zu steps\n", wlite_plan_count(plan));
wl_plan_free(plan);
wlite_close(db);
wlite_model_free(model);
return 0;
}
Error paths¶
Every stage can return an error. The error codes are documented in Memory and Errors. The most common errors are:
WLITE_NOT_FOUND: the model file or database does not existWLITE_MODEL_ERROR: the model file has syntax errorsWLITE_SQLITE_ERROR: a SQL execution failed during migrationWLITE_ERROR: a generic error occurred
When wlite_migrate is used with error reporting, the error object includes the failing SQL statement and the SQLite error message. This makes debugging straightforward.
Performance characteristics¶
The pipeline is designed to be fast for typical schemas. The parsing stage is O(n) in the size of the model file. The introspection stage is O(m) in the number of tables and columns. The diff stage is O(n + m). The planning stage is O(d) in the number of differences. The migration stage is limited by SQLite I/O.
For schemas with fewer than 100 tables, the entire planning phase (stages 1 through 4) takes microseconds. The migration phase is dominated by SQLite's own execution time, especially for table rebuilds that copy data.
Summary¶
The data flow is linear and deterministic. A model file produces a schema. A database produces a schema. The diff identifies differences. The plan orders them. The migration executes them. Every stage is a pure function of its inputs. There is no hidden state, no side-channel configuration, and no ambiguous behavior.