Compare commits

..

3 commits

9 changed files with 648 additions and 163 deletions

128
Cargo.lock generated
View file

@ -73,9 +73,9 @@ checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.21.3" version = "0.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "414dcefbc63d77c526a76b3afcf6fbb9b5e2791c19c3aa2297733208750c6e53" checksum = "9ba43ea6f343b788c8764558649e08df62f86c6ef251fdaeb1ffd010a9ae50a2"
[[package]] [[package]]
name = "base64ct" name = "base64ct"
@ -109,9 +109,9 @@ dependencies = [
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.13.0" version = "3.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec"
[[package]] [[package]]
name = "byteorder" name = "byteorder"
@ -121,9 +121,9 @@ checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.4.0" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223"
[[package]] [[package]]
name = "cc" name = "cc"
@ -349,6 +349,12 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6999dc1837253364c2ebb0704ba97994bd874e8f195d665c50b7548f6ea92764" checksum = "6999dc1837253364c2ebb0704ba97994bd874e8f195d665c50b7548f6ea92764"
[[package]]
name = "finl_unicode"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6"
[[package]] [[package]]
name = "flume" name = "flume"
version = "0.10.14" version = "0.10.14"
@ -443,7 +449,7 @@ checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.29", "syn 2.0.36",
] ]
[[package]] [[package]]
@ -615,9 +621,9 @@ dependencies = [
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.10.5" version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57"
dependencies = [ dependencies = [
"either", "either",
] ]
@ -648,9 +654,9 @@ dependencies = [
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.147" version = "0.2.148"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" checksum = "9cdc71e17332e86d2e1d38c1f99edcb6288ee11b815fb1a4b049eaa2114d369b"
[[package]] [[package]]
name = "libm" name = "libm"
@ -677,9 +683,9 @@ checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.4.5" version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57bcfdad1b858c2db7c38303a6d2ad4dfaf5eb53dfeb0910128b2c26d6158503" checksum = "1a9bad9f94746442c783ca431b22403b519cd7fbeed0533fdd6328b2f2212128"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
@ -708,9 +714,9 @@ dependencies = [
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.6.0" version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76fc44e2588d5b436dbc3c6cf62aef290f90dab6235744a93dfe1cc18f451e2c" checksum = "8f232d6ef707e1956a43342693d2a31e72989554d58299d7a88738cc95b0d35c"
[[package]] [[package]]
name = "minimal-lexical" name = "minimal-lexical"
@ -817,9 +823,9 @@ dependencies = [
[[package]] [[package]]
name = "object" name = "object"
version = "0.32.0" version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ac5bbd07aea88c60a577a1ce218075ffd59208b2d7ca97adf9bfc5aeb21ebe" checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
@ -890,19 +896,20 @@ checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94"
[[package]] [[package]]
name = "pest" name = "pest"
version = "2.7.2" version = "2.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1acb4a4365a13f749a93f1a094a7805e5cfa0955373a9de860d962eaa3a5fe5a" checksum = "d7a4d085fd991ac8d5b05a147b437791b4260b76326baf0fc60cf7c9c27ecd33"
dependencies = [ dependencies = [
"memchr",
"thiserror", "thiserror",
"ucd-trie", "ucd-trie",
] ]
[[package]] [[package]]
name = "pest_derive" name = "pest_derive"
version = "2.7.2" version = "2.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "666d00490d4ac815001da55838c500eafb0320019bbaa44444137c48b443a853" checksum = "a2bee7be22ce7918f641a33f08e3f43388c7656772244e2bbb2477f44cc9021a"
dependencies = [ dependencies = [
"pest", "pest",
"pest_generator", "pest_generator",
@ -910,22 +917,22 @@ dependencies = [
[[package]] [[package]]
name = "pest_generator" name = "pest_generator"
version = "2.7.2" version = "2.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ca01446f50dbda87c1786af8770d535423fa8a53aec03b8f4e3d7eb10e0929" checksum = "d1511785c5e98d79a05e8a6bc34b4ac2168a0e3e92161862030ad84daa223141"
dependencies = [ dependencies = [
"pest", "pest",
"pest_meta", "pest_meta",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.29", "syn 2.0.36",
] ]
[[package]] [[package]]
name = "pest_meta" name = "pest_meta"
version = "2.7.2" version = "2.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56af0a30af74d0445c0bf6d9d051c979b516a1a5af790d251daee76005420a48" checksum = "b42f0394d3123e33353ca5e1e89092e533d2cc490389f2bd6131c43c634ebc5f"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"pest", "pest",
@ -949,7 +956,7 @@ checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.29", "syn 2.0.36",
] ]
[[package]] [[package]]
@ -999,9 +1006,9 @@ checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.66" version = "1.0.67"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9" checksum = "3d433d9f1a3e8c1263d9456598b16fec66f4acc9a74dacffd35c7bb09b3a1328"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@ -1116,9 +1123,9 @@ checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "0.38.10" version = "0.38.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed6248e1caa625eb708e266e06159f135e8c26f2bb7ceb72dc4b2766d0340964" checksum = "d7db8590df6dfcd144d22afd1b83b36c21a18d7cbc1dc4bb5295a8712e9eb662"
dependencies = [ dependencies = [
"bitflags 2.4.0", "bitflags 2.4.0",
"errno", "errno",
@ -1144,14 +1151,14 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d3987094b1d07b653b7dfdc3f70ce9a1da9c51ac18c1b06b662e4f9a0e9f4b2" checksum = "2d3987094b1d07b653b7dfdc3f70ce9a1da9c51ac18c1b06b662e4f9a0e9f4b2"
dependencies = [ dependencies = [
"base64 0.21.3", "base64 0.21.4",
] ]
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.101.4" version = "0.101.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d93931baf2d282fff8d3a532bbfd7653f734643161b87e3e01e59a04439bf0d" checksum = "45a27e3b59326c16e23d30aeb7a36a24cc0d29e71d68ff611cdfb4a01d013bed"
dependencies = [ dependencies = [
"ring", "ring",
"untrusted", "untrusted",
@ -1196,14 +1203,14 @@ checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.29", "syn 2.0.36",
] ]
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.105" version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "693151e1ac27563d6dbcec9dee9fbd5da8539b20fa14ad3752b2e6d363ace360" checksum = "6b420ce6e3d8bd882e9b243c6eed35dbc9a6110c9769e74b584e0d68d1f20c65"
dependencies = [ dependencies = [
"itoa", "itoa",
"ryu", "ryu",
@ -1280,9 +1287,9 @@ checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9"
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.5.3" version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2538b18701741680e0322a2302176d3253a35388e2e62f172f64f4f16605f877" checksum = "4031e820eb552adee9295814c0ced9e5cf38ddf1e8b7d566d6de8e2538ea989e"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.48.0", "windows-sys 0.48.0",
@ -1315,9 +1322,9 @@ dependencies = [
[[package]] [[package]]
name = "sqlformat" name = "sqlformat"
version = "0.2.1" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c12bc9199d1db8234678b7051747c07f517cdcf019262d1847b94ec8b1aee3e" checksum = "6b7b278788e7be4d0d29c0f39497a0eef3fba6bbc8e70d8bf7fde46edeaa9e85"
dependencies = [ dependencies = [
"itertools", "itertools",
"nom", "nom",
@ -1451,7 +1458,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ca69bf415b93b60b80dc8fda3cb4ef52b2336614d8da2de5456cc942a110482" checksum = "8ca69bf415b93b60b80dc8fda3cb4ef52b2336614d8da2de5456cc942a110482"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64 0.21.3", "base64 0.21.4",
"bitflags 2.4.0", "bitflags 2.4.0",
"byteorder", "byteorder",
"bytes", "bytes",
@ -1495,7 +1502,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0db2df1b8731c3651e204629dd55e52adbae0462fa1bdcbed56a2302c18181e" checksum = "a0db2df1b8731c3651e204629dd55e52adbae0462fa1bdcbed56a2302c18181e"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64 0.21.3", "base64 0.21.4",
"bitflags 2.4.0", "bitflags 2.4.0",
"byteorder", "byteorder",
"crc", "crc",
@ -1555,10 +1562,11 @@ dependencies = [
[[package]] [[package]]
name = "stringprep" name = "stringprep"
version = "0.1.3" version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db3737bde7edce97102e0e2b15365bf7a20bfdb5f60f4f9e8d7004258a51a8da" checksum = "bb41d74e231a107a1b4ee36bd1214b11285b77768d2e3824aedafa988fd36ee6"
dependencies = [ dependencies = [
"finl_unicode",
"unicode-bidi", "unicode-bidi",
"unicode-normalization", "unicode-normalization",
] ]
@ -1588,9 +1596,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.29" version = "2.0.36"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c324c494eba9d92503e6f1ef2e6df781e78f6a7705a0202d9801b198807d518a" checksum = "91e02e55d62894af2a08aca894c6577281f76769ba47c94d5756bec8ac6e7373"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -1612,22 +1620,22 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.47" version = "1.0.48"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a802ec30afc17eee47b2855fc72e0c4cd62be9b4efe6591edde0ec5bd68d8f" checksum = "9d6d7a740b8a666a7e828dd00da9c0dc290dff53154ea77ac109281de90589b7"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl",
] ]
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "1.0.47" version = "1.0.48"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bb623b56e39ab7dcd4b1b98bb6c8f8d907ed255b18de254088016b27a8ee19b" checksum = "49922ecae66cc8a249b77e68d1d0623c1b2c514f0060c27cdc68bd62a1219d35"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.29", "syn 2.0.36",
] ]
[[package]] [[package]]
@ -1720,7 +1728,7 @@ checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.29", "syn 2.0.36",
] ]
[[package]] [[package]]
@ -1755,7 +1763,7 @@ checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.29", "syn 2.0.36",
] ]
[[package]] [[package]]
@ -1769,9 +1777,9 @@ dependencies = [
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.16.0" version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
[[package]] [[package]]
name = "ucd-trie" name = "ucd-trie"
@ -1787,9 +1795,9 @@ checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.11" version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]] [[package]]
name = "unicode-normalization" name = "unicode-normalization"
@ -1878,7 +1886,7 @@ dependencies = [
"once_cell", "once_cell",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.29", "syn 2.0.36",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@ -1900,7 +1908,7 @@ checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.29", "syn 2.0.36",
"wasm-bindgen-backend", "wasm-bindgen-backend",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]

View file

@ -5,54 +5,45 @@ use time::PrimitiveDateTime;
use super::{DatabaseError, PlaylistChange, PlaylistEntry, SrcIdOwned}; use super::{DatabaseError, PlaylistChange, PlaylistEntry, SrcIdOwned};
/// Change operation stored in the database /// Change operation stored in the database
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE", tag = "typ")] #[serde(rename_all = "SCREAMING_SNAKE_CASE", tag = "typ")]
pub enum ChangeOperation { pub enum ChangeOperation {
/// Insert /// Insert
Ins { pos: u32, val: Vec<SrcIdOwned> }, Ins { pos: usize, val: Vec<SrcIdOwned> },
/// Delete /// Delete
Del { pos: u32, n: u32 }, Del { pos: usize, n: usize },
/// Move /// Move
Mov { pos: u32, n: u32, to: u32 }, Mov { pos: usize, n: usize, to: usize },
} }
impl ChangeOperation { impl ChangeOperation {
pub fn to_op(&self, dt: PrimitiveDateTime) -> Result<Operation<PlaylistEntry>, DatabaseError> { pub fn into_op(self, dt: PrimitiveDateTime) -> Result<Operation<PlaylistEntry>, DatabaseError> {
match self { match self {
ChangeOperation::Ins { pos, val } => Ok(Operation::Ins { ChangeOperation::Ins { pos, val } => Ok(Operation::Ins {
pos: (*pos).try_into()?, pos,
val: val val: val.into_iter().map(|id| PlaylistEntry { id, dt }).collect(),
.iter()
.map(|id| PlaylistEntry {
id: id.to_owned(),
dt,
})
.collect(),
}),
ChangeOperation::Del { pos, n } => Ok(Operation::Del {
pos: (*pos).try_into()?,
n: (*n).try_into()?,
}), }),
ChangeOperation::Del { pos, n } => Ok(Operation::Del { pos, n }),
ChangeOperation::Mov { pos, n, to } => { ChangeOperation::Mov { pos, n, to } => {
if *n == 1 { if n == 1 {
Ok(Operation::Mov { Ok(Operation::Mov { pos, to })
pos: (*pos).try_into()?,
to: (*to).try_into()?,
})
} else { } else {
/* let end = pos + n;
let start = usize::try_from(pos)?;
let end = usize::try_from(pos + n)?;
let to = usize::try_from(to)?;
Ok(Operation::Seq { Ok(Operation::Seq {
ops: (start..end) ops: if to > pos {
.rev() // Move down
.enumerate() (pos..end)
.map(|(i, pos)| Operation::Mov { pos, to }) .map(|pos| Operation::Mov { pos, to: to + n })
.collect(), .collect()
} else {
// Move up
(pos..end)
.rev()
.map(|pos| Operation::Mov { pos, to })
.collect()
},
}) })
*/
todo!("figure out moving ranges")
} }
} }
} }
@ -64,7 +55,9 @@ impl ChangeOperation {
let changes = changes.into_iter(); let changes = changes.into_iter();
let mut ops = Vec::with_capacity(changes.size_hint().0); let mut ops = Vec::with_capacity(changes.size_hint().0);
for c in changes { for c in changes {
ops.append(&mut c.operations()?); for cop in &c.operations.0 {
ops.push(cop.clone().into_op(c.created_at)?);
}
} }
Ok(Operation::Seq { ops }) Ok(Operation::Seq { ops })
} }
@ -76,18 +69,11 @@ impl ChangeOperation {
let changeop = match op { let changeop = match op {
Operation::Nop => return Ok(()), Operation::Nop => return Ok(()),
Operation::Ins { pos, val } => ChangeOperation::Ins { Operation::Ins { pos, val } => ChangeOperation::Ins {
pos: pos.try_into()?, pos,
val: val.into_iter().map(|itm| itm.id).collect(), val: val.into_iter().map(|itm| itm.id).collect(),
}, },
Operation::Del { pos, n } => ChangeOperation::Del { Operation::Del { pos, n } => ChangeOperation::Del { pos, n },
pos: pos.try_into()?, Operation::Mov { pos, to } => ChangeOperation::Mov { pos, n: 1, to },
n: n.try_into()?,
},
Operation::Mov { pos, to } => ChangeOperation::Mov {
pos: pos.try_into()?,
n: 1,
to: to.try_into()?,
},
Operation::Seq { ops } => { Operation::Seq { ops } => {
return ops return ops
.into_iter() .into_iter()
@ -97,16 +83,84 @@ impl ChangeOperation {
list.push(changeop); list.push(changeop);
Ok(()) Ok(())
} }
pub fn apply(
self,
items: &mut Vec<PlaylistEntry>,
dt: PrimitiveDateTime,
) -> Result<(), DatabaseError> {
let assert_pos = |pos: usize| {
if pos > items.len() {
Err(DatabaseError::PlaylistVcs(
format!("index {} out of range (len: {})", pos, items.len()).into(),
))
} else {
Ok(())
}
};
match self {
ChangeOperation::Ins { pos, val } => {
let new_items = val.into_iter().map(|id| PlaylistEntry { id, dt });
if pos < items.len() {
items.splice(pos..pos, new_items);
} else {
items.extend(new_items);
}
}
ChangeOperation::Del { pos, n } => {
let end = pos + n;
assert_pos(end)?;
items.splice(pos..end, None);
}
ChangeOperation::Mov { pos, n, to } => {
let end = pos + n;
assert_pos(end)?;
assert_pos(to)?;
let moved = items.splice(pos..end, None).collect::<Vec<_>>();
items.splice(to..to, moved);
}
}
Ok(())
}
}
impl std::fmt::Display for ChangeOperation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ChangeOperation::Ins { pos, val } => {
f.write_fmt(format_args!("INS {pos}: "))?;
let show_max = 5;
for i in 0..(val.len().min(show_max)) {
if i != 0 {
f.write_str(", ")?;
}
val[i].fmt(f)?;
}
if val.len() > show_max {
f.write_fmt(format_args!(" (+{})", val.len() - show_max))?;
}
Ok(())
}
ChangeOperation::Del { pos, n } => f.write_fmt(format_args!("DEL {pos} ({n})")),
ChangeOperation::Mov { pos, n, to } => {
f.write_fmt(format_args!("MOV {pos} ({n}) -> {to}"))
}
}
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use time::macros::datetime;
use crate::models::MusicService; use crate::models::MusicService;
use super::*; use super::*;
#[test] #[test]
pub fn to_json() { fn to_json() {
let ins = ChangeOperation::Ins { let ins = ChangeOperation::Ins {
pos: 1, pos: 1,
val: vec![SrcIdOwned("Zeerrnuli5E".to_owned(), MusicService::YouTube)], val: vec![SrcIdOwned("Zeerrnuli5E".to_owned(), MusicService::YouTube)],
@ -122,14 +176,174 @@ mod tests {
r#"{"typ":"DEL","pos":1,"n":2}"# r#"{"typ":"DEL","pos":1,"n":2}"#
); );
let del = ChangeOperation::Mov { let mov = ChangeOperation::Mov {
pos: 1, pos: 1,
n: 2, n: 2,
to: 3, to: 3,
}; };
assert_eq!( assert_eq!(
serde_json::to_string(&del).unwrap(), serde_json::to_string(&mov).unwrap(),
r#"{"typ":"MOV","pos":1,"n":2,"to":3}"# r#"{"typ":"MOV","pos":1,"n":2,"to":3}"#
); );
} }
#[test]
fn to_string() {
let ins = ChangeOperation::Ins {
pos: 1,
val: vec![SrcIdOwned("Zeerrnuli5E".to_owned(), MusicService::YouTube)],
};
assert_eq!(ins.to_string(), r#"INS 1: yt:Zeerrnuli5E"#);
let del = ChangeOperation::Del { pos: 1, n: 2 };
assert_eq!(del.to_string(), r#"DEL 1 (2)"#);
let mov = ChangeOperation::Mov {
pos: 1,
n: 2,
to: 3,
};
assert_eq!(mov.to_string(), r#"MOV 1 (2) -> 3"#);
}
const TESTDATE: time::PrimitiveDateTime = datetime!(2023-09-06 8:00:00);
#[test]
fn apply_ins() {
let mut entries = vec![
PlaylistEntry {
id: SrcIdOwned("1".to_owned(), MusicService::Tiraya),
dt: TESTDATE,
},
PlaylistEntry {
id: SrcIdOwned("4".to_owned(), MusicService::Tiraya),
dt: TESTDATE,
},
];
let ins = ChangeOperation::Ins {
pos: 1,
val: vec![
SrcIdOwned("2".to_owned(), MusicService::Tiraya),
SrcIdOwned("3".to_owned(), MusicService::Tiraya),
],
};
ins.apply(&mut entries, TESTDATE).unwrap();
assert_eq!(entries.len(), 4);
for (i, e) in entries.iter().enumerate() {
assert_eq!(e.id.0, (i + 1).to_string());
}
}
#[test]
fn apply_del() {
let mut entries = vec![
PlaylistEntry {
id: SrcIdOwned("1".to_owned(), MusicService::Tiraya),
dt: TESTDATE,
},
PlaylistEntry {
id: SrcIdOwned("2".to_owned(), MusicService::Tiraya),
dt: TESTDATE,
},
PlaylistEntry {
id: SrcIdOwned("X".to_owned(), MusicService::Tiraya),
dt: TESTDATE,
},
PlaylistEntry {
id: SrcIdOwned("X".to_owned(), MusicService::Tiraya),
dt: TESTDATE,
},
];
let del = ChangeOperation::Del { pos: 2, n: 2 };
del.apply(&mut entries, TESTDATE).unwrap();
assert_eq!(entries.len(), 2);
for (i, e) in entries.iter().enumerate() {
assert_eq!(e.id.0, (i + 1).to_string());
}
}
#[test]
fn apply_mov_up() {
let mut entries = vec![
PlaylistEntry {
id: SrcIdOwned("3".to_owned(), MusicService::Tiraya),
dt: TESTDATE,
},
PlaylistEntry {
id: SrcIdOwned("4".to_owned(), MusicService::Tiraya),
dt: TESTDATE,
},
PlaylistEntry {
id: SrcIdOwned("1".to_owned(), MusicService::Tiraya),
dt: TESTDATE,
},
PlaylistEntry {
id: SrcIdOwned("2".to_owned(), MusicService::Tiraya),
dt: TESTDATE,
},
];
let mov = ChangeOperation::Mov {
pos: 2,
n: 2,
to: 0,
};
mov.apply(&mut entries, TESTDATE).unwrap();
assert_eq!(entries.len(), 4);
for (i, e) in entries.iter().enumerate() {
assert_eq!(e.id.0, (i + 1).to_string());
}
}
#[test]
fn apply_mov_down() {
let mut entries = vec![
PlaylistEntry {
id: SrcIdOwned("3".to_owned(), MusicService::Tiraya),
dt: TESTDATE,
},
PlaylistEntry {
id: SrcIdOwned("4".to_owned(), MusicService::Tiraya),
dt: TESTDATE,
},
PlaylistEntry {
id: SrcIdOwned("1".to_owned(), MusicService::Tiraya),
dt: TESTDATE,
},
PlaylistEntry {
id: SrcIdOwned("2".to_owned(), MusicService::Tiraya),
dt: TESTDATE,
},
];
let mov = ChangeOperation::Mov {
pos: 0,
n: 2,
to: 2,
};
mov.apply(&mut entries, TESTDATE).unwrap();
assert_eq!(entries.len(), 4);
for (i, e) in entries.iter().enumerate() {
assert_eq!(e.id.0, (i + 1).to_string());
}
}
#[test]
fn apply_del_out_of_range() {
let mut entries = vec![
PlaylistEntry {
id: SrcIdOwned("1".to_owned(), MusicService::Tiraya),
dt: TESTDATE,
},
PlaylistEntry {
id: SrcIdOwned("2".to_owned(), MusicService::Tiraya),
dt: TESTDATE,
},
];
let del = ChangeOperation::Del { pos: 1, n: 2 };
let err = del.apply(&mut entries, TESTDATE).unwrap_err();
assert!(matches!(err, DatabaseError::PlaylistVcs(_)));
}
} }

View file

@ -35,18 +35,14 @@ pub enum DatabaseError {
Json(#[from] serde_json::Error), Json(#[from] serde_json::Error),
#[error("Item {0} not found")] #[error("Item {0} not found")]
NotFound(IdOwned), NotFound(IdOwned),
#[error("Conflict while inserting {typ}: ID {id}")]
Conflict { typ: &'static str, id: String },
#[error("Playlist VCS error: {0}")] #[error("Playlist VCS error: {0}")]
PlaylistVcs(Cow<'static, str>), PlaylistVcs(Cow<'static, str>),
#[error("DB error: {0}")] #[error("DB error: {0}")]
Other(Cow<'static, str>), Other(Cow<'static, str>),
} }
impl From<std::num::TryFromIntError> for DatabaseError {
fn from(value: std::num::TryFromIntError) -> Self {
Self::Other(value.to_string().into())
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)] #[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum Id<'a> { pub enum Id<'a> {
Db(i32), Db(i32),

View file

@ -28,11 +28,10 @@ pub struct Playlist {
pub updated_at: PrimitiveDateTime, pub updated_at: PrimitiveDateTime,
pub last_sync_at: Option<PrimitiveDateTime>, pub last_sync_at: Option<PrimitiveDateTime>,
pub last_sync_data: Option<Json<SyncData>>, pub last_sync_data: Option<Json<SyncData>>,
pub cache_version: Option<Uuid>,
pub cache_data: Option<Json<Vec<PlaylistEntry>>>,
} }
/// Data for creating a playlist /// Data for creating a playlist
#[derive(Default)]
pub struct PlaylistNew { pub struct PlaylistNew {
pub src_id: Option<String>, pub src_id: Option<String>,
pub service: Option<MusicService>, pub service: Option<MusicService>,
@ -110,8 +109,7 @@ impl Playlist {
Self, Self,
r#"select id, src_id, service as "service: _", name, description, r#"select id, src_id, service as "service: _", name, description,
owner_name, owner_url, playlist_type as "playlist_type: _", image_url, image_hash, owner_name, owner_url, playlist_type as "playlist_type: _", image_url, image_hash,
image_type as "image_type: _", created_at, updated_at, last_sync_at, last_sync_data as "last_sync_data: _", image_type as "image_type: _", created_at, updated_at, last_sync_at, last_sync_data as "last_sync_data: _"
cache_version, cache_data as "cache_data: _"
from playlists where id=$1"#, from playlists where id=$1"#,
id id
) )
@ -123,8 +121,7 @@ from playlists where id=$1"#,
Self, Self,
r#"select id, src_id, service as "service: _", name, description, r#"select id, src_id, service as "service: _", name, description,
owner_name, owner_url, playlist_type as "playlist_type: _", image_url, image_hash, owner_name, owner_url, playlist_type as "playlist_type: _", image_url, image_hash,
image_type as "image_type: _", created_at, updated_at, last_sync_at, last_sync_data as "last_sync_data: _", image_type as "image_type: _", created_at, updated_at, last_sync_at, last_sync_data as "last_sync_data: _"
cache_version, cache_data as "cache_data: _"
from playlists where src_id=$1 and service=$2"#, from playlists where src_id=$1 and service=$2"#,
src_id, src_id,
srv as MusicService srv as MusicService
@ -210,6 +207,23 @@ from playlists where src_id=$1 and service=$2"#,
Ok(()) Ok(())
} }
/// Get the cached playlist entries for the given version if available
async fn get_cache<'a, E>(
id: i32,
exec: E,
) -> Result<Option<(Uuid, Vec<PlaylistEntry>)>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let res = sqlx::query!(
r#"select cache_version, cache_data as "cache_data: Json<Vec<PlaylistEntry>>" from playlists where id=$1"#,
id,
).fetch_optional(exec).await?;
Ok(res.and_then(|res| res.cache_version.zip(res.cache_data.map(|d| d.0))))
}
/// Update the cached playlist entries
async fn set_cache<'a, E>( async fn set_cache<'a, E>(
id: i32, id: i32,
cache_version: Uuid, cache_version: Uuid,
@ -289,17 +303,17 @@ order by pc.created_at"#,
/// Get a list of playlist entries from the current revision /// Get a list of playlist entries from the current revision
/// ///
/// Merges heads and updates the cache if necessary. /// Merges heads and updates the cache if necessary.
pub async fn get_entries<'a, E>(&self, exec: E) -> Result<Vec<PlaylistEntry>, DatabaseError> pub async fn get_entries<'a, E>(id: i32, exec: E) -> Result<Vec<PlaylistEntry>, DatabaseError>
where where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{ {
if let Some(head) = Self::get_single_head(self.id, exec).await? { if let Some(head) = Self::get_single_head(id, exec).await? {
// Get list of items and changes to apply // Get list of items and changes to apply
let (mut items, changes) = if let (Some(cache_version), Some(Json(cache_data))) = let cache_data = Self::get_cache(id, exec).await?;
(self.cache_version, self.cache_data.clone())
{ let (mut items, changes) = if let Some((cache_version, entries)) = cache_data {
( (
cache_data, entries,
if head != cache_version { if head != cache_version {
PlaylistChange::get_changes(Some(cache_version), head, exec).await? PlaylistChange::get_changes(Some(cache_version), head, exec).await?
} else { } else {
@ -315,12 +329,12 @@ order by pc.created_at"#,
if !changes.is_empty() { if !changes.is_empty() {
// Apply changes // Apply changes
for c in changes.iter().rev() { for c in changes.into_iter().rev() {
c.apply(&mut items)?; c.apply(&mut items)?;
} }
// Update cache // Update cache
Self::set_cache(self.id, head, &items, exec).await?; Self::set_cache(id, head, &items, exec).await?;
} }
Ok(items) Ok(items)
@ -340,18 +354,20 @@ order by pc.created_at"#,
let changes = PlaylistChange::get_changes(None, head, exec).await?; let changes = PlaylistChange::get_changes(None, head, exec).await?;
// Apply changes // Apply changes
for c in changes.iter().rev() { for c in changes.into_iter().rev() {
c.apply(&mut items)?; c.apply(&mut items)?;
} }
Ok(items) Ok(items)
} }
pub async fn get_tracks<'a, E>(&self, exec: E) -> Result<Vec<PlaylistEntryTrack>, DatabaseError> pub async fn get_tracks<'a, E>(
id: i32,
exec: E,
) -> Result<Vec<PlaylistEntryTrack>, DatabaseError>
where where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{ {
let entries = self.get_entries(exec).await?; let entries = Self::get_entries(id, exec).await?;
Self::tracks_from_entries(&entries, exec).await Self::tracks_from_entries(&entries, exec).await
} }
@ -765,14 +781,18 @@ mod tests {
async fn get_tracks() { async fn get_tracks() {
testutil::run_sql("base.sql", &pool).await; testutil::run_sql("base.sql", &pool).await;
let pl = Playlist::get(ids::PLAYLIST_TEST1, &pool).await.unwrap(); let tracks = Playlist::get_tracks(ids::PLAYLIST_ID_TEST1, &pool)
let tracks = pl.get_tracks(&pool).await.unwrap(); .await
.unwrap();
insta::assert_ron_snapshot!("get_tracks", tracks); insta::assert_ron_snapshot!("get_tracks", tracks);
// Check cache // Check cache
let pl = Playlist::get(ids::PLAYLIST_TEST1, &pool).await.unwrap(); let (cache_version, cache_entries) = Playlist::get_cache(ids::PLAYLIST_ID_TEST1, &pool)
assert_eq!(pl.cache_version.unwrap(), ids::PLAYLIST_CHANGE_HEAD); .await
for (i, entry) in pl.cache_data.unwrap().0.iter().enumerate() { .unwrap()
.unwrap();
assert_eq!(cache_version, ids::PLAYLIST_CHANGE_HEAD);
for (i, entry) in cache_entries.iter().enumerate() {
let t = &tracks[i]; let t = &tracks[i];
let track = t.track.as_ref().unwrap(); let track = t.track.as_ref().unwrap();
assert_eq!(t.n, u32::try_from(i).unwrap()); assert_eq!(t.n, u32::try_from(i).unwrap());
@ -819,9 +839,9 @@ mod tests {
.unwrap(); .unwrap();
tx.commit().await.unwrap(); tx.commit().await.unwrap();
let pl = Playlist::get(ids::PLAYLIST_TEST1, &pool).await.unwrap(); let entries = Playlist::get_entries(ids::PLAYLIST_ID_TEST1, &pool)
let entries = pl.get_entries(&pool).await.unwrap(); .await
.unwrap();
assert!( assert!(
testutil::compare_pl_entries( testutil::compare_pl_entries(
&entries, &entries,
@ -829,10 +849,104 @@ mod tests {
SrcId("WSBUeFdXiSs", MusicService::YouTube), SrcId("WSBUeFdXiSs", MusicService::YouTube),
SrcId("OCgE2GSL1Pk", MusicService::YouTube), SrcId("OCgE2GSL1Pk", MusicService::YouTube),
SrcId("6485PhOtHzY", MusicService::YouTube), SrcId("6485PhOtHzY", MusicService::YouTube),
SrcId("2txScm52-QI", MusicService::YouTube) SrcId("2txScm52-QI", MusicService::YouTube),
], ],
), ),
"got: {entries:#?}" "got: {entries:#?}"
); );
} }
#[sqlx_database_tester::test(pool(variable = "pool"))]
async fn add_changes_1() {
testutil::run_sql("base.sql", &pool).await;
let mut tx = pool.begin().await.unwrap();
Playlist::add_change(
Some(ids::PLAYLIST_CHANGE_HEAD),
ids::PLAYLIST_ID_TEST1,
vec![ChangeOperation::Ins {
pos: 3,
val: vec![SrcIdOwned("2txScm52-QI".to_owned(), MusicService::YouTube)],
}],
&mut tx,
)
.await
.unwrap();
tx.commit().await.unwrap();
let mut tx = pool.begin().await.unwrap();
Playlist::add_change(
Some(ids::PLAYLIST_CHANGE_I3),
ids::PLAYLIST_ID_TEST1,
vec![ChangeOperation::Del { pos: 1, n: 1 }],
&mut tx,
)
.await
.unwrap();
tx.commit().await.unwrap();
let entries = Playlist::get_entries(ids::PLAYLIST_ID_TEST1, &pool)
.await
.unwrap();
assert!(
testutil::compare_pl_entries(
&entries,
&[
SrcId("WSBUeFdXiSs", MusicService::YouTube),
SrcId("6485PhOtHzY", MusicService::YouTube),
SrcId("2txScm52-QI", MusicService::YouTube),
],
),
"got: {entries:#?}"
);
}
/// Add a change to the playlist and run the merge operation concurrently to make
/// sure it does not create any ghost revisions.
#[sqlx_database_tester::test(pool(variable = "pool"))]
async fn merge_concurrent() {
testutil::run_sql("base.sql", &pool).await;
let mut tx = pool.begin().await.unwrap();
Playlist::add_change(
Some(ids::PLAYLIST_CHANGE_I3),
ids::PLAYLIST_ID_TEST1,
vec![ChangeOperation::Ins {
pos: 3,
val: vec![SrcIdOwned("2txScm52-QI".to_owned(), MusicService::YouTube)],
}],
&mut tx,
)
.await
.unwrap();
tx.commit().await.unwrap();
stream::iter(0..8)
.for_each_concurrent(8, |_| {
let pool = pool.clone();
async move {
let entries = Playlist::get_entries(ids::PLAYLIST_ID_TEST1, &pool)
.await
.unwrap();
assert!(
testutil::compare_pl_entries(
&entries,
&[
SrcId("WSBUeFdXiSs", MusicService::YouTube),
SrcId("OCgE2GSL1Pk", MusicService::YouTube),
SrcId("6485PhOtHzY", MusicService::YouTube),
SrcId("2txScm52-QI", MusicService::YouTube),
],
),
"got: {entries:#?}"
);
}
})
.await;
let revs = PlaylistChange::get_all_changes(ids::PLAYLIST_ID_TEST1, &pool)
.await
.unwrap();
assert_eq!(revs.len(), 12);
}
} }

View file

@ -1,5 +1,6 @@
use std::fmt::Debug;
use nonempty_collections::{nev, NonEmptyIterator}; use nonempty_collections::{nev, NonEmptyIterator};
use otvec::Operation;
use serde::Serialize; use serde::Serialize;
use sqlx::types::Json; use sqlx::types::Json;
use time::PrimitiveDateTime; use time::PrimitiveDateTime;
@ -115,16 +116,9 @@ parent1_id, parent2_id, created_at from playlist_changes where playlist_id=$1 or
).fetch_all(exec).await ).fetch_all(exec).await
} }
pub fn operations(&self) -> Result<Vec<Operation<PlaylistEntry>>, DatabaseError> { pub fn apply(self, items: &mut Vec<PlaylistEntry>) -> Result<(), DatabaseError> {
self.operations for op in self.operations.0 {
.iter() op.apply(items, self.created_at)?;
.map(|op| op.to_op(self.created_at))
.collect::<Result<Vec<_>, _>>()
}
pub fn apply(&self, items: &mut Vec<PlaylistEntry>) -> Result<(), DatabaseError> {
for op in &self.operations.0 {
op.to_op(self.created_at)?.apply(items);
} }
Ok(()) Ok(())
} }
@ -273,6 +267,54 @@ parent1_id, parent2_id, created_at from playlist_changes where playlist_id=$1 or
ncm.insert_with_id(merge_ids.merge, exec).await?; ncm.insert_with_id(merge_ids.merge, exec).await?;
Ok(merge_ids.merge) Ok(merge_ids.merge)
} }
/// Create a Graphviz graph from playlist changes
pub fn change_graph(changes: &[PlaylistChange]) -> String {
let mut g = String::from("digraph {\n node [shape=box];\n");
let line_start = " \"";
let line_end = "\"\n";
let arrow = "\"->\"";
for change in changes {
let cid = change.id.to_string();
g += line_start;
g += &cid;
g += "\" [label=<<b>#";
g += &change.seq.to_string();
g += "</b><br/>";
g += &cid;
g += "<br/><font point-size=\"10\">";
for op in change.operations.0.iter().take(5) {
g += "<br/>";
g += &op.to_string();
}
if change.operations.0.len() > 5 {
g += &format!("<br>({} more ops)", change.operations.0.len() - 5);
}
g += " </font>>]\n";
if let Some(p1) = change.parent1_id {
g += line_start;
g += &p1.to_string();
g += arrow;
g += &cid;
g += line_end;
}
if let Some(p2) = change.parent2_id {
g += line_start;
g += &p2.to_string();
g += arrow;
g += &cid;
g += line_end;
}
}
g += "}\n";
g
}
} }
impl PlaylistChangeNew { impl PlaylistChangeNew {
@ -297,12 +339,11 @@ values ($1,$2,$3,$4,$5,$6) returning id"#,
pub async fn insert_with_id<'a, 'b, E>(&'b self, id: Uuid, exec: E) -> Result<(), DatabaseError> pub async fn insert_with_id<'a, 'b, E>(&'b self, id: Uuid, exec: E) -> Result<(), DatabaseError>
where where
E: sqlx::Executor<'a, Database = sqlx::Postgres>, E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{ {
sqlx::query!( let res = sqlx::query!(
r#"insert into playlist_changes (id,seq,operations,playlist_id,parent1_id,parent2_id,created_at) r#"insert into playlist_changes (id,seq,operations,playlist_id,parent1_id,parent2_id,created_at)
values ($1,$2,$3,$4,$5,$6,$7) values ($1,$2,$3,$4,$5,$6,$7)"#,
on conflict (id) do nothing"#,
id, id,
self.seq, self.seq,
serde_json::to_value(&self.operations)?, serde_json::to_value(&self.operations)?,
@ -310,16 +351,42 @@ on conflict (id) do nothing"#,
self.parent1_id, self.parent1_id,
self.parent2_id, self.parent2_id,
self.created_at.unwrap_or_else(|| util::primitive_now()), self.created_at.unwrap_or_else(|| util::primitive_now()),
).execute(exec).await?; ).execute(exec).await;
if let Err(e) = res {
match &e {
sqlx::Error::Database(dbe) => {
if dbe.is_unique_violation() {
let conflicting = PlaylistChange::get(id, exec).await?;
if self.seq != conflicting.seq
|| self.playlist_id != conflicting.playlist_id
|| self.operations != conflicting.operations.0
|| self.parent1_id != conflicting.parent1_id
|| self.parent2_id != conflicting.parent2_id
{
return Err(DatabaseError::Conflict {
typ: "playlist_change",
id: id.to_string(),
});
}
} else {
return Err(DatabaseError::Database(e));
}
}
_ => return Err(DatabaseError::Database(e)),
}
}
Ok(()) Ok(())
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use time::macros::datetime;
use super::*; use super::*;
use crate::{ use crate::{
models::Playlist, models::{MusicService, Playlist, SrcIdOwned},
testutil::{self, ids}, testutil::{self, ids},
}; };
@ -353,6 +420,81 @@ mod tests {
insta::assert_ron_snapshot!(res); insta::assert_ron_snapshot!(res);
} }
#[sqlx_database_tester::test(pool(variable = "pool"))]
async fn insert_change() {
testutil::run_sql("base.sql", &pool).await;
let nc = PlaylistChangeNew {
seq: 7,
playlist_id: ids::PLAYLIST_ID_TEST1,
operations: vec![ChangeOperation::Ins {
pos: 1,
val: vec![SrcIdOwned("abcd".to_string(), MusicService::Tiraya)],
}],
parent1_id: Some(ids::PLAYLIST_CHANGE_HEAD),
parent2_id: None,
..Default::default()
};
let id = nc.insert(&pool).await.unwrap();
let got = PlaylistChange::get(id, &pool).await.unwrap();
assert_eq!(got.id, id);
assert_eq!(got.seq, nc.seq);
assert_eq!(got.operations.0, nc.operations);
assert_eq!(got.parent1_id, nc.parent1_id);
assert_eq!(got.parent2_id, nc.parent2_id);
}
/// Trying to insert changes with same ID and content as already existing ones
/// should result in no action
#[sqlx_database_tester::test(pool(variable = "pool"))]
async fn insert_with_id_duplicate() {
testutil::run_sql("base.sql", &pool).await;
let nc = PlaylistChangeNew {
seq: 6,
playlist_id: ids::PLAYLIST_ID_TEST1,
operations: Vec::new(),
parent1_id: Some(ids::PLAYLIST_CHANGE_BR1T),
parent2_id: Some(ids::PLAYLIST_CHANGE_BR2T),
..Default::default()
};
nc.insert_with_id(ids::PLAYLIST_CHANGE_HEAD, &pool)
.await
.unwrap();
let change = PlaylistChange::get(ids::PLAYLIST_CHANGE_HEAD, &pool)
.await
.unwrap();
assert_eq!(change.created_at, datetime!(2023-09-06 23:50:59.397384));
}
/// Trying to insert changes with same ID and different content as already existing ones
/// should result in an error
#[sqlx_database_tester::test(pool(variable = "pool"))]
async fn insert_with_id_conflict() {
testutil::run_sql("base.sql", &pool).await;
let nc = PlaylistChangeNew {
seq: 6,
playlist_id: ids::PLAYLIST_ID_TEST1,
operations: Vec::new(),
parent1_id: Some(ids::PLAYLIST_CHANGE_BR1T),
parent2_id: None,
..Default::default()
};
let err = nc
.insert_with_id(ids::PLAYLIST_CHANGE_HEAD, &pool)
.await
.unwrap_err();
if let DatabaseError::Conflict { typ, id } = err {
assert_eq!(typ, "playlist_change");
assert_eq!(id, ids::PLAYLIST_CHANGE_HEAD.to_string());
} else {
panic!("unexpected err: {err}")
}
}
#[sqlx_database_tester::test(pool(variable = "pool"))] #[sqlx_database_tester::test(pool(variable = "pool"))]
async fn paths_to_lca() { async fn paths_to_lca() {
testutil::run_sql("base.sql", &pool).await; testutil::run_sql("base.sql", &pool).await;

View file

@ -18,6 +18,4 @@ Playlist(
updated_at: "[date]", updated_at: "[date]",
last_sync_at: None, last_sync_at: None,
last_sync_data: None, last_sync_data: None,
cache_version: None,
cache_data: None,
) )

View file

@ -18,6 +18,4 @@ Playlist(
updated_at: "[date]", updated_at: "[date]",
last_sync_at: None, last_sync_at: None,
last_sync_data: None, last_sync_data: None,
cache_version: None,
cache_data: None,
) )

View file

@ -39,11 +39,12 @@ pub enum DatePrecision {
Year, Year,
} }
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
#[sqlx(type_name = "playlist_type", rename_all = "snake_case")] #[sqlx(type_name = "playlist_type", rename_all = "snake_case")]
pub enum PlaylistType { pub enum PlaylistType {
/// Local playlist created by a Tiraya user /// Local playlist created by a Tiraya user
#[default]
Local, Local,
/// Remote playlist hosted by another service /// Remote playlist hosted by another service
Remote, Remote,

View file

@ -3,7 +3,7 @@ use std::path::PathBuf;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use path_macro::path; use path_macro::path;
use crate::models::{PlaylistEntry, SrcId}; use crate::models::{PlaylistChange, PlaylistEntry, SrcId};
pub static TESTDATA: Lazy<PathBuf> = Lazy::new(|| path!(env!("CARGO_MANIFEST_DIR") / "testdata")); pub static TESTDATA: Lazy<PathBuf> = Lazy::new(|| path!(env!("CARGO_MANIFEST_DIR") / "testdata"));
@ -64,3 +64,17 @@ pub fn compare_pl_entries(entries: &[PlaylistEntry], ids: &[SrcId]) -> bool {
} }
entries.iter().zip(ids).all(|(a, b)| a.id == *b) entries.iter().zip(ids).all(|(a, b)| a.id == *b)
} }
/// Print the change graph of a playlist
///
/// Use Graphviz to visualize: <https://edotor.net/>
pub async fn print_change_graph<'a, E>(playlist_id: i32, exec: E)
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let changes = PlaylistChange::get_all_changes(playlist_id, exec)
.await
.unwrap();
let graph = PlaylistChange::change_graph(&changes);
println!("{graph}");
}