AfterSchema Reference
aftermarket schema helps an old app keep working after its SQLite database moved to a new schema. You describe each old table in terms of the new tables, and AfterSchema writes SQL views that show the old tables again, read from the new ones. Before you apply anything, it shows what changed between the two schemas, which old queries would break, and whether every old read gives the same rows through the views as it did on the old database.
AfterSchema never writes a database. It opens them read-only, and its only output is a .sql file you review and apply yourself. The worked example is examples/legacy-shop, which also ships in every download archive, and the case study walks through it.
aftermarket schema diff --old A --new B [--map map.yaml] [--json]
aftermarket schema compat --map map.yaml --out compat.sql [--new B]
aftermarket schema check --queries old.sql [--map map.yaml] [--new B]
aftermarket schema test --map map.yaml --queries old.sql [--old A] [--new B]
A and B are each a SQLite database file or a .sql file (a schema, with or without data, such as the output of sqlite3 app.db .dump). A .sql file is loaded into memory, never into a file.
AfterSchema works through the sqlite3 command, which must be installed (any version from 3.37 on). Set AFTERMARKET_SQLITE3 to use a particular one.
The mapping file
version: 1
old: v1/shop.sql # the old schema (paths are relative to this file)
new: v2/shop.sql # the new schema, used when --new isn't given
views:
- old: customers # the name old queries use
from: accounts # the new table (or view) it reads
columns:
name: first_name || ' ' || last_name
fax: null # no longer stored
created: created_at
where: deleted_at IS NULL
- old: orders
from: purchases
columns:
customer_id: account_id
total: total_cents / 100.0
status: state
placed: placed_at
| Key | Meaning |
|---|---|
version |
Always 1. |
old |
The old schema. Required, so every view can list the old columns in their old order. |
new |
The new schema. Optional; --new overrides it. |
views[].old |
An old table or view name. Each may be listed once. |
views[].from |
The new table or view the old one is read from. |
views[].columns |
Old column name to an SQL expression over from. null means the column is always NULL. |
views[].where |
Optional SQL condition. Rows that don’t meet it are hidden from old queries. |
Old columns that aren’t listed keep their name: the view reads the column with the same name in from, and compat stops if there is none. Each view shows every old column, in the old order, so SELECT * and INSERT ... SELECT keep the old shape.
Expressions are plain SQL and may call any function SQLite has. Each must be one piece of SQL: no ;, no -- comment (put a # comment after the YAML value instead, or use /* */), and no string or comment left open. An expression that doesn’t compile on the new schema is reported with its line in the mapping file.
diff
Lists every difference between two schemas and marks the ones that can break an old app.
$ aftermarket schema diff --old v1/shop.sql --new v2/shop.sql
v1/shop.sql -> v2/shop.sql: 5 change(s), 2 breaking
breaking table customers removed
breaking table orders removed
safe column products.sku added (TEXT)
safe table accounts added (id, first_name, last_name, email, created_at, deleted_at)
safe table purchases added (id, account_id, total_cents, state, placed_at)
Breaking: a table, view or column removed; a table replaced by a view that doesn’t show all its columns; an INTEGER PRIMARY KEY that is no longer the table’s rowid; a column whose type now reads values differently (a different type affinity); a column that may now be NULL; a view that no longer shows a column; a table that became WITHOUT ROWID. Other changes are listed as safe, with a note where old writes could now fail, such as a new NOT NULL column with no default, a new unique index or a table that became STRICT. Declared types with the same affinity (VARCHAR(10) and TEXT) are the same to SQLite and are listed as safe.
With --map, breaking changes the views take care of are marked covered:
$ aftermarket schema diff --map map.yaml
v1/shop.sql -> v2/shop.sql: 5 change(s), 0 breaking
covered table customers removed, covered by the compatibility view customers
covered table orders removed, covered by the compatibility view orders
...
A view counts as covering only when every view in the mapping compiles on the new schema; otherwise the problems are listed and nothing is marked covered. diff exits 1 when any breaking change is left uncovered, so it fits in CI. --json prints the same list as JSON. The inner tables of a virtual table (such as FTS5’s) and its hidden columns aren’t listed.
compat
Writes the views to a file:
$ aftermarket schema compat --map map.yaml --out compat.sql
wrote compat.sql: 2 view(s): customers, orders
BEGIN;
DROP VIEW IF EXISTS "customers";
CREATE VIEW "customers" ("id", "name", "email", "fax", "created") AS
SELECT
"id",
first_name || ' ' || last_name,
"email",
NULL,
created_at
FROM "accounts"
WHERE deleted_at IS NULL;
...
COMMIT;
Before writing, compat reads both schemas, checks the mapping against them and compiles every view on the new schema. If anything is wrong, every problem is listed and nothing is written. The same mapping always gives the same bytes. --out may not be one of the inputs, and the file is replaced atomically.
Apply it yourself when you’re ready, for example sqlite3 new.db < compat.sql. Running it again replaces the views.
check
Compiles each old query on the new schema, with the views when --map is given, without running it:
$ aftermarket schema check --queries reports.sql --new v2/shop.sql
FAIL line 4: SELECT id, name, email FROM customers ORDER BY id
no such table: customers
...
1 of 12 queries compile on the new schema
$ aftermarket schema check --queries reports.sql --map map.yaml
12 of 12 queries compile on the new schema with the compatibility views
The queries file holds statements separated by ;, with -- and /* */ comments, as in any .sql file; a CREATE TRIGGER ends at the ; after its END. Writes are compiled too, so check also shows which old INSERT, UPDATE and DELETE statements no longer fit. EXPLAIN statements are compiled as they are.
test
Runs every old read on the old database and on the new one with the views, and compares the results:
$ aftermarket schema test --map map.yaml --queries reports.sql
PASS line 4: SELECT id, name, email FROM customers ORDER BY id
...
12 of 12 reads give the same rows on the new schema with the compatibility views as on the old one
Results must match in column names, values and types: 1 and 1.0 and '1' are all different, and a text with a line break is compared whole. When neither side gives any rows, the column names are still compared. A query with ORDER BY must give the rows in the same order, ties included; one without may give them in any order (an ORDER BY inside a subquery or a window doesn’t count). Only reads are compared: SELECT, VALUES, and WITH when its main statement is a SELECT. Everything else, EXPLAIN included, is skipped. When a read differs, test names the first row that differs and shows both values, so give it sample data, not production data. It exits 1 on any difference.
For test to mean something, the old and new databases must hold the same data, each in its own shape: for example a copy of the old database and the result of running your migration on it.
Promises
- No database is ever written. Databases are opened read-only and the
sqlite3shell runs in safe mode, from an empty temporary folder, so SQL can’t write, attach another file or run a program..sqlfiles are loaded into memory. Every piece of SQL from a file or a mapping is split into statements first, and each statement is checked exactly as it will be sent: it is refused if thesqlite3shell could read any line of it as one of its own commands (a line starting with.or#where no statement is open, or a line that is onlyGOor/, comments allowed after), if a string or comment never ends, or if it holds a control character other than a tab or a line break. A line that is onlyGOor/is refused even inside a string or comment. The tests try these attacks and check that every file stays the same byte for byte and no new file appears. compatwrites only its--outfile, never one of its inputs, and only when every view compiles on the new schema and the script reads back as exactly the statements written. The tests apply it to a real database.- The same mapping always writes the same file.
- What
testpasses reads the same. Every read in the queries file gives the same rows, with the same types, through the views as on the old database. The tests check this with randomly generated schemas, renames, split columns and scaled numbers.
Limits
- Views serve reads. An old app that writes to a mapped table gets an error from SQLite, because views can’t be written to.
checkshows which statements are affected. - A view can’t have the same name as a table, so an old table whose name the new schema still uses for a table can’t get a compatibility view.
difflists the changes to that table, and the mapping refuses it with a clear message. - One old table is read from one new table or view. When an old table is spread over several new ones, create a view in the new database that joins them and map to that.
- A
CREATE TRIGGERwhose body hasCASE ... END;is cut at thatEND;, as thesqlite3shell cuts it, so an error inside such a trigger may name the wrong line. - SQLite only. Other databases have different rules for views and types.
- A database in WAL mode gets the usual
-waland-shmhelper files next to it when it is read, as with any SQLite reader. The database file itself is not changed. testcompares results on the data it is given. It can’t know about rows the sample doesn’t hold, such as an old customer who still had a fax number.