From 9c2b43ec3c14db5eef2a332f784d67fb85da9c40 Mon Sep 17 00:00:00 2001 From: Philippe Loctaux Date: Mon, 27 Feb 2023 16:07:18 +0100 Subject: [PATCH] added database crate, "settings" with migrations and queries, running migrations on web startup --- crates/database/Cargo.toml | 11 +++ crates/database/build.rs | 5 ++ crates/database/justfile | 48 ++++++++++++ .../20230227133744_settings.down.sql | 1 + .../migrations/20230227133744_settings.up.sql | 20 +++++ crates/database/queries/settings/get.sql | 9 +++ crates/database/queries/settings/init.sql | 2 + .../queries/settings/set_business_logo.sql | 5 ++ .../queries/settings/set_business_name.sql | 5 ++ crates/database/readme.md | 59 +++++++++++++++ crates/database/sqlx-data.json | 75 +++++++++++++++++++ crates/database/src/error.rs | 45 +++++++++++ crates/database/src/lib.rs | 11 +++ crates/database/src/tables/mod.rs | 3 + crates/database/src/tables/settings.rs | 57 ++++++++++++++ crates/database_pool/src/migrations.rs | 2 +- 16 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 crates/database/Cargo.toml create mode 100644 crates/database/build.rs create mode 100755 crates/database/justfile create mode 100644 crates/database/migrations/20230227133744_settings.down.sql create mode 100644 crates/database/migrations/20230227133744_settings.up.sql create mode 100644 crates/database/queries/settings/get.sql create mode 100644 crates/database/queries/settings/init.sql create mode 100644 crates/database/queries/settings/set_business_logo.sql create mode 100644 crates/database/queries/settings/set_business_name.sql create mode 100644 crates/database/readme.md create mode 100644 crates/database/sqlx-data.json create mode 100644 crates/database/src/error.rs create mode 100644 crates/database/src/lib.rs create mode 100644 crates/database/src/tables/mod.rs create mode 100644 crates/database/src/tables/settings.rs diff --git a/crates/database/Cargo.toml b/crates/database/Cargo.toml new file mode 100644 index 0000000..8f5dcfc --- /dev/null +++ b/crates/database/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "database" +version = "0.0.0" +edition = "2021" + +[dependencies] +thiserror = { workspace = true } + +[dependencies.sqlx] +workspace = true +features = ["sqlite", "macros", "migrate", "chrono", "offline", "runtime-tokio-rustls"] diff --git a/crates/database/build.rs b/crates/database/build.rs new file mode 100644 index 0000000..7609593 --- /dev/null +++ b/crates/database/build.rs @@ -0,0 +1,5 @@ +// generated by `sqlx migrate build-script` +fn main() { + // trigger recompilation when a new migration is added + println!("cargo:rerun-if-changed=migrations"); +} \ No newline at end of file diff --git a/crates/database/justfile b/crates/database/justfile new file mode 100755 index 0000000..714fef6 --- /dev/null +++ b/crates/database/justfile @@ -0,0 +1,48 @@ +#!/usr/bin/env just --justfile + +database_dir := justfile_directory() + "/../../database" +database_file := "ezidam.sqlite" +database_path := database_dir / database_file + +database_url := "sqlite://" + absolute_path(database_path) +sqlx_database := "--database-url " + database_url + +cargo := "cargo" + +# list recipes +default: + just --list + +# prepare sql queries for offline usage +offline: + {{cargo}} sqlx prepare {{sqlx_database}} + +# verify offline data is up to date +offline_check: + {{cargo}} sqlx prepare --check {{sqlx_database}} + +# run pending migrations +run: + sqlx migrate run {{sqlx_database}} + +# add new migration +new name: + sqlx migrate add -r {{name}} + +# revert latest migration +revert_last: + sqlx migrate revert {{sqlx_database}} + +# create database +create: + mkdir -p {{database_dir}} + sqlx database create {{sqlx_database}} + +# reset database and apply migrations +reset: + mkdir -p {{database_dir}} + sqlx database reset {{sqlx_database}} + +# create a build script to trigger recompilation when a new migration is added +build_script: + sqlx migrate build-script diff --git a/crates/database/migrations/20230227133744_settings.down.sql b/crates/database/migrations/20230227133744_settings.down.sql new file mode 100644 index 0000000..54ca3d7 --- /dev/null +++ b/crates/database/migrations/20230227133744_settings.down.sql @@ -0,0 +1 @@ +drop table if exists settings; diff --git a/crates/database/migrations/20230227133744_settings.up.sql b/crates/database/migrations/20230227133744_settings.up.sql new file mode 100644 index 0000000..51a1531 --- /dev/null +++ b/crates/database/migrations/20230227133744_settings.up.sql @@ -0,0 +1,20 @@ +create table if not exists settings +( + id INTEGER not null primary key check ( id = 0 ), + created_at TEXT not null default CURRENT_TIMESTAMP, + updated_at TEXT not null default CURRENT_TIMESTAMP, + business_name TEXT, + business_logo BLOB +); + +-- update "updated_at" +create trigger if not exists settings_updated_at + after + update + on settings + for each row +begin + update settings + set updated_at = CURRENT_TIMESTAMP + where id is NEW.id; +end; diff --git a/crates/database/queries/settings/get.sql b/crates/database/queries/settings/get.sql new file mode 100644 index 0000000..313b33b --- /dev/null +++ b/crates/database/queries/settings/get.sql @@ -0,0 +1,9 @@ +select id, + created_at as "created_at: DateTime", + updated_at as "updated_at: DateTime", + business_name, + business_logo + +from settings + +where id is 0 diff --git a/crates/database/queries/settings/init.sql b/crates/database/queries/settings/init.sql new file mode 100644 index 0000000..dd4764f --- /dev/null +++ b/crates/database/queries/settings/init.sql @@ -0,0 +1,2 @@ +insert or ignore into settings(id) +values (0); \ No newline at end of file diff --git a/crates/database/queries/settings/set_business_logo.sql b/crates/database/queries/settings/set_business_logo.sql new file mode 100644 index 0000000..8238312 --- /dev/null +++ b/crates/database/queries/settings/set_business_logo.sql @@ -0,0 +1,5 @@ +update settings + +set business_logo = ? + +where id is 0 diff --git a/crates/database/queries/settings/set_business_name.sql b/crates/database/queries/settings/set_business_name.sql new file mode 100644 index 0000000..66c4df3 --- /dev/null +++ b/crates/database/queries/settings/set_business_name.sql @@ -0,0 +1,5 @@ +update settings + +set business_name = ? + +where id is 0 diff --git a/crates/database/readme.md b/crates/database/readme.md new file mode 100644 index 0000000..1e03a8d --- /dev/null +++ b/crates/database/readme.md @@ -0,0 +1,59 @@ +# sql + +sql interactions for ezidam + +the purpose of this crate is **only** to interact with the database + +## initial setup + +- install https://github.com/casey/just (it's like `make` but simpler) +- install https://github.com/launchbadge/sqlx/tree/main/sqlx-cli (it's the tool to interact with the database) +- install sqlite (if it is not already installed) +- run `just create` to create an empty database + +## tools + +- `just run` to run pending migrations (in case they are not already applied) +- `just reset` to trash the database, create a new one and apply migrations + +## offline + +### save + +the command `just offline` is used to verify if all sql queries are valid. +the result will be placed in `sqlx-data.json` and is used to compile the crate. if this command fails the crate **will +not compile**. + +since this command will have be executed frequently, find a way to run this command when any files are modified inside +this crate. + +- vscode: TODO +- clion: https://www.jetbrains.com/help/clion/using-file-watchers.html + +### check + +the command `just offline_check` is here to verify if offline data is up-to-date. it will return a nonzero exit +status if data is not up-to-date. + +it is mainly used in CI, but you can also use it to determinate if `just offline` is required to run. + +## migrations + +### new + +use `just new ` to create a new SQL migration. the parameter `` should indicate what the migration is doing. + +### fix mistakes + +if there is a mistake in the latest migration, but it has already been applied, use `just revert_last` to revert it. +make your modifications, and run `just run` to apply it again. + +**note**: this works only for the most recent migration, if there is a mistake in an earlier migration, you will have to +reset the whole database with `just reset`. + +consider making a new migration to fix the broken migration, and document it. + +## miscellaneous + +the command `just build_script` will create a rust build script `build.rs` to trigger recompilation when a new migration +is added. this action can be done only at the beginning of the project. diff --git a/crates/database/sqlx-data.json b/crates/database/sqlx-data.json new file mode 100644 index 0000000..859442a --- /dev/null +++ b/crates/database/sqlx-data.json @@ -0,0 +1,75 @@ +{ + "db": "SQLite", + "06cfa74715f3725e99e63aa206f1be5d26cb26924d53dc5a68ee4ea48d6bbbfd": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 1 + } + }, + "query": "update settings\n\nset business_logo = ?\n\nwhere id is 0\n" + }, + "0b60c7829e95dde4145b7f207b64df7006c1fde2faaca0f7952a009d6cda90a3": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 1 + } + }, + "query": "update settings\n\nset business_name = ?\n\nwhere id is 0\n" + }, + "62c75412f673f6a293b0d188d79c50676ec21cf94e2e50e18f9279c91e6b85c8": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 0 + } + }, + "query": "insert or ignore into settings(id)\nvalues (0);" + }, + "cc69514c4d9457462e634eb58cbfc82b454197c5cb7f4a451954eb5a421afc3b": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int64" + }, + { + "name": "created_at: DateTime", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "updated_at: DateTime", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "business_name", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "business_logo", + "ordinal": 4, + "type_info": "Blob" + } + ], + "nullable": [ + false, + false, + false, + true, + true + ], + "parameters": { + "Right": 0 + } + }, + "query": "select id,\n created_at as \"created_at: DateTime\",\n updated_at as \"updated_at: DateTime\",\n business_name,\n business_logo\n\nfrom settings\n\nwhere id is 0\n" + } +} \ No newline at end of file diff --git a/crates/database/src/error.rs b/crates/database/src/error.rs new file mode 100644 index 0000000..b04070a --- /dev/null +++ b/crates/database/src/error.rs @@ -0,0 +1,45 @@ +// error +#[derive(thiserror::Error)] +// the rest +#[derive(Debug)] +pub enum Error { + #[error("Unique constraint on primary key")] + UniqueConstraintPrimaryKey, + + #[error("Unique constraint on key {0}")] + UniqueConstraint(String), + + #[error("{0}")] + Database(#[from] sqlx::Error), +} + +fn parse_unique_constraint(msg: &str) -> Option { + // Format will be "table.column" + let mut constraint = msg.strip_prefix("UNIQUE constraint failed: ")?.split('.'); + + // Extract "column" (which is the second value) + let _table = constraint.next()?; + let column = constraint.next()?; + + Some(column.to_string()) +} + +pub fn handle_error(e: sqlx::Error) -> Error { + match e.as_database_error() { + Some(database_error) => match database_error.code() { + // List of all codes: https://www.sqlite.org/rescode.html + Some(code) => match code.as_ref() { + "1555" => Error::UniqueConstraintPrimaryKey, + + // Attempt to extract unique constraint column + "2067" => match parse_unique_constraint(database_error.message()) { + Some(column) => Error::UniqueConstraint(column), + None => Error::Database(e), + }, + _ => Error::Database(e), + }, + None => Error::Database(e), + }, + None => Error::Database(e), + } +} diff --git a/crates/database/src/lib.rs b/crates/database/src/lib.rs new file mode 100644 index 0000000..9c5d078 --- /dev/null +++ b/crates/database/src/lib.rs @@ -0,0 +1,11 @@ +pub(crate) mod error; +mod tables; + +/// Re-export sqlx +pub use sqlx; + +/// Error +pub use self::error::Error; + +/// Export tables +pub use self::tables::*; diff --git a/crates/database/src/tables/mod.rs b/crates/database/src/tables/mod.rs new file mode 100644 index 0000000..a6fdf4c --- /dev/null +++ b/crates/database/src/tables/mod.rs @@ -0,0 +1,3 @@ +mod settings; + +pub use settings::Settings; diff --git a/crates/database/src/tables/settings.rs b/crates/database/src/tables/settings.rs new file mode 100644 index 0000000..a7a5709 --- /dev/null +++ b/crates/database/src/tables/settings.rs @@ -0,0 +1,57 @@ +use crate::error::{handle_error, Error}; +use sqlx::sqlite::SqliteQueryResult; +use sqlx::types::chrono::{DateTime, Utc}; +use sqlx::{FromRow, SqliteExecutor}; + +#[derive(FromRow)] +pub struct Settings { + pub id: i64, + pub created_at: DateTime, + pub updated_at: DateTime, + pub business_name: Option, + pub business_logo: Option>, +} + +impl Settings { + pub async fn init(conn: impl SqliteExecutor<'_>) -> Result, Error> { + let query: SqliteQueryResult = sqlx::query_file!("queries/settings/init.sql") + .execute(conn) + .await + .map_err(handle_error)?; + + Ok((query.rows_affected() == 1).then_some(())) + } + + pub async fn get(conn: impl SqliteExecutor<'_>) -> Result { + sqlx::query_file_as!(Self, "queries/settings/get.sql") + .fetch_one(conn) + .await + .map_err(handle_error) + } + + pub async fn set_business_name( + conn: impl SqliteExecutor<'_>, + value: Option<&str>, + ) -> Result, Error> { + let query: SqliteQueryResult = + sqlx::query_file!("queries/settings/set_business_name.sql", value) + .execute(conn) + .await + .map_err(handle_error)?; + + Ok((query.rows_affected() == 1).then_some(())) + } + + pub async fn set_business_logo( + conn: impl SqliteExecutor<'_>, + value: Option<&[u8]>, + ) -> Result, Error> { + let query: SqliteQueryResult = + sqlx::query_file!("queries/settings/set_business_logo.sql", value) + .execute(conn) + .await + .map_err(handle_error)?; + + Ok((query.rows_affected() == 1).then_some(())) + } +} diff --git a/crates/database_pool/src/migrations.rs b/crates/database_pool/src/migrations.rs index 7873def..f98677e 100644 --- a/crates/database_pool/src/migrations.rs +++ b/crates/database_pool/src/migrations.rs @@ -2,7 +2,7 @@ use sqlx::migrate::MigrateError; use sqlx::{Pool, Sqlite}; pub async fn run_migrations(pool: &Pool) -> Result<(), MigrateError> { - match sqlx::migrate!("../database").run(pool).await { + match sqlx::migrate!("../database/migrations").run(pool).await { Ok(ok) => { println!("Migrations are OK"); Ok(ok)