|
1 | 1 | --- |
2 | 2 | title: Database and DB Migrations |
3 | 3 | slug: database-and-db-migrations |
4 | | ---- |
| 4 | +--- |
| 5 | + |
| 6 | +## Database Design |
| 7 | + |
| 8 | +| Column Name | Datatype | Not Null | Primary Key | |
| 9 | +|----------------|-------------|----------|-------------| |
| 10 | +| created_at | TIMESTAMPTZ | ✅ | | |
| 11 | +| updated_at | TIMESTAMPTZ | ✅ | | |
| 12 | +| id | UUID | ✅ | ✅ | |
| 13 | +| published_date | DATE | ✅ | | |
| 14 | +| status | SMALLINT | ✅ | | |
| 15 | +| title | TEXT | ✅ | | |
| 16 | +| description | TEXT | | | |
| 17 | +| image_url | TEXT | | | |
| 18 | + |
| 19 | +## Add `jiff`, `uuid`, `toasty` and `toasty-cli` |
| 20 | + |
| 21 | +Run following `cargo add` commands from the `book_service` folder. |
| 22 | + |
| 23 | +```shell |
| 24 | +cargo add jiff |
| 25 | +cargo add uuid -F v7,fast-rng |
| 26 | +cargo add toasty -F postgresql,migration,jiff |
| 27 | +cargo add toasty-cli |
| 28 | +``` |
| 29 | + |
| 30 | +## Initial Model |
| 31 | + |
| 32 | +### Add Initial `book.rs` |
| 33 | + |
| 34 | +Add a new file `book_service/src/models/book.rs`. |
| 35 | + |
| 36 | +```rust |
| 37 | +use jiff::{Timestamp, civil::Date}; |
| 38 | +use uuid::Uuid; |
| 39 | + |
| 40 | +#[derive(Debug, toasty::Model)] |
| 41 | +pub struct Book { |
| 42 | + #[auto] |
| 43 | + pub created_at: Timestamp, |
| 44 | + |
| 45 | + #[auto] |
| 46 | + pub updated_at: Timestamp, |
| 47 | + |
| 48 | + #[key] |
| 49 | + #[auto] |
| 50 | + pub id: Uuid, |
| 51 | + |
| 52 | + pub published_date: Date, |
| 53 | + |
| 54 | + pub status: BookStatus, |
| 55 | + |
| 56 | + pub title: String, |
| 57 | + |
| 58 | + pub description: Option<String>, |
| 59 | + |
| 60 | + pub image_url: Option<String>, |
| 61 | +} |
| 62 | + |
| 63 | +#[derive(Debug, Clone, Copy, PartialEq, toasty::Embed)] |
| 64 | +#[column(type = u8)] |
| 65 | +pub enum BookStatus { |
| 66 | + #[column(variant = 0)] |
| 67 | + Pending, |
| 68 | + #[column(variant = 1)] |
| 69 | + Verified, |
| 70 | +} |
| 71 | +``` |
| 72 | + |
| 73 | +### Re-exporting `Book` and `BookStatus` |
| 74 | + |
| 75 | +Add a new file `book_service/src/models/mod.rs`. |
| 76 | + |
| 77 | +```rust |
| 78 | +mod book; |
| 79 | + |
| 80 | +pub use book::{Book, BookStatus}; |
| 81 | +``` |
| 82 | + |
| 83 | +Update `book_service/src/lib.rs` to add `pub mod models;`. |
| 84 | + |
| 85 | +```rust |
| 86 | +mod config; |
| 87 | +mod state; |
| 88 | + |
| 89 | +pub mod models; |
| 90 | +pub mod routes; |
| 91 | + |
| 92 | +pub use config::{AppConf, ServerConf}; |
| 93 | +pub use state::AppState; |
| 94 | +``` |
| 95 | + |
| 96 | +## Add Initial `compose.yml` |
| 97 | + |
| 98 | +Add a new file `book_service/compose.yml`. |
| 99 | + |
| 100 | +```yaml |
| 101 | +services: |
| 102 | + |
| 103 | + db: |
| 104 | + image: postgres:18-alpine |
| 105 | + container_name: book-service-db |
| 106 | + environment: |
| 107 | + POSTGRES_DB: book-service-db |
| 108 | + POSTGRES_USER: book-service-user |
| 109 | + POSTGRES_PASSWORD: book-service-pass |
| 110 | + ports: |
| 111 | + - "5432:5432" |
| 112 | + healthcheck: |
| 113 | + test: [ "CMD-SHELL", "pg_isready -U book-service-user -d book-service-db" ] |
| 114 | + interval: 3s |
| 115 | + timeout: 5s |
| 116 | + retries: 5 |
| 117 | + restart: always |
| 118 | +``` |
| 119 | +
|
| 120 | +## Database Configuration |
| 121 | +
|
| 122 | +### Update `.env` |
| 123 | + |
| 124 | +Update `book_service/.env` to add environment variables, that are related to database configuration. |
| 125 | + |
| 126 | +```env |
| 127 | +DB_PROTOCOL=postgres |
| 128 | +DB_HOST=db |
| 129 | +DB_PORT=5432 |
| 130 | +DB_USER=book-service-user |
| 131 | +DB_PASS=book-service-pass |
| 132 | +DB_NAME=book-service-db |
| 133 | +``` |
| 134 | + |
| 135 | +### Update `config.rs` |
| 136 | + |
| 137 | +Update `book_service/src/config.rs` to add `DbConf`. |
| 138 | + |
| 139 | +```rust |
| 140 | +use envconfig::Envconfig; |
| 141 | +#[derive(Envconfig)] |
| 142 | +pub struct AppConf { |
| 143 | + #[envconfig(nested)] |
| 144 | + pub server: ServerConf, |
| 145 | +
|
| 146 | + #[envconfig(nested)] |
| 147 | + pub db: DbConf, |
| 148 | +} |
| 149 | +
|
| 150 | +// impl AppConf and ServerConf |
| 151 | +
|
| 152 | +#[derive(Envconfig)] |
| 153 | +pub struct DbConf { |
| 154 | + #[envconfig(from = "DB_PROTOCOL", default = "postgres")] |
| 155 | + pub protocol: String, |
| 156 | + #[envconfig(from = "DB_HOST")] |
| 157 | + pub host: String, |
| 158 | + #[envconfig(from = "DB_PORT", default = "5432")] |
| 159 | + pub port: u16, |
| 160 | + #[envconfig(from = "DB_USER")] |
| 161 | + pub user: String, |
| 162 | + #[envconfig(from = "DB_PASS")] |
| 163 | + pub password: String, |
| 164 | + #[envconfig(from = "DB_NAME")] |
| 165 | + pub db_name: String, |
| 166 | +} |
| 167 | +
|
| 168 | +impl DbConf { |
| 169 | + pub fn init() -> Self { |
| 170 | + Self::init_from_env().expect("Failed to load configuration! Check the .env file.") |
| 171 | + } |
| 172 | +
|
| 173 | + pub fn to_database_url(&self) -> String { |
| 174 | + format!( |
| 175 | + "{}://{}:{}@{}:{}/{}", |
| 176 | + self.protocol, self.user, self.password, self.host, self.port, self.db_name |
| 177 | + ) |
| 178 | + } |
| 179 | +} |
| 180 | +``` |
| 181 | + |
| 182 | +### Re-exporting `DbConf` Struct |
| 183 | + |
| 184 | +Update `book_service/src/lib.rs` to re-export `DbConf`. |
| 185 | + |
| 186 | +```rust |
| 187 | +mod config; |
| 188 | +mod state; |
| 189 | +
|
| 190 | +pub mod models; |
| 191 | +pub mod routes; |
| 192 | +
|
| 193 | +pub use config::{AppConf, DbConf, ServerConf}; |
| 194 | +pub use state::AppState; |
| 195 | +``` |
| 196 | + |
| 197 | +## Migration CLI |
| 198 | + |
| 199 | +### Add Migration CLI |
| 200 | + |
| 201 | +Add a new file `book_service/src/bin/migration.rs`. |
| 202 | + |
| 203 | +```rust |
| 204 | +use book_service::{DbConf, models::*}; |
| 205 | +use toasty_cli::{Config, ToastyCli}; |
| 206 | +
|
| 207 | +#[tokio::main] |
| 208 | +async fn main() -> anyhow::Result<()> { |
| 209 | + let db_url = DbConf::init().to_database_url(); |
| 210 | + let config = Config::load()?; |
| 211 | + let db = toasty::Db::builder().models(toasty::models!(Book)).connect(&db_url).await?; |
| 212 | +
|
| 213 | + let cli = ToastyCli::with_config(db, config); |
| 214 | +
|
| 215 | + cli.parse_and_run().await?; |
| 216 | +
|
| 217 | + Ok(()) |
| 218 | +} |
| 219 | +``` |
| 220 | + |
| 221 | +Update `book_service/Cargo.toml` to add the migration CLI `[[bin]]`. |
| 222 | + |
| 223 | +```toml |
| 224 | +[[bin]] |
| 225 | +name = "migration" |
| 226 | +path = "src/bin/migration.rs" |
| 227 | +``` |
| 228 | + |
| 229 | +### Add `Toasty.toml` |
| 230 | + |
| 231 | +Add a new file `book_service/Toasty.toml`. |
| 232 | + |
| 233 | +```toml |
| 234 | +[migration] |
| 235 | +path = "toasty" |
| 236 | +prefix_style = "Sequential" |
| 237 | +checksums = false |
| 238 | +statement_breakpoints = true |
| 239 | +``` |
| 240 | + |
| 241 | +## Update justfile |
| 242 | + |
| 243 | +Update `book_service/justfile` to add `migration` and `docker` commands. |
| 244 | + |
| 245 | +```justfile |
| 246 | +export CARGO_TARGET_DIR := "../../target" |
| 247 | +
|
| 248 | +db_host := "localhost" |
| 249 | +
|
| 250 | +## help, lint, check, ..., app |
| 251 | +
|
| 252 | +# Run DB migration CLI |
| 253 | +migration *cmd: |
| 254 | + @export $(grep -v '^#' .env | xargs) && \ |
| 255 | + DB_HOST={{ db_host }} \ |
| 256 | + cargo run --bin migration -- migration {{ cmd }} |
| 257 | +
|
| 258 | +# Run docker commands |
| 259 | +docker *cmd: |
| 260 | + docker {{ cmd }} |
| 261 | +``` |
| 262 | + |
| 263 | +## Generate & Apply DB Migrations |
| 264 | + |
| 265 | +> [!tip] |
| 266 | +> We need to run the local database first. Then, run the migration CLI commands. |
| 267 | +> ``` |
| 268 | +> Commands: |
| 269 | +> apply Apply pending migrations to the database |
| 270 | +> generate Generate a new migration based on schema changes |
| 271 | +> snapshot Print the current schema snapshot file |
| 272 | +> drop Drop a migration from the history |
| 273 | +> reset Reset the database (drop all tables) and optionally re-apply migrations |
| 274 | +> help Print this message or the help of the given subcommand(s) |
| 275 | +> ``` |
| 276 | + |
| 277 | +Due to our justfiles, we can run the following commands from the project root: |
| 278 | + |
| 279 | +- `just book docker compose up`: to start the local database |
| 280 | +- `just book migration`: to see the available commands |
| 281 | +- `just book migration generate`: to generate database migrations |
| 282 | +- `just book migration apply`: to apply database migrations |
| 283 | + |
| 284 | +`just book migration generate` command generates a new folder, `book_service/toasty`, with the following files. |
| 285 | + |
| 286 | +{{< tabs count=3 label="Select File" >}} |
| 287 | +{{< tab label="history.toml" >}} |
| 288 | +```toml {title="crates/book_service/toasty/history.toml"} |
| 289 | +version = 1 |
| 290 | +
|
| 291 | +[[migrations]] |
| 292 | +id = 1234567890123456789 |
| 293 | +name = "0000_migration.sql" |
| 294 | +snapshot_name = "0000_snapshot.toml" |
| 295 | +``` |
| 296 | +{{< /tab >}} |
| 297 | +{{< tab label="0000_migration.sql" >}} |
| 298 | +```sql {title="crates/book_service/toasty/migrations/0000_migration.sql"} |
| 299 | +CREATE TABLE "books" ( |
| 300 | + "created_at" TIMESTAMPTZ(6) NOT NULL, |
| 301 | + "updated_at" TIMESTAMPTZ(6) NOT NULL, |
| 302 | + "id" UUID NOT NULL, |
| 303 | + "published_date" DATE NOT NULL, |
| 304 | + "status" SMALLINT NOT NULL, |
| 305 | + "title" TEXT NOT NULL, |
| 306 | + "description" TEXT, |
| 307 | + "image_url" TEXT, |
| 308 | + PRIMARY KEY ("id") |
| 309 | +); |
| 310 | +``` |
| 311 | +{{< /tab >}} |
| 312 | +{{< tab label="0000_snapshot.toml" >}} |
| 313 | +```toml {title="crates/book_service/toasty/snapshots/0000_snapshot.toml"} |
| 314 | +version = 1 |
| 315 | +
|
| 316 | +[schema] |
| 317 | +
|
| 318 | +[[schema.tables]] |
| 319 | +id = 0 |
| 320 | +name = "books" |
| 321 | +primary_key = { columns = [{ table = 0, index = 2 }], index = { table = 0, index = 0 } } |
| 322 | +
|
| 323 | +[[schema.tables.columns]] |
| 324 | +id = { table = 0, index = 0 } |
| 325 | +name = "created_at" |
| 326 | +ty = "Timestamp" |
| 327 | +storage_ty = { Timestamp = 6 } |
| 328 | +
|
| 329 | +[[schema.tables.columns]] |
| 330 | +id = { table = 0, index = 1 } |
| 331 | +name = "updated_at" |
| 332 | +ty = "Timestamp" |
| 333 | +storage_ty = { Timestamp = 6 } |
| 334 | +
|
| 335 | +[[schema.tables.columns]] |
| 336 | +id = { table = 0, index = 2 } |
| 337 | +name = "id" |
| 338 | +ty = "Uuid" |
| 339 | +storage_ty = "Uuid" |
| 340 | +
|
| 341 | +[[schema.tables.columns]] |
| 342 | +id = { table = 0, index = 3 } |
| 343 | +name = "published_date" |
| 344 | +ty = "Date" |
| 345 | +storage_ty = "Date" |
| 346 | +
|
| 347 | +[[schema.tables.columns]] |
| 348 | +id = { table = 0, index = 4 } |
| 349 | +name = "status" |
| 350 | +ty = "U8" |
| 351 | +storage_ty = { UnsignedInteger = 1 } |
| 352 | +
|
| 353 | +[[schema.tables.columns]] |
| 354 | +id = { table = 0, index = 5 } |
| 355 | +name = "title" |
| 356 | +ty = "String" |
| 357 | +storage_ty = "Text" |
| 358 | +
|
| 359 | +[[schema.tables.columns]] |
| 360 | +id = { table = 0, index = 6 } |
| 361 | +name = "description" |
| 362 | +ty = "String" |
| 363 | +storage_ty = "Text" |
| 364 | +nullable = true |
| 365 | +
|
| 366 | +[[schema.tables.columns]] |
| 367 | +id = { table = 0, index = 7 } |
| 368 | +name = "image_url" |
| 369 | +ty = "String" |
| 370 | +storage_ty = "Text" |
| 371 | +nullable = true |
| 372 | +
|
| 373 | +[[schema.tables.indices]] |
| 374 | +id = { table = 0, index = 0 } |
| 375 | +name = "index_books_by_id" |
| 376 | +on = 0 |
| 377 | +columns = [{ column = { table = 0, index = 2 }, op = "Eq", scope = "Partition" }] |
| 378 | +unique = true |
| 379 | +primary_key = true |
| 380 | +``` |
| 381 | +{{< /tab >}} |
| 382 | +{{< /tabs >}} |
| 383 | + |
| 384 | +Run `just book migration apply` to apply database migrations to the local database. |
0 commit comments