#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
#![allow(clippy::multiple_crate_versions)]
use switchy_database::DatabaseError;
use switchy_schema::{
MigrationError as SwitchyMigrationError, discovery::code::CodeMigrationSource,
};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum MigrateError {
#[error(transparent)]
Database(#[from] DatabaseError),
#[error(transparent)]
Schema(#[from] SwitchyMigrationError),
}
#[cfg(any(feature = "postgres", feature = "sqlite"))]
#[must_use]
fn should_skip_migrations() -> bool {
switchy_env::var("MOOSICBOX_SKIP_MIGRATION_EXECUTION")
.as_deref()
.unwrap_or("0")
== "1"
}
#[cfg(any(feature = "postgres", feature = "sqlite"))]
#[must_use]
fn should_drop_migrations_table() -> bool {
switchy_env::var("MOOSICBOX_DROP_MIGRATIONS_TABLE")
.as_deref()
.unwrap_or("0")
== "1"
}
#[cfg(feature = "postgres")]
pub async fn migrate_config_postgres(
db: &dyn switchy_database::Database,
) -> Result<(), MigrateError> {
log::debug!("migrate_config_postgres: running postgres migrations");
let source = postgres_config_migrations();
let runner = switchy_schema::runner::MigrationRunner::new(Box::new(source))
.with_table_name("__moosicbox_schema_migrations");
if should_drop_migrations_table() {
log::info!("migrate_config_postgres: dropping postgres migration table");
runner.drop_tracking_table(db).await?;
}
if should_skip_migrations() {
log::info!(
"migrate_config_postgres: populating postgres migration table without execution due to MOOSICBOX_SKIP_MIGRATION_EXECUTION"
);
let summary = runner
.mark_all_migrations_completed(db, switchy_schema::MarkCompletedScope::PendingOnly)
.await?;
log::info!(
"migrate_config_postgres: marked {} migrations as completed ({} newly marked, {} failed skipped, {} in-progress skipped)",
summary.total,
summary.newly_marked,
summary.failed_skipped,
summary.in_progress_skipped
);
} else {
runner.run(db).await?;
}
log::debug!("migrate_config_postgres: finished running postgres migrations");
Ok(())
}
#[cfg(feature = "sqlite")]
pub async fn migrate_config_sqlite(
db: &dyn switchy_database::Database,
) -> Result<(), MigrateError> {
log::debug!("migrate_config_sqlite: running sqlite migrations");
let source = sqlite_config_migrations();
let runner = switchy_schema::runner::MigrationRunner::new(Box::new(source))
.with_table_name("__moosicbox_schema_migrations");
if should_drop_migrations_table() {
log::info!("migrate_config_sqlite: dropping sqlite migration table");
runner.drop_tracking_table(db).await?;
}
if should_skip_migrations() {
log::info!(
"migrate_config_sqlite: populating sqlite migration table without execution due to MOOSICBOX_SKIP_MIGRATION_EXECUTION"
);
let summary = runner
.mark_all_migrations_completed(db, switchy_schema::MarkCompletedScope::PendingOnly)
.await?;
log::info!(
"migrate_config_sqlite: marked {} migrations as completed ({} newly marked, {} failed skipped, {} in-progress skipped)",
summary.total,
summary.newly_marked,
summary.failed_skipped,
summary.in_progress_skipped
);
} else {
runner.run(db).await?;
}
log::debug!("migrate_config_sqlite: finished running sqlite migrations");
Ok(())
}
#[cfg(any(feature = "postgres", feature = "sqlite"))]
pub async fn migrate_config(db: &dyn switchy_database::Database) -> Result<(), MigrateError> {
#[cfg(feature = "postgres")]
{
if let Err(e) = migrate_config_postgres(db).await {
log::warn!("migrate_config: postgres migrations failed, continuing: {e:?}");
}
}
#[cfg(feature = "sqlite")]
{
migrate_config_sqlite(db).await?;
}
Ok(())
}
#[cfg(feature = "postgres")]
pub async fn migrate_library_postgres(
db: &dyn switchy_database::Database,
) -> Result<(), MigrateError> {
migrate_library_postgres_until(db, None).await
}
#[cfg(feature = "postgres")]
pub async fn migrate_library_postgres_until(
db: &dyn switchy_database::Database,
migration_name: Option<&str>,
) -> Result<(), MigrateError> {
log::debug!("migrate_library_postgres: running postgres migrations");
let source = postgres_library_migrations();
let runner = switchy_schema::runner::MigrationRunner::new(Box::new(source))
.with_table_name("__moosicbox_schema_migrations");
if should_drop_migrations_table() {
log::info!("migrate_library_postgres_until: dropping postgres migration table");
runner.drop_tracking_table(db).await?;
}
if should_skip_migrations() {
log::info!(
"migrate_library_postgres: populating postgres migration table without execution due to MOOSICBOX_SKIP_MIGRATION_EXECUTION"
);
let summary = runner
.mark_all_migrations_completed(db, switchy_schema::MarkCompletedScope::PendingOnly)
.await?;
log::info!(
"migrate_library_postgres: marked {} migrations as completed ({} newly marked, {} failed skipped, {} in-progress skipped)",
summary.total,
summary.newly_marked,
summary.failed_skipped,
summary.in_progress_skipped
);
} else {
let runner = if let Some(migration_name) = migration_name {
runner.with_strategy(switchy_schema::runner::ExecutionStrategy::UpTo(
migration_name.to_string(),
))
} else {
runner.with_strategy(switchy_schema::runner::ExecutionStrategy::All)
};
runner.run(db).await?;
}
log::debug!("migrate_library_postgres: finished running postgres migrations");
Ok(())
}
#[cfg(feature = "sqlite")]
pub async fn migrate_library_sqlite(
db: &dyn switchy_database::Database,
) -> Result<(), MigrateError> {
migrate_library_sqlite_until(db, None).await
}
#[cfg(feature = "sqlite")]
pub async fn migrate_library_sqlite_until(
db: &dyn switchy_database::Database,
migration_name: Option<&str>,
) -> Result<(), MigrateError> {
log::debug!("migrate_library_sqlite: running sqlite migrations");
let source = sqlite_library_migrations();
let runner = switchy_schema::runner::MigrationRunner::new(Box::new(source))
.with_table_name("__moosicbox_schema_migrations");
if should_drop_migrations_table() {
log::info!("migrate_library_sqlite_until: dropping sqlite migration table");
runner.drop_tracking_table(db).await?;
}
if should_skip_migrations() {
log::info!(
"migrate_library_sqlite: populating sqlite migration table without execution due to MOOSICBOX_SKIP_MIGRATION_EXECUTION"
);
let summary = runner
.mark_all_migrations_completed(db, switchy_schema::MarkCompletedScope::PendingOnly)
.await?;
log::info!(
"migrate_library_sqlite: marked {} migrations as completed ({} newly marked, {} failed skipped, {} in-progress skipped)",
summary.total,
summary.newly_marked,
summary.failed_skipped,
summary.in_progress_skipped
);
} else {
let runner = if let Some(migration_name) = migration_name {
runner.with_strategy(switchy_schema::runner::ExecutionStrategy::UpTo(
migration_name.to_string(),
))
} else {
runner.with_strategy(switchy_schema::runner::ExecutionStrategy::All)
};
runner.run(db).await?;
}
log::debug!("migrate_library_sqlite: finished running sqlite migrations");
Ok(())
}
#[cfg(any(feature = "postgres", feature = "sqlite"))]
pub async fn migrate_library(db: &dyn switchy_database::Database) -> Result<(), MigrateError> {
migrate_library_until(db, None).await
}
#[cfg(any(feature = "postgres", feature = "sqlite"))]
pub async fn migrate_library_until(
db: &dyn switchy_database::Database,
migration_name: Option<&str>,
) -> Result<(), MigrateError> {
#[cfg(feature = "postgres")]
{
if let Err(e) = migrate_library_postgres_until(db, migration_name).await {
log::warn!("migrate_library: postgres migrations failed, continuing: {e:?}");
}
}
#[cfg(feature = "sqlite")]
{
migrate_library_sqlite_until(db, migration_name).await?;
}
Ok(())
}
#[cfg(feature = "sqlite")]
static SQLITE_CONFIG_MIGRATIONS_DIR: include_dir::Dir =
include_dir::include_dir!("$CARGO_MANIFEST_DIR/migrations/server/config/sqlite");
#[cfg(feature = "sqlite")]
static SQLITE_LIBRARY_MIGRATIONS_DIR: include_dir::Dir =
include_dir::include_dir!("$CARGO_MANIFEST_DIR/migrations/server/library/sqlite");
#[cfg(feature = "postgres")]
static POSTGRES_CONFIG_MIGRATIONS_DIR: include_dir::Dir =
include_dir::include_dir!("$CARGO_MANIFEST_DIR/migrations/server/config/postgres");
#[cfg(feature = "postgres")]
static POSTGRES_LIBRARY_MIGRATIONS_DIR: include_dir::Dir =
include_dir::include_dir!("$CARGO_MANIFEST_DIR/migrations/server/library/postgres");
#[cfg(any(feature = "sqlite", feature = "postgres"))]
#[must_use]
fn load_migrations_from_dir(dir: &include_dir::Dir) -> CodeMigrationSource<'static> {
let mut source = CodeMigrationSource::new();
let mut migration_dirs: Vec<_> = dir
.entries()
.iter()
.filter_map(|entry| entry.as_dir())
.collect();
migration_dirs.sort_by(|a, b| a.path().file_name().cmp(&b.path().file_name()));
for migration_dir in migration_dirs {
if let Some(dir_name) = migration_dir.path().file_name().and_then(|n| n.to_str()) {
let mut up_sql_content = None;
let mut down_sql_content = None;
for file in migration_dir.files() {
if let Some(file_name) = file.path().file_name().and_then(|n| n.to_str()) {
if file_name == "up.sql" {
up_sql_content = file.contents_utf8();
} else if file_name == "down.sql" {
down_sql_content = file.contents_utf8();
}
}
}
if let Some(up_sql) = up_sql_content {
source.add_migration(switchy_schema::discovery::code::CodeMigration::new(
dir_name.to_string(),
Box::new(up_sql.to_string()) as Box<dyn switchy_database::Executable>,
down_sql_content
.map(|s| Box::new(s.to_string()) as Box<dyn switchy_database::Executable>),
));
}
}
}
source
}
#[cfg(feature = "sqlite")]
#[must_use]
fn sqlite_config_migrations() -> CodeMigrationSource<'static> {
load_migrations_from_dir(&SQLITE_CONFIG_MIGRATIONS_DIR)
}
#[cfg(feature = "sqlite")]
#[must_use]
fn sqlite_library_migrations() -> CodeMigrationSource<'static> {
load_migrations_from_dir(&SQLITE_LIBRARY_MIGRATIONS_DIR)
}
#[cfg(feature = "postgres")]
#[must_use]
fn postgres_config_migrations() -> CodeMigrationSource<'static> {
load_migrations_from_dir(&POSTGRES_CONFIG_MIGRATIONS_DIR)
}
#[cfg(feature = "postgres")]
#[must_use]
fn postgres_library_migrations() -> CodeMigrationSource<'static> {
load_migrations_from_dir(&POSTGRES_LIBRARY_MIGRATIONS_DIR)
}
#[cfg(feature = "sqlite")]
pub async fn get_sqlite_library_migrations() -> Result<
Vec<std::sync::Arc<dyn switchy_schema::migration::Migration<'static> + 'static>>,
MigrateError,
> {
use switchy_schema::migration::MigrationSource;
let source = sqlite_library_migrations();
Ok(source.migrations().await?)
}
#[cfg(feature = "sqlite")]
pub async fn get_sqlite_config_migrations() -> Result<
Vec<std::sync::Arc<dyn switchy_schema::migration::Migration<'static> + 'static>>,
MigrateError,
> {
use switchy_schema::migration::MigrationSource;
let source = sqlite_config_migrations();
Ok(source.migrations().await?)
}
#[cfg(feature = "postgres")]
pub async fn get_postgres_library_migrations() -> Result<
Vec<std::sync::Arc<dyn switchy_schema::migration::Migration<'static> + 'static>>,
MigrateError,
> {
use switchy_schema::migration::MigrationSource;
let source = postgres_library_migrations();
Ok(source.migrations().await?)
}
#[cfg(feature = "postgres")]
pub async fn get_postgres_config_migrations() -> Result<
Vec<std::sync::Arc<dyn switchy_schema::migration::Migration<'static> + 'static>>,
MigrateError,
> {
use switchy_schema::migration::MigrationSource;
let source = postgres_config_migrations();
Ok(source.migrations().await?)
}
#[cfg(not(feature = "sqlite"))]
#[allow(unused, clippy::missing_const_for_fn)]
#[must_use]
fn sqlite_config_migrations() -> CodeMigrationSource<'static> {
CodeMigrationSource::new()
}
#[cfg(not(feature = "sqlite"))]
#[allow(unused, clippy::missing_const_for_fn)]
#[must_use]
fn sqlite_library_migrations() -> CodeMigrationSource<'static> {
CodeMigrationSource::new()
}
#[cfg(not(feature = "postgres"))]
#[allow(unused, clippy::missing_const_for_fn)]
#[must_use]
fn postgres_config_migrations() -> CodeMigrationSource<'static> {
CodeMigrationSource::new()
}
#[cfg(not(feature = "postgres"))]
#[allow(unused, clippy::missing_const_for_fn)]
#[must_use]
fn postgres_library_migrations() -> CodeMigrationSource<'static> {
CodeMigrationSource::new()
}
#[cfg(test)]
mod tests {
#[cfg(any(feature = "postgres", feature = "sqlite"))]
use super::*;
#[cfg(any(feature = "postgres", feature = "sqlite"))]
mod env_var_tests {
use super::*;
use serial_test::serial;
use switchy_env::simulator::{remove_var, set_var};
#[test_log::test]
#[serial]
fn test_should_skip_migrations_returns_true_when_set_to_one() {
set_var("MOOSICBOX_SKIP_MIGRATION_EXECUTION", "1");
assert!(should_skip_migrations());
remove_var("MOOSICBOX_SKIP_MIGRATION_EXECUTION");
}
#[test_log::test]
#[serial]
fn test_should_skip_migrations_returns_false_when_set_to_zero() {
set_var("MOOSICBOX_SKIP_MIGRATION_EXECUTION", "0");
assert!(!should_skip_migrations());
remove_var("MOOSICBOX_SKIP_MIGRATION_EXECUTION");
}
#[test_log::test]
#[serial]
fn test_should_skip_migrations_returns_false_when_not_set() {
remove_var("MOOSICBOX_SKIP_MIGRATION_EXECUTION");
assert!(!should_skip_migrations());
}
#[test_log::test]
#[serial]
fn test_should_skip_migrations_returns_false_for_other_values() {
set_var("MOOSICBOX_SKIP_MIGRATION_EXECUTION", "true");
assert!(!should_skip_migrations());
set_var("MOOSICBOX_SKIP_MIGRATION_EXECUTION", "yes");
assert!(!should_skip_migrations());
set_var("MOOSICBOX_SKIP_MIGRATION_EXECUTION", "");
assert!(!should_skip_migrations());
remove_var("MOOSICBOX_SKIP_MIGRATION_EXECUTION");
}
#[test_log::test]
#[serial]
fn test_should_drop_migrations_table_returns_true_when_set_to_one() {
set_var("MOOSICBOX_DROP_MIGRATIONS_TABLE", "1");
assert!(should_drop_migrations_table());
remove_var("MOOSICBOX_DROP_MIGRATIONS_TABLE");
}
#[test_log::test]
#[serial]
fn test_should_drop_migrations_table_returns_false_when_set_to_zero() {
set_var("MOOSICBOX_DROP_MIGRATIONS_TABLE", "0");
assert!(!should_drop_migrations_table());
remove_var("MOOSICBOX_DROP_MIGRATIONS_TABLE");
}
#[test_log::test]
#[serial]
fn test_should_drop_migrations_table_returns_false_when_not_set() {
remove_var("MOOSICBOX_DROP_MIGRATIONS_TABLE");
assert!(!should_drop_migrations_table());
}
#[test_log::test]
#[serial]
fn test_should_drop_migrations_table_returns_false_for_other_values() {
set_var("MOOSICBOX_DROP_MIGRATIONS_TABLE", "true");
assert!(!should_drop_migrations_table());
set_var("MOOSICBOX_DROP_MIGRATIONS_TABLE", "yes");
assert!(!should_drop_migrations_table());
set_var("MOOSICBOX_DROP_MIGRATIONS_TABLE", "");
assert!(!should_drop_migrations_table());
remove_var("MOOSICBOX_DROP_MIGRATIONS_TABLE");
}
}
#[cfg(feature = "sqlite")]
#[test_log::test(switchy_async::test)]
async fn test_get_sqlite_library_migrations_returns_migrations() {
let migrations = get_sqlite_library_migrations().await.unwrap();
assert!(
!migrations.is_empty(),
"Expected SQLite library migrations to be non-empty"
);
}
#[cfg(feature = "sqlite")]
#[test_log::test(switchy_async::test)]
async fn test_get_sqlite_config_migrations_returns_migrations() {
let migrations = get_sqlite_config_migrations().await.unwrap();
assert!(
!migrations.is_empty(),
"Expected SQLite config migrations to be non-empty"
);
}
#[cfg(feature = "postgres")]
#[test_log::test(switchy_async::test)]
async fn test_get_postgres_library_migrations_returns_migrations() {
let migrations = get_postgres_library_migrations().await.unwrap();
assert!(
!migrations.is_empty(),
"Expected PostgreSQL library migrations to be non-empty"
);
}
#[cfg(feature = "postgres")]
#[test_log::test(switchy_async::test)]
async fn test_get_postgres_config_migrations_returns_migrations() {
let migrations = get_postgres_config_migrations().await.unwrap();
assert!(
!migrations.is_empty(),
"Expected PostgreSQL config migrations to be non-empty"
);
}
#[cfg(feature = "sqlite")]
#[test_log::test(switchy_async::test)]
async fn test_migrate_library_until_with_specific_migration() {
let db = switchy_database_connection::init_sqlite_rusqlite(None).unwrap();
let result =
migrate_library_sqlite_until(&*db, Some("2024-09-21-130720_set_journal_mode_to_wal"))
.await;
assert!(
result.is_ok(),
"Migration until specific migration should succeed"
);
}
#[cfg(feature = "sqlite")]
#[test_log::test(switchy_async::test)]
async fn test_migrate_library_sqlite_until_with_none_runs_all() {
let db = switchy_database_connection::init_sqlite_rusqlite(None).unwrap();
let result = migrate_library_sqlite_until(&*db, None).await;
assert!(
result.is_ok(),
"Migration with None should run all migrations successfully"
);
}
#[cfg(feature = "sqlite")]
#[test_log::test(switchy_async::test)]
async fn test_sqlite_library_migrations_are_sorted_by_timestamp() {
let migrations = get_sqlite_library_migrations().await.unwrap();
let ids: Vec<_> = migrations.iter().map(|m| m.id().to_string()).collect();
let mut sorted_ids = ids.clone();
sorted_ids.sort();
assert_eq!(
ids, sorted_ids,
"Migrations should be sorted by ID (timestamp order)"
);
}
#[cfg(feature = "sqlite")]
#[test_log::test(switchy_async::test)]
async fn test_sqlite_config_migrations_are_sorted_by_timestamp() {
let migrations = get_sqlite_config_migrations().await.unwrap();
let ids: Vec<_> = migrations.iter().map(|m| m.id().to_string()).collect();
let mut sorted_ids = ids.clone();
sorted_ids.sort();
assert_eq!(
ids, sorted_ids,
"Migrations should be sorted by ID (timestamp order)"
);
}
#[cfg(feature = "postgres")]
#[test_log::test(switchy_async::test)]
async fn test_postgres_library_migrations_are_sorted_by_timestamp() {
let migrations = get_postgres_library_migrations().await.unwrap();
let ids: Vec<_> = migrations.iter().map(|m| m.id().to_string()).collect();
let mut sorted_ids = ids.clone();
sorted_ids.sort();
assert_eq!(
ids, sorted_ids,
"Migrations should be sorted by ID (timestamp order)"
);
}
#[cfg(feature = "postgres")]
#[test_log::test(switchy_async::test)]
async fn test_postgres_config_migrations_are_sorted_by_timestamp() {
let migrations = get_postgres_config_migrations().await.unwrap();
let ids: Vec<_> = migrations.iter().map(|m| m.id().to_string()).collect();
let mut sorted_ids = ids.clone();
sorted_ids.sort();
assert_eq!(
ids, sorted_ids,
"Migrations should be sorted by ID (timestamp order)"
);
}
}
#[cfg(feature = "sqlite")]
#[cfg(test)]
mod sqlite_tests {
use moosicbox_json_utils::ToValueType;
use moosicbox_music_models::{
ApiSource, ApiSources,
id::{ApiId, Id},
};
use pretty_assertions::assert_eq;
use switchy_database::DatabaseValue;
use super::*;
#[test_log::test(switchy_async::test)]
async fn sqlx_config_migrations() {
let db = switchy_database_connection::init_sqlite_sqlx(None)
.await
.unwrap();
migrate_config_sqlite(&*db).await.unwrap();
}
#[test_log::test(switchy_async::test)]
async fn sqlx_library_migrations() {
let db = switchy_database_connection::init_sqlite_sqlx(None)
.await
.unwrap();
migrate_library_sqlite(&*db).await.unwrap();
}
#[test_log::test(switchy_async::test)]
async fn rusqlite_config_migrations() {
let db = switchy_database_connection::init_sqlite_rusqlite(None).unwrap();
migrate_config_sqlite(&*db).await.unwrap();
}
#[test_log::test(switchy_async::test)]
async fn rusqlite_library_migrations() {
let db = switchy_database_connection::init_sqlite_rusqlite(None).unwrap();
migrate_library_sqlite(&*db).await.unwrap();
}
#[test_log::test(switchy_async::test)]
async fn test_api_sources_table_migration() {
const API_SOURCES_COLUMN: &str = "
(
SELECT json_group_array(
json_object(
'id', api_sources.source_id,
'source', api_sources.source
)
)
FROM api_sources
WHERE api_sources.entity_type='{table}' AND api_sources.entity_id = {table}.id
) AS api_sources
";
let tidal = ApiSource::register("Tidal", "Tidal");
let qobuz = ApiSource::register("Qobuz", "Qobuz");
let db = switchy_database_connection::init_sqlite_rusqlite(None).unwrap();
migrate_library_sqlite_until(&*db, Some("2024-09-21-130720_set_journal_mode_to_wal"))
.await
.unwrap();
db.exec_raw(
"
INSERT INTO artists (id, title, cover, tidal_id, qobuz_id) VALUES
(1, 'title1', '', 'art123', 'art456'),
(2, 'title2', '', 'art789', NULL),
(3, 'title3', '', NULL, 'art101112'),
(4, 'title4', '', NULL, NULL);
INSERT INTO albums (id, artist_id, title, date_released, date_added, artwork, directory, blur, tidal_id, qobuz_id) VALUES
(1, 1, 'title1', '2022-01-01', '2022-01-01', '', '', 0, 'alb123', 'alb456'),
(2, 2, 'title2', '2022-01-01', '2022-01-01', '', '', 0, 'alb789', NULL),
(3, 3, 'title3', '2022-01-01', '2022-01-01', '', '', 0, NULL, 'alb101112'),
(4, 4, 'title4', '2022-01-01', '2022-01-01', '', '', 0, NULL, NULL);
INSERT INTO tracks (id, album_id, number, title, duration, file, format, source, tidal_id, qobuz_id) VALUES
(1, 1, 1, 'title1', 10, 'file1', 'FLAC', 'LOCAL', '123', '456'),
(2, 2, 2, 'title2', 13, 'file2', 'FLAC', 'LOCAL', '789', NULL),
(3, 3, 3, 'title3', 19, 'file3', 'FLAC', 'LOCAL', NULL, '101112'),
(4, 4, 4, 'title4', 15, 'file4', 'FLAC', 'LOCAL', NULL, NULL),
(5, 4, 4, 'title4', 15, NULL, 'SOURCE', 'LOCAL', '123', NULL),
(6, 4, 4, 'title4', 15, NULL, 'SOURCE', 'LOCAL', NULL, '123');
",
)
.await
.unwrap();
migrate_library_sqlite(&*db).await.unwrap();
let artists = db
.select("artists")
.columns(&[&API_SOURCES_COLUMN.replace("{table}", "artists")])
.execute(&*db)
.await
.unwrap();
assert_eq!(artists.len(), 4);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
artists[0].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default()
.with_api_id(ApiId {
source: tidal.clone(),
id: Id::String("art123".into())
})
.with_api_id(ApiId {
source: qobuz.clone(),
id: Id::String("art456".into())
})
);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
artists[1].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default().with_api_id(ApiId {
source: tidal.clone(),
id: Id::String("art789".into())
})
);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
artists[2].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default().with_api_id(ApiId {
source: qobuz.clone(),
id: Id::String("art101112".into())
})
);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
artists[3].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default()
);
let albums = db
.select("albums")
.columns(&[&API_SOURCES_COLUMN.replace("{table}", "albums")])
.execute(&*db)
.await
.unwrap();
assert_eq!(albums.len(), 4);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
albums[0].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default()
.with_api_id(ApiId {
source: tidal.clone(),
id: Id::String("alb123".into())
})
.with_api_id(ApiId {
source: qobuz.clone(),
id: Id::String("alb456".into())
})
);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
albums[1].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default().with_api_id(ApiId {
source: tidal.clone(),
id: Id::String("alb789".into())
})
);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
albums[2].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default().with_api_id(ApiId {
source: qobuz.clone(),
id: Id::String("alb101112".into())
})
);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
albums[3].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default()
);
let tracks = db
.select("tracks")
.columns(&["id", &API_SOURCES_COLUMN.replace("{table}", "tracks")])
.sort("id", switchy_database::query::SortDirection::Asc)
.execute(&*db)
.await
.unwrap();
assert_eq!(tracks.len(), 5);
assert_eq!(
tracks
.iter()
.filter_map(|x| x.get("id").and_then(|x| x.as_u64()))
.collect::<Vec<_>>(),
vec![1, 2, 3, 4, 6]
);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
tracks[0].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default()
.with_api_id(ApiId {
source: tidal.clone(),
id: Id::String("123".into())
})
.with_api_id(ApiId {
source: qobuz.clone(),
id: Id::String("456".into())
})
);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
tracks[1].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default().with_api_id(ApiId {
source: tidal.clone(),
id: Id::String("789".into())
})
);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
tracks[2].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default().with_api_id(ApiId {
source: qobuz.clone(),
id: Id::String("101112".into())
})
);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
tracks[3].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default()
);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
tracks[4].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default()
.with_api_id(ApiId {
source: tidal.clone(),
id: Id::String("123".into())
})
.with_api_id(ApiId {
source: qobuz.clone(),
id: Id::String("123".into())
})
);
}
#[test_log::test(switchy_async::test)]
async fn test_api_sources_column_migration() {
let tidal = ApiSource::register("Tidal", "Tidal");
let qobuz = ApiSource::register("Qobuz", "Qobuz");
let db = switchy_database_connection::init_sqlite_rusqlite(None).unwrap();
migrate_library_sqlite_until(
&*db,
Some("2025-05-31-110603_update_api_source_id_structure"),
)
.await
.unwrap();
db.exec_raw(
"
INSERT INTO artists (id, title, cover) VALUES
(1, 'title1', ''),
(2, 'title2', ''),
(3, 'title3', ''),
(4, 'title4', '');
INSERT INTO albums (id, artist_id, title, date_released, date_added, artwork, directory, blur) VALUES
(1, 1, 'title1', '2022-01-01', '2022-01-01', '', '', 0),
(2, 2, 'title2', '2022-01-01', '2022-01-01', '', '', 0),
(3, 3, 'title3', '2022-01-01', '2022-01-01', '', '', 0),
(4, 4, 'title4', '2022-01-01', '2022-01-01', '', '', 0);
INSERT INTO tracks (id, album_id, number, title, duration, file, format, source) VALUES
(1, 1, 1, 'title1', 10, 'file1', 'FLAC', 'LOCAL'),
(2, 2, 2, 'title2', 13, 'file2', 'FLAC', 'LOCAL'),
(3, 3, 3, 'title3', 19, 'file3', 'FLAC', 'LOCAL'),
(4, 4, 4, 'title4', 15, 'file4', 'FLAC', 'LOCAL'),
(6, 4, 4, 'title4', 15, NULL, 'SOURCE', 'LOCAL');
INSERT INTO api_sources (entity_type, entity_id, source, source_id) VALUES
('artists', 1, 'Tidal', 'art123'),
('artists', 1, 'Qobuz', 'art456'),
('artists', 2, 'Tidal', 'art789'),
('artists', 3, 'Qobuz', 'art101112');
INSERT INTO api_sources (entity_type, entity_id, source, source_id) VALUES
('albums', 1, 'Tidal', 'alb123'),
('albums', 1, 'Qobuz', 'alb456'),
('albums', 2, 'Tidal', 'alb789'),
('albums', 3, 'Qobuz', 'alb101112');
INSERT INTO api_sources (entity_type, entity_id, source, source_id) VALUES
('tracks', 1, 'Tidal', '123'),
('tracks', 1, 'Qobuz', '456'),
('tracks', 2, 'Tidal', '789'),
('tracks', 3, 'Qobuz', '101112'),
('tracks', 6, 'Tidal', '123'),
('tracks', 6, 'Qobuz', '123');
",
)
.await
.unwrap();
migrate_library_sqlite(&*db).await.unwrap();
let artists = db
.select("artists")
.columns(&["api_sources"])
.execute(&*db)
.await
.unwrap();
assert_eq!(artists.len(), 4);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
artists[0].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default()
.with_api_id(ApiId {
source: tidal.clone(),
id: Id::String("art123".into())
})
.with_api_id(ApiId {
source: qobuz.clone(),
id: Id::String("art456".into())
})
);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
artists[1].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default().with_api_id(ApiId {
source: tidal.clone(),
id: Id::String("art789".into())
})
);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
artists[2].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default().with_api_id(ApiId {
source: qobuz.clone(),
id: Id::String("art101112".into())
})
);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
artists[3].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default()
);
let albums = db
.select("albums")
.columns(&["api_sources"])
.execute(&*db)
.await
.unwrap();
assert_eq!(albums.len(), 4);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
albums[0].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default()
.with_api_id(ApiId {
source: tidal.clone(),
id: Id::String("alb123".into())
})
.with_api_id(ApiId {
source: qobuz.clone(),
id: Id::String("alb456".into())
})
);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
albums[1].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default().with_api_id(ApiId {
source: tidal.clone(),
id: Id::String("alb789".into())
})
);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
albums[2].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default().with_api_id(ApiId {
source: qobuz.clone(),
id: Id::String("alb101112".into())
})
);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
albums[3].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default()
);
let tracks = db
.select("tracks")
.columns(&["id", "api_sources"])
.sort("id", switchy_database::query::SortDirection::Asc)
.execute(&*db)
.await
.unwrap();
assert_eq!(tracks.len(), 5);
assert_eq!(
tracks
.iter()
.filter_map(|x| x.get("id").and_then(|x| x.as_u64()))
.collect::<Vec<_>>(),
vec![1, 2, 3, 4, 6]
);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
tracks[0].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default()
.with_api_id(ApiId {
source: tidal.clone(),
id: Id::String("123".into())
})
.with_api_id(ApiId {
source: qobuz.clone(),
id: Id::String("456".into())
})
);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
tracks[1].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default().with_api_id(ApiId {
source: tidal.clone(),
id: Id::String("789".into())
})
);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
tracks[2].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default().with_api_id(ApiId {
source: qobuz.clone(),
id: Id::String("101112".into())
})
);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
tracks[3].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default()
);
assert_eq!(
<DatabaseValue as ToValueType<ApiSources>>::to_value_type(
tracks[4].get("api_sources").unwrap()
)
.unwrap(),
ApiSources::default()
.with_api_id(ApiId {
source: tidal.clone(),
id: Id::String("123".into())
})
.with_api_id(ApiId {
source: qobuz.clone(),
id: Id::String("123".into())
})
);
}
}