refactor: split gpodder repository and the sqlite data store implementation into separate crates

The complete separation of concerns via the gpodder repository allows us
to cleanly separate the server from the gpodder specification. This
paves the way for a later Postgres implementation of the data store.
This commit is contained in:
Jef Roosens 2025-03-19 08:54:49 +01:00
parent 86687a7b96
commit 0cfcd90eba
No known key found for this signature in database
GPG key ID: 21FD3D77D56BAF49
45 changed files with 2416 additions and 882 deletions

View file

@ -0,0 +1,148 @@
use std::{fmt, str::FromStr};
use diesel::{
deserialize::{FromSql, FromSqlRow},
expression::AsExpression,
prelude::*,
serialize::ToSql,
sql_types::Text,
sqlite::{Sqlite, SqliteValue},
};
use crate::schema::*;
#[derive(Clone, Queryable, Selectable)]
#[diesel(table_name = devices)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct Device {
pub id: i64,
pub device_id: String,
pub user_id: i64,
pub caption: String,
pub type_: DeviceType,
pub sync_group_id: Option<i64>,
}
#[derive(Insertable)]
#[diesel(table_name = devices)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct NewDevice {
pub device_id: String,
pub user_id: i64,
pub caption: String,
pub type_: DeviceType,
}
#[derive(FromSqlRow, Debug, AsExpression, Clone)]
#[diesel(sql_type = Text)]
pub enum DeviceType {
Desktop,
Laptop,
Mobile,
Server,
Other,
}
impl Device {
pub fn device_id_to_id(
conn: &mut SqliteConnection,
user_id: i64,
device_id: &str,
) -> diesel::QueryResult<i64> {
devices::table
.select(devices::id)
.filter(
devices::user_id
.eq(user_id)
.and(devices::device_id.eq(device_id)),
)
.get_result(conn)
}
pub fn by_device_id(
conn: &mut SqliteConnection,
user_id: i64,
device_id: &str,
) -> diesel::QueryResult<Self> {
devices::dsl::devices
.select(Self::as_select())
.filter(
devices::user_id
.eq(user_id)
.and(devices::device_id.eq(device_id)),
)
.get_result(conn)
}
}
impl NewDevice {
pub fn new(user_id: i64, device_id: String, caption: String, type_: DeviceType) -> Self {
Self {
device_id,
user_id,
caption,
type_,
}
}
}
impl fmt::Display for DeviceType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}",
match self {
Self::Desktop => "desktop",
Self::Laptop => "laptop",
Self::Mobile => "mobile",
Self::Server => "server",
Self::Other => "other",
}
)
}
}
#[derive(Debug)]
pub struct DeviceTypeParseErr(String);
impl fmt::Display for DeviceTypeParseErr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid device type '{}'", self.0)
}
}
impl std::error::Error for DeviceTypeParseErr {}
impl FromStr for DeviceType {
type Err = DeviceTypeParseErr;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"desktop" => Ok(Self::Desktop),
"laptop" => Ok(Self::Laptop),
"mobile" => Ok(Self::Mobile),
"server" => Ok(Self::Server),
"other" => Ok(Self::Other),
_ => Err(DeviceTypeParseErr(s.to_string())),
}
}
}
impl FromSql<Text, Sqlite> for DeviceType {
fn from_sql(bytes: SqliteValue) -> diesel::deserialize::Result<Self> {
let s = <String as FromSql<Text, Sqlite>>::from_sql(bytes)?;
Ok(s.as_str().parse()?)
}
}
impl ToSql<Text, Sqlite> for DeviceType {
fn to_sql<'b>(
&'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result {
out.set_value(self.to_string());
Ok(diesel::serialize::IsNull::No)
}
}

View file

@ -0,0 +1,24 @@
use diesel::prelude::*;
use crate::schema::*;
#[derive(Clone, Queryable, Selectable)]
#[diesel(table_name = device_subscriptions)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct DeviceSubscription {
pub id: i64,
pub device_id: i64,
pub podcast_url: String,
pub time_changed: i64,
pub deleted: bool,
}
#[derive(Insertable)]
#[diesel(table_name = device_subscriptions)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct NewDeviceSubscription {
pub device_id: i64,
pub podcast_url: String,
pub time_changed: i64,
pub deleted: bool,
}

View file

@ -0,0 +1,114 @@
use std::{fmt, str::FromStr};
use diesel::{
deserialize::{FromSql, FromSqlRow},
expression::AsExpression,
prelude::{Insertable, Queryable},
serialize::ToSql,
sql_types::Text,
sqlite::{Sqlite, SqliteValue},
Selectable,
};
use crate::schema::*;
#[derive(Clone, Queryable, Selectable)]
#[diesel(table_name = episode_actions)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct EpisodeAction {
pub id: i64,
pub user_id: i64,
pub device_id: Option<i64>,
pub podcast_url: String,
pub episode_url: String,
pub time_changed: i64,
pub timestamp: Option<i64>,
pub action: ActionType,
pub started: Option<i32>,
pub position: Option<i32>,
pub total: Option<i32>,
}
#[derive(Insertable)]
#[diesel(table_name = episode_actions)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct NewEpisodeAction {
pub user_id: i64,
pub device_id: Option<i64>,
pub podcast_url: String,
pub episode_url: String,
pub time_changed: i64,
pub timestamp: Option<i64>,
pub action: ActionType,
pub started: Option<i32>,
pub position: Option<i32>,
pub total: Option<i32>,
}
#[derive(FromSqlRow, Debug, AsExpression, Clone)]
#[diesel(sql_type = Text)]
pub enum ActionType {
New,
Download,
Play,
Delete,
}
impl fmt::Display for ActionType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}",
match self {
Self::New => "new",
Self::Download => "download",
Self::Play => "play",
Self::Delete => "delete",
}
)
}
}
#[derive(Debug)]
pub struct ActionTypeParseErr(String);
impl fmt::Display for ActionTypeParseErr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid action type '{}'", self.0)
}
}
impl std::error::Error for ActionTypeParseErr {}
impl FromStr for ActionType {
type Err = ActionTypeParseErr;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"new" => Ok(Self::New),
"download" => Ok(Self::Download),
"delete" => Ok(Self::Delete),
"play" => Ok(Self::Play),
_ => Err(ActionTypeParseErr(s.to_string())),
}
}
}
impl FromSql<Text, Sqlite> for ActionType {
fn from_sql(bytes: SqliteValue) -> diesel::deserialize::Result<Self> {
let s = <String as FromSql<Text, Sqlite>>::from_sql(bytes)?;
Ok(s.as_str().parse()?)
}
}
impl ToSql<Text, Sqlite> for ActionType {
fn to_sql<'b>(
&'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result {
out.set_value(self.to_string());
Ok(diesel::serialize::IsNull::No)
}
}

View file

@ -0,0 +1,6 @@
pub mod device;
pub mod device_subscription;
pub mod episode_action;
pub mod session;
pub mod sync_group;
pub mod user;

View file

@ -0,0 +1,60 @@
use diesel::prelude::*;
use crate::schema::*;
#[derive(Clone, Queryable, Selectable, Insertable, Associations)]
#[diesel(belongs_to(super::user::User))]
#[diesel(table_name = sessions)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct Session {
pub id: i64,
pub user_id: i64,
pub last_seen: i64,
}
impl Session {
// pub fn new_for_user(pool: &DbPool, user_id: i64, last_seen: i64) -> DbResult<Self> {
// let id: i64 = rand::thread_rng().gen();
// Ok(Self {
// id,
// user_id,
// last_seen,
// }
// .insert_into(sessions::table)
// .returning(Self::as_returning())
// .get_result(&mut pool.get()?)?)
// }
// pub fn user_from_id(pool: &DbPool, id: i64) -> DbResult<Option<super::user::User>> {
// Ok(sessions::dsl::sessions
// .inner_join(users::table)
// .filter(sessions::id.eq(id))
// .select(User::as_select())
// .get_result(&mut pool.get()?)
// .optional()?)
// }
// pub fn user(&self, pool: &DbPool) -> DbResult<Option<super::user::User>> {
// Self::user_from_id(pool, self.id)
// }
// pub fn by_id(pool: &DbPool, id: i64) -> DbResult<Option<Self>> {
// Ok(sessions::dsl::sessions
// .find(id)
// .get_result(&mut pool.get()?)
// .optional()?)
// }
// pub fn remove(self, pool: &DbPool) -> DbResult<bool> {
// Self::remove_by_id(pool, self.id)
// }
// pub fn remove_by_id(pool: &DbPool, id: i64) -> DbResult<bool> {
// Ok(
// diesel::delete(sessions::dsl::sessions.filter(sessions::id.eq(id)))
// .execute(&mut pool.get()?)?
// > 0,
// )
// }
}

View file

@ -0,0 +1,33 @@
use diesel::{
dsl::{exists, not},
prelude::*,
};
use crate::schema::*;
#[derive(Queryable, Selectable)]
#[diesel(table_name = sync_groups)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct SyncGroup {
pub id: i64,
}
impl SyncGroup {
pub fn new(conn: &mut SqliteConnection) -> QueryResult<Self> {
diesel::insert_into(sync_groups::table)
.default_values()
.returning(SyncGroup::as_returning())
.get_result(conn)
}
pub fn remove_unused(conn: &mut SqliteConnection) -> QueryResult<usize> {
diesel::delete(
sync_groups::table.filter(not(exists(
devices::table
.select(1.into_sql::<diesel::sql_types::Integer>())
.filter(devices::sync_group_id.eq(sync_groups::id.nullable())),
))),
)
.execute(conn)
}
}

View file

@ -0,0 +1,47 @@
use diesel::prelude::*;
use crate::schema::*;
#[derive(Clone, Queryable, Selectable)]
#[diesel(table_name = users)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct User {
pub id: i64,
pub username: String,
pub password_hash: String,
}
#[derive(Insertable)]
#[diesel(table_name = users)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct NewUser {
pub username: String,
pub password_hash: String,
}
// impl NewUser {
// pub fn new(username: String, password: String) -> Self {
// Self {
// username,
// password_hash: hash_password(&password),
// }
// }
// }
// impl User {
// pub fn by_username(pool: &DbPool, username: impl AsRef<str>) -> DbResult<Option<Self>> {
// Ok(users::dsl::users
// .select(User::as_select())
// .filter(users::username.eq(username.as_ref()))
// .first(&mut pool.get()?)
// .optional()?)
// }
// pub fn verify_password(&self, password: impl AsRef<str>) -> bool {
// let password_hash = PasswordHash::new(&self.password_hash).unwrap();
// Argon2::default()
// .verify_password(password.as_ref().as_bytes(), &password_hash)
// .is_ok()
// }
// }