1
0

Database User & Session repository

This commit is contained in:
2025-01-29 15:13:08 +02:00
commit 0e4b219f97
13 changed files with 5904 additions and 0 deletions

3
3/coursework/src/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/target/
/**/scrapyard/
/database/data/

5354
3/coursework/src/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
[workspace]
resolver = "2"
members = ["app/*", "libs/*", "database"]

View File

@ -0,0 +1,19 @@
# Stuff that helped:
* Architecture:
- [How to apply hexagonal architecture to Rust](https://www.barrage.net/blog/technology/how-to-apply-hexagonal-architecture-to-rust)
- [Implementing onion architecture using Rust](https://mathias-vandaele.dev/implementing-onion-architecture-using-rust)
* Design:
- [Rust Data Modelling Without Classes](https://www.youtube.com/watch?v=z-0-bbc80JM)
- ["Making Impossible States Impossible" by Richard Feldman](https://www.youtube.com/watch?v=IcgmSRJHu_8)
- [Pretty State Machine Patterns in Rust](https://hoverbear.org/blog/rust-state-machine-pattern/)
* How to Iced:
- [Building a simple text editor with iced, a cross-platform GUI library for Rust](https://www.youtube.com/watch?v=gcBJ7cPSALo)
- [Unofficial Iced Guide](https://jl710.github.io/iced-guide/)
- [icebreaker](https://github.com/hecrj/icebreaker)
---
> _The purpose of abstraction is not to be vague, but to create a new semantic level in which one can be absolutely precise._
— Edsger W. Dijkstra

View File

@ -0,0 +1,20 @@
[package]
name = "database"
version = "0.1.0"
edition = "2024"
[dependencies]
thiserror = "2.0.11"
derive_more = { version = "1.0.0", features = ["deref", "deref_mut", "from", "into"] }
garde = { version = "0.22.0", features = ["email", "url", "derive"] }
chrono = { version = "0.4.39", default-features = false, features = [
"std",
"now",
] }
sqlx = { version = "0.8.3", default-features = false, features = [
"mysql",
"macros",
"chrono",
"runtime-tokio",
] }

View File

@ -0,0 +1,11 @@
services:
database:
image: mysql:9.0
restart: unless-stopped
ports:
- "127.0.0.1:3306:3306"
volumes:
- ./data:/var/lib/mysql
- ./init:/docker-entrypoint-initdb.d/:ro
environment:
MYSQL_ROOT_PASSWORD: password # yes, I know

View File

@ -0,0 +1,177 @@
-- DROP DATABASE IF EXISTS repository;
CREATE DATABASE repository;
USE repository;
-- Required info for an account
CREATE TABLE Users ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(31) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
last_used TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
-- Enables multiple packages to have the same base yet different components
CREATE TABLE PackageBases ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(127) UNIQUE NOT NULL,
description VARCHAR(510) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL
);
-- User roles for working on packages: flagger, packager, submitter, maintainer, etc.
CREATE TABLE PackageBaseRoles ( id TINYINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(31) UNIQUE NOT NULL,
description VARCHAR(255) NULL
);
-- Roles that a user has for a package
CREATE TABLE PackageBaseUserRoles (
base_id INT UNSIGNED,
user_id INT UNSIGNED,
role_id TINYINT UNSIGNED,
comment VARCHAR(255) NULL,
PRIMARY KEY (base_id, user_id, role_id), -- composite key
FOREIGN KEY (base_id) REFERENCES PackageBases(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES Users(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES PackageBaseRoles(id) ON DELETE CASCADE
);
-- Information about the actual packages
CREATE TABLE Packages ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
package_base INT UNSIGNED NOT NULL,
name VARCHAR(127) UNIQUE NOT NULL,
version VARCHAR(127) NOT NULL,
description VARCHAR(255) NULL,
url VARCHAR(510) NULL,
flagged_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (package_base) REFERENCES PackageBases (id) ON DELETE CASCADE
);
-- depends, makedepends, optdepends, etc.
CREATE TABLE DependencyTypes ( id TINYINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(31) UNIQUE NOT NULL
);
INSERT INTO DependencyTypes (id, name) VALUES
(1, 'depends'),
(2, 'makedepends'),
(3, 'checkdepends'),
(4, 'optdepends');
-- Track which dependencies a package has
CREATE TABLE PackageDependencies ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
arch VARCHAR(63) NULL,
condition VARCHAR(255) NULL,
description VARCHAR(127) NULL,
package INT UNSIGNED NOT NULL,
dependency_type TINYINT UNSIGNED NOT NULL,
dependency_package_name VARCHAR(127) NOT NULL, -- Not an actual package, but an an alias. Allows for package substitution.
FOREIGN KEY (package) REFERENCES Packages (id) ON DELETE CASCADE,
FOREIGN KEY (dependency_type) REFERENCES DependencyTypes (id)
);
-- conflicts, provides, replaces, etc.
CREATE TABLE RelationTypes ( id TINYINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(31) UNIQUE NOT NULL
);
INSERT INTO RelationTypes (id, name) VALUES
(1, 'conflicts'),
(2, 'provides'),
(3, 'replaces');
-- Track which conflicts, provides and replaces a package has
CREATE TABLE PackageRelations ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
arch VARCHAR(63) NULL,
condition VARCHAR(255) NULL,
package INT UNSIGNED NOT NULL,
relation_type TINYINT UNSIGNED NOT NULL,
relation_package_name VARCHAR(127) NOT NULL,
FOREIGN KEY (package) REFERENCES Packages (id) ON DELETE CASCADE,
FOREIGN KEY (relation_type) REFERENCES RelationTypes (id)
);
-- Public user profile
/* CREATE TABLE UserProfiles ( user_id INT UNSIGNED PRIMARY KEY,
real_name VARCHAR(63) NULL,
homepage TEXT NULL, -- bio / description / whatever
irc_nick VARCHAR(31) NULL,
pgp_key CHAR(40) NULL,
language VARCHAR(31) NULL, -- only for display
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (user_id) REFERENCES Users(id) ON DELETE CASCADE
); */
-- Settings for the User
/* CREATE TABLE UserPreferences ( user_id INT UNSIGNED PRIMARY KEY,
inactive BOOLEAN DEFAULT 0 NOT NULL, -- user is no longer active
show_email BOOLEAN DEFAULT 0 NOT NULL, -- on public profile page
utc_timezone TINYINT DEFAULT 0 NOT NULL, -- adjust timestamps shown
backup_email VARCHAR(127) NULL, -- to restore the account
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (user_id) REFERENCES Users(id) ON DELETE CASCADE
); */
-- Levels of access to the repository
/* CREATE TABLE AccessRoles ( id TINYINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(31) UNIQUE NOT NULL,
description VARCHAR(255) NULL
); */
-- Roles that a user has
/* CREATE TABLE UserAccessRoles (
user_id INT UNSIGNED,
role_id TINYINT UNSIGNED,
PRIMARY KEY (user_id, role_id), -- composite key
FOREIGN KEY (user_id) REFERENCES Users(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES AccessRoles(id) ON DELETE CASCADE
); */
-- Votes
/* CREATE TABLE PackageBaseUserVotes (
package_base INT UNSIGNED,
user INT UNSIGNED,
score TINYINT UNSIGNED DEFAULT 0 NOT NULL CHECK (score <= 10),
comment VARCHAR(255) NULL,
log TEXT NULL, -- error logs, etc.
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
PRIMARY KEY (package_base, user), -- composite key
FOREIGN KEY (package_base) REFERENCES PackageBases (id) ON DELETE CASCADE,
FOREIGN KEY (user) REFERENCES Users (id) ON DELETE CASCADE
); */
-- Information about licenses
/* CREATE TABLE Licenses ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(127) UNIQUE NOT NULL,
description TEXT NULL
); */
-- Information about licenses
/* CREATE TABLE PackageLicenses (
package INT UNSIGNED,
license INT UNSIGNED,
PRIMARY KEY (package, license), -- composite key
FOREIGN KEY (package) REFERENCES Packages (id) ON DELETE CASCADE,
FOREIGN KEY (license) REFERENCES Licenses (id) ON DELETE CASCADE
); */

View File

@ -0,0 +1,40 @@
type Result<T = ()> = std::result::Result<T, Box<dyn std::error::Error>>;
#[allow(async_fn_in_trait)]
pub trait Atomic {
type Transaction<'a>;
async fn start_transaction(&mut self) -> Result<Self::Transaction<'_>>;
async fn abort_transaction(transaction: Self::Transaction<'_>) -> Result;
async fn commit_transaction(transaction: Self::Transaction<'_>) -> Result;
}
use sqlx::Connection;
impl Atomic for sqlx::MySqlPool {
type Transaction<'a> = sqlx::MySqlTransaction<'a>;
async fn start_transaction(&mut self) -> Result<Self::Transaction<'_>> {
self.begin().await.map_err(Box::from)
}
async fn abort_transaction(transaction: Self::Transaction<'_>) -> Result {
transaction.rollback().await.map_err(Box::from)
}
async fn commit_transaction(transaction: Self::Transaction<'_>) -> Result {
transaction.commit().await.map_err(Box::from)
}
}
impl Atomic for sqlx::MySqlConnection {
type Transaction<'a> = sqlx::MySqlTransaction<'a>;
async fn start_transaction(&mut self) -> Result<Self::Transaction<'_>> {
self.begin().await.map_err(Box::from)
}
async fn abort_transaction(transaction: Self::Transaction<'_>) -> Result {
transaction.rollback().await.map_err(Box::from)
}
async fn commit_transaction(transaction: Self::Transaction<'_>) -> Result {
transaction.commit().await.map_err(Box::from)
}
}

View File

@ -0,0 +1,44 @@
type Result<T = ()> = std::result::Result<T, Box<dyn std::error::Error>>;
#[allow(async_fn_in_trait)]
pub trait Connect {
type Connection;
async fn open_connection(&self) -> Result<Self::Connection>;
async fn close_connection(connection: Self::Connection) -> Result;
}
use sqlx::Connection;
#[derive(Clone)]
pub struct MySqlPool {
pool: sqlx::MySqlPool,
}
impl Connect for MySqlPool {
type Connection = sqlx::MySqlPool;
async fn open_connection(&self) -> Result<Self::Connection> {
Ok(self.pool.clone())
}
async fn close_connection(_: Self::Connection) -> Result {
Ok(())
}
}
pub struct MySqlConnection {
link: String,
}
impl Connect for MySqlConnection {
type Connection = sqlx::MySqlConnection;
async fn open_connection(&self) -> Result<Self::Connection> {
sqlx::MySqlConnection::connect(&self.link)
.await
.map_err(Box::from)
}
async fn close_connection(connection: Self::Connection) -> Result {
connection.close().await?;
Ok(())
}
}

View File

@ -0,0 +1,16 @@
use garde::{Report, Unvalidated, Valid, Validate};
pub trait IntoValid: Validate {
fn into_valid(self) -> Result<Valid<Self>, Report>
where
Self: Sized,
<Self as Validate>::Context: Default,
{
Unvalidated::new(self).validate()
}
}
impl<T: garde::Validate> IntoValid for T {}
pub mod atomic;
pub mod connect;
pub mod repository;

View File

@ -0,0 +1,2 @@
pub mod session;
pub mod user;

View File

@ -0,0 +1,51 @@
use super::user::User;
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
use chrono::{DateTime, Utc};
use derive_more::{Deref, DerefMut};
use sqlx::{Executor, MySql};
#[allow(async_fn_in_trait)]
pub trait SessionRepository<C> {
async fn start(connection: &mut C, user: User) -> Result<Session>;
async fn end(connection: &mut C, session: Session) -> Result<User>;
}
#[derive(DerefMut, Deref)]
pub struct Session {
#[deref]
#[deref_mut]
user: User,
start: DateTime<Utc>,
}
impl Session {
pub const fn start(&self) -> DateTime<Utc> {
self.start
}
}
#[derive(Debug)]
pub struct SessionAdapter;
impl<E> SessionRepository<E> for SessionAdapter
where
for<'a> &'a E: Executor<'a, Database = MySql>,
{
async fn start(connection: &mut E, user: User) -> Result<Session> {
let start = Utc::now();
sqlx::query!(
"UPDATE Users SET last_used = ? WHERE id = ?",
start,
*user.id()
)
.execute(&*connection)
.await?;
Ok(Session { user, start })
}
async fn end(_: &mut E, session: Session) -> Result<User> {
Ok(session.user)
}
}

View File

@ -0,0 +1,164 @@
use derive_more::{Deref, From, Into};
use garde::{Valid, Validate};
use sqlx::{Executor, MySql};
pub type Result<T = ()> = std::result::Result<T, Box<dyn std::error::Error>>;
#[allow(async_fn_in_trait)]
pub trait UserRepository<C> {
async fn get_by_id(connection: &C, id: Id) -> Result<Option<User>>;
async fn get_by_name(connection: &C, name: &Valid<Username>) -> Result<Option<User>>;
async fn get_by_email(connection: &C, email: &Valid<Email>) -> Result<Option<User>>;
async fn change_name(connection: &mut C, user: &mut User, name: Valid<Username>) -> Result;
async fn change_email(connection: &mut C, user: &mut User, email: Valid<Email>) -> Result;
async fn change_password(
connection: &mut C,
user: &mut User,
password: Valid<Password>,
) -> Result;
async fn create(connection: &mut C, user: Valid<UserData>) -> Result<User>;
}
#[derive(Deref, Into, Clone, Copy)]
pub struct Id(u32);
#[derive(Validate, Deref)]
#[garde(transparent)]
pub struct Username(#[garde(alphanumeric, length(min = 2, max = 31))] pub String);
#[derive(Validate, Deref)]
#[garde(transparent)]
pub struct Email(#[garde(email, length(max = 255))] pub String);
#[derive(Validate, Deref)]
#[garde(transparent)]
pub struct Password(#[garde(ascii, length(max = 255))] pub String);
#[derive(Validate)]
pub struct UserData {
#[garde(dive)]
pub name: Username,
#[garde(dive)]
pub email: Email,
#[garde(dive)]
pub password: Password,
}
#[derive(Deref)]
pub struct User {
id: Id,
#[deref]
data: UserData,
}
impl User {
pub const fn id(&self) -> Id {
self.id
}
}
pub struct UserAdapter;
struct QueryUser {
id: u32,
name: String,
email: String,
password: String,
}
impl From<QueryUser> for User {
fn from(value: QueryUser) -> Self {
Self {
id: Id(value.id),
data: UserData {
name: Username(value.name),
email: Email(value.email),
password: Password(value.password),
},
}
}
}
impl<E> UserRepository<E> for UserAdapter
where
for<'a> &'a E: Executor<'a, Database = MySql>,
{
async fn get_by_id(connection: &E, id: Id) -> Result<Option<User>> {
Ok(sqlx::query_as!(
QueryUser,
"SELECT id, name, email, password FROM Users WHERE id = ?",
id.0
)
.fetch_optional(connection)
.await?
.map(Into::into))
}
async fn get_by_name(connection: &E, name: &Valid<Username>) -> Result<Option<User>> {
Ok(sqlx::query_as!(
QueryUser,
"SELECT id, name, email, password FROM Users WHERE name = ?",
name.0
)
.fetch_optional(connection)
.await?
.map(Into::into))
}
async fn get_by_email(connection: &E, email: &Valid<Email>) -> Result<Option<User>> {
Ok(sqlx::query_as!(
QueryUser,
"SELECT id, name, email, password FROM Users WHERE email = ?",
email.0
)
.fetch_optional(connection)
.await?
.map(Into::into))
}
async fn change_name(connection: &mut E, user: &mut User, name: Valid<Username>) -> Result {
sqlx::query!("UPDATE Users SET name = ? WHERE id = ?", name.0, user.id.0)
.execute(&*connection)
.await?;
Ok(())
}
async fn change_email(connection: &mut E, user: &mut User, email: Valid<Email>) -> Result {
sqlx::query!(
"UPDATE Users SET email = ? WHERE id = ?",
email.0,
user.id.0
)
.execute(&*connection)
.await?;
Ok(())
}
async fn change_password(
connection: &mut E,
user: &mut User,
password: Valid<Password>,
) -> Result {
sqlx::query!(
"UPDATE Users SET password = ? WHERE id = ?",
password.0,
user.id.0
)
.execute(&*connection)
.await?;
Ok(())
}
async fn create(connection: &mut E, user: Valid<UserData>) -> Result<User> {
let id = sqlx::query!(
"INSERT INTO Users (name, email, password) VALUES (?, ?, ?)",
user.name.0,
user.email.0,
user.password.0
)
.execute(&*connection)
.await?
.last_insert_id() as u32;
Ok(User {
id: Id(id),
data: user.into_inner(),
})
}
}