diff --git a/README.md b/README.md index 51f886a..45f21aa 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ working across different projects via [VisualMode](https://www.visualmode.dev/). For a steady stream of TILs, [sign up for my newsletter](https://visualmode.kit.com/newsletter). -_1872 TILs and counting..._ +_1873 TILs and counting..._ See some of the other learning resources I work on: @@ -1650,6 +1650,7 @@ If you've learned something here, support my efforts writing daily TILs by ### SQLite +- [Add Check Constraint To Existing Column](sqlite/add-check-constraint-to-existing-column.md) - [Display Results In Readable Column Format](sqlite/display-results-in-readable-column-format.md) - [Explore The Database Schema](sqlite/explore-the-database-schema.md) - [Manage Lightweight Schema Migrations With `user_version`](sqlite/manage-lightweight-schema-migrations-with-user-version.md) diff --git a/sqlite/add-check-constraint-to-existing-column.md b/sqlite/add-check-constraint-to-existing-column.md new file mode 100644 index 0000000..116b08b --- /dev/null +++ b/sqlite/add-check-constraint-to-existing-column.md @@ -0,0 +1,74 @@ +# Add Check Constraint To Existing Column + +To not bury the lede, SQLite does not support adding a check constraint to an +existing column. Instead, if I want to add one after the fact, I can reconstruct +the table with the check constraint specified up front and then migrate the +data. + +Here is a `sessions` table that includes `start_time` and `end_time` columns +that use the `text` data type to represent points in time. + +```sql +sqlite> .schema sessions +CREATE TABLE sessions ( + id integer primary key, + active integer not null check (active in (0, 1)), + project_id integer not null references projects(id) on delete cascade, + start_time text not null, + end_time text, + created_at text not null default (datetime('now')), + updated_at text not null default (datetime('now')) + ); +CREATE UNIQUE INDEX idx_sessions_single_active + on sessions(active) + where active = 1; +``` + +I want `start_time` and `end_time` to both enforce the shape of the timestamp +strings with `check` constraints. To do that, I need to start a transaction, +create a new version of the table with the check constraints, migrate the data, +rename `sessions` to `sessions_old`, rename `sessions_new` to `sessions` (that's +the in-place swap), and then commit the transaction. `sessions_old` can be +dropped later once I feel good about the migration. + +```sql +begin transaction; + +create table sessions_new ( + id integer primary key, + active integer not null check (active in (0, 1)), + project_id integer not null references projects(id) on delete cascade, + start_time text not null, + end_time text, + created_at text not null default (datetime('now')), + updated_at text not null default (datetime('now')), + check(start_time is strftime('%Y-%m-%dT%H:%M:%fZ', start_time)), + check(end_time is strftime('%Y-%m-%dT%H:%M:%fZ', end_time)) +); + +insert into sessions_new ( + id, active, project_id, start_time, end_time, created_at, updated_at +) +select id, active, project_id, start_time, end_time, created_at, updated_at +from sessions; + +alter table sessions rename to sessions_old; + +alter table sessions_new rename to sessions; + +drop index if exists idx_sessions_single_active; -- on sessions_old + +create unique index idx_sessions_single_active + on sessions(active) + where active = 1; + +commit; +``` + +Notice that after the table renames I also drop index (now pointing to +`sessions_old`) and recreate it for the _new_ `sessions` table. + +This approach worked well for my situation, but may not be a one-size-fits-all +solution. Depending on how the database is deployed, the size of the tables, and +usage, this approach may not scale. Always do a dry-run of database migrations +like this.