Replace literal SQL with Diesel abstractions
This commit is contained in:
4
BUILD.md
4
BUILD.md
@@ -55,8 +55,8 @@ rustup default stable
|
|||||||
|
|
||||||
No additional system packages beyond the above. Key crates use bundled/vendored native code:
|
No additional system packages beyond the above. Key crates use bundled/vendored native code:
|
||||||
|
|
||||||
- `rusqlite` with `bundled` feature compiles SQLite from source
|
- `diesel` provides the typed SQLite query layer
|
||||||
- `refinery` manages SQL migrations against rusqlite
|
- `diesel_migrations` embeds migrations; `libsqlite3-sys` bundles SQLite
|
||||||
- `iced` uses wgpu which links to Metal (macOS) or Vulkan (Linux/Windows) at runtime
|
- `iced` uses wgpu which links to Metal (macOS) or Vulkan (Linux/Windows) at runtime
|
||||||
- `muda` uses native platform menu APIs (NSMenu on macOS, GTK on Linux, Win32 on Windows)
|
- `muda` uses native platform menu APIs (NSMenu on macOS, GTK on Linux, Win32 on Windows)
|
||||||
- `rfd` uses native platform dialog APIs (NSOpenPanel on macOS, GTK on Linux, Win32 on Windows)
|
- `rfd` uses native platform dialog APIs (NSOpenPanel on macOS, GTK on Linux, Win32 on Windows)
|
||||||
|
|||||||
272
Cargo.lock
generated
272
Cargo.lock
generated
@@ -799,18 +799,19 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"deunicode",
|
"deunicode",
|
||||||
|
"diesel",
|
||||||
|
"diesel_migrations",
|
||||||
"fluent-bundle",
|
"fluent-bundle",
|
||||||
"image 0.25.10",
|
"image 0.25.10",
|
||||||
"keyring",
|
"keyring",
|
||||||
|
"libsqlite3-sys",
|
||||||
"liquid",
|
"liquid",
|
||||||
"liquid-core",
|
"liquid-core",
|
||||||
"pagefind",
|
"pagefind",
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
"quick-xml 0.41.0",
|
"quick-xml 0.41.0",
|
||||||
"rayon",
|
"rayon",
|
||||||
"refinery",
|
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rusqlite",
|
|
||||||
"rust-stemmers",
|
"rust-stemmers",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -852,7 +853,6 @@ dependencies = [
|
|||||||
"objc2-foundation 0.3.2",
|
"objc2-foundation 0.3.2",
|
||||||
"open",
|
"open",
|
||||||
"rfd",
|
"rfd",
|
||||||
"rusqlite",
|
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -1679,6 +1679,41 @@ dependencies = [
|
|||||||
"zbus 4.4.0",
|
"zbus 4.4.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling"
|
||||||
|
version = "0.21.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core",
|
||||||
|
"darling_macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_core"
|
||||||
|
version = "0.21.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
|
||||||
|
dependencies = [
|
||||||
|
"fnv",
|
||||||
|
"ident_case",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"strsim",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_macro"
|
||||||
|
version = "0.21.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "data-url"
|
name = "data-url"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -1756,6 +1791,52 @@ version = "1.6.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
|
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "diesel"
|
||||||
|
version = "2.3.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e54d1f576cd3a3460f212a4615fd12ce1b6303c095b79a44449ffbe627753dc1"
|
||||||
|
dependencies = [
|
||||||
|
"diesel_derives",
|
||||||
|
"downcast-rs 2.0.2",
|
||||||
|
"libsqlite3-sys",
|
||||||
|
"sqlite-wasm-rs",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "diesel_derives"
|
||||||
|
version = "2.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d1817b7f4279b947fc4cafddec12b0e5f8727141706561ce3ac94a60bddd1cf5"
|
||||||
|
dependencies = [
|
||||||
|
"diesel_table_macro_syntax",
|
||||||
|
"dsl_auto_type",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "diesel_migrations"
|
||||||
|
version = "2.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "28d0f4a98124ba6d4ca75da535f65984badec16a003b6e2f94a01e31a79490b8"
|
||||||
|
dependencies = [
|
||||||
|
"diesel",
|
||||||
|
"migrations_internals",
|
||||||
|
"migrations_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "diesel_table_macro_syntax"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c"
|
||||||
|
dependencies = [
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -1893,6 +1974,12 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "downcast-rs"
|
||||||
|
version = "2.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dpi"
|
name = "dpi"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -1939,6 +2026,20 @@ dependencies = [
|
|||||||
"linux-raw-sys 0.9.4",
|
"linux-raw-sys 0.9.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dsl_auto_type"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e"
|
||||||
|
dependencies = [
|
||||||
|
"darling",
|
||||||
|
"either",
|
||||||
|
"heck 0.5.0",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dtoa"
|
name = "dtoa"
|
||||||
version = "1.0.11"
|
version = "1.0.11"
|
||||||
@@ -2123,18 +2224,6 @@ dependencies = [
|
|||||||
"zune-inflate",
|
"zune-inflate",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fallible-iterator"
|
|
||||||
version = "0.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fallible-streaming-iterator"
|
|
||||||
version = "0.1.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fast-srgb8"
|
name = "fast-srgb8"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -2931,15 +3020,6 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hashlink"
|
|
||||||
version = "0.10.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
|
|
||||||
dependencies = [
|
|
||||||
"hashbrown 0.15.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hassle-rs"
|
name = "hassle-rs"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
@@ -3423,6 +3503,12 @@ version = "2.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ident_case"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -4007,9 +4093,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libsqlite3-sys"
|
name = "libsqlite3-sys"
|
||||||
version = "0.31.0"
|
version = "0.37.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ad8935b44e7c13394a179a438e0cebba0fe08fe01b54f152e29a93b5cf993fd4"
|
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
@@ -4266,6 +4352,27 @@ dependencies = [
|
|||||||
"paste",
|
"paste",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "migrations_internals"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"toml 0.9.12+spec-1.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "migrations_macros"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703"
|
||||||
|
dependencies = [
|
||||||
|
"migrations_internals",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
version = "0.3.17"
|
version = "0.3.17"
|
||||||
@@ -5996,48 +6103,6 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "refinery"
|
|
||||||
version = "0.8.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a2783724569d96af53464d0711dff635cab7a4934df5e22e9fbc9e181523b83e"
|
|
||||||
dependencies = [
|
|
||||||
"refinery-core",
|
|
||||||
"refinery-macros",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "refinery-core"
|
|
||||||
version = "0.8.16"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8a83581f18c1a4c3a6ebd7a174bdc665f17f618d79f7edccb6a0ac67e660b319"
|
|
||||||
dependencies = [
|
|
||||||
"async-trait",
|
|
||||||
"cfg-if",
|
|
||||||
"log",
|
|
||||||
"regex",
|
|
||||||
"rusqlite",
|
|
||||||
"siphasher",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
"time",
|
|
||||||
"url",
|
|
||||||
"walkdir",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "refinery-macros"
|
|
||||||
version = "0.8.15"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "096eb40b781fbe15734615c557f47ebffb50afb9f25154fdf50fc03c6ee97adb"
|
|
||||||
dependencies = [
|
|
||||||
"heck 0.5.0",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"refinery-core",
|
|
||||||
"regex",
|
|
||||||
"syn 2.0.117",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.12.3"
|
version = "1.12.3"
|
||||||
@@ -6199,17 +6264,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rusqlite"
|
name = "rsqlite-vfs"
|
||||||
version = "0.33.0"
|
version = "0.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1c6d5e5acb6f6129fe3f7ba0a7fc77bca1942cb568535e18e7bc40262baf3110"
|
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"hashbrown 0.16.1",
|
||||||
"fallible-iterator",
|
"thiserror 2.0.18",
|
||||||
"fallible-streaming-iterator",
|
|
||||||
"hashlink",
|
|
||||||
"libsqlite3-sys",
|
|
||||||
"smallvec",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6558,6 +6619,15 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_urlencoded"
|
name = "serde_urlencoded"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -6889,6 +6959,18 @@ dependencies = [
|
|||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlite-wasm-rs"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"js-sys",
|
||||||
|
"rsqlite-vfs",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -7361,11 +7443,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
|
checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_spanned",
|
"serde_spanned 0.6.9",
|
||||||
"toml_datetime 0.6.3",
|
"toml_datetime 0.6.3",
|
||||||
"toml_edit 0.20.2",
|
"toml_edit 0.20.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.9.12+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_spanned 1.1.1",
|
||||||
|
"toml_datetime 0.7.5+spec-1.1.0",
|
||||||
|
"toml_parser",
|
||||||
|
"winnow 0.7.15",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "0.6.3"
|
version = "0.6.3"
|
||||||
@@ -7375,6 +7470,15 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.7.5+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "1.1.1+spec-1.1.0"
|
version = "1.1.1+spec-1.1.0"
|
||||||
@@ -7403,7 +7507,7 @@ checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.0",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_spanned",
|
"serde_spanned 0.6.9",
|
||||||
"toml_datetime 0.6.3",
|
"toml_datetime 0.6.3",
|
||||||
"winnow 0.5.40",
|
"winnow 0.5.40",
|
||||||
]
|
]
|
||||||
@@ -7417,7 +7521,7 @@ dependencies = [
|
|||||||
"indexmap 2.13.0",
|
"indexmap 2.13.0",
|
||||||
"toml_datetime 1.1.1+spec-1.1.0",
|
"toml_datetime 1.1.1+spec-1.1.0",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"winnow 1.0.1",
|
"winnow 1.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7426,7 +7530,7 @@ version = "1.1.2+spec-1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"winnow 1.0.1",
|
"winnow 1.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8003,7 +8107,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
|
checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"downcast-rs",
|
"downcast-rs 1.2.1",
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
"scoped-tls",
|
"scoped-tls",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
@@ -8961,9 +9065,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "1.0.1"
|
version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
|
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ license = "MIT"
|
|||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# Foundation
|
# Foundation
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
rusqlite = { version = "0.33", features = ["bundled", "vtab"] }
|
|
||||||
refinery = { version = "0.8", features = ["rusqlite"] }
|
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
@@ -43,6 +41,9 @@ rayon = "1.10"
|
|||||||
pagefind = "1.5.2"
|
pagefind = "1.5.2"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||||
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }
|
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }
|
||||||
|
diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35"] }
|
||||||
|
diesel_migrations = "2.3.2"
|
||||||
|
libsqlite3-sys = { version = "0.37.0", features = ["bundled"] }
|
||||||
|
|
||||||
# UI framework
|
# UI framework
|
||||||
iced = { version = "0.13", features = ["wgpu", "advanced", "image", "svg", "tokio"] }
|
iced = { version = "0.13", features = ["wgpu", "advanced", "image", "svg", "tokio"] }
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ Rules:
|
|||||||
### `bds-core`
|
### `bds-core`
|
||||||
|
|
||||||
- ~~create workspace and crate boundaries (bds-core, bds-editor, bds-ui, bds-cli)~~ **DONE**
|
- ~~create workspace and crate boundaries (bds-core, bds-editor, bds-ui, bds-cli)~~ **DONE**
|
||||||
- ~~add SQLite connection management via `rusqlite` (bundled, vtab features)~~ **DONE**
|
- ~~add typed SQLite connection and query layer via `diesel`~~ **DONE**
|
||||||
- ~~add migration loader via `refinery`~~ **DONE** (refinery `embed_migrations!` with V1__initial_schema.sql)
|
- ~~add embedded migration loader via `diesel_migrations`~~ **DONE**
|
||||||
- ~~define initial shared model modules with `serde` derives~~ **DONE**
|
- ~~define initial shared model modules with `serde` derives~~ **DONE**
|
||||||
- ~~add checksum (`sha2`) and slug (`deunicode`) utilities~~ **DONE**
|
- ~~add checksum (`sha2`) and slug (`deunicode`) utilities~~ **DONE**
|
||||||
- ~~establish error handling conventions: `thiserror` for bds-core, `anyhow` for bds-ui/bds-cli~~ **DONE**
|
- ~~establish error handling conventions: `thiserror` for bds-core, `anyhow` for bds-ui/bds-cli~~ **DONE**
|
||||||
@@ -274,7 +274,7 @@ Rules:
|
|||||||
|
|
||||||
## Milestone M6: bDS2 Spec Parity
|
## Milestone M6: bDS2 Spec Parity
|
||||||
|
|
||||||
Code-vs-spec gaps from the 2026-07-18 spec sync. Each task names its authoritative spec. Schema changes go through proper refinery migrations, and every new metadata field must be wired into publishing, metadata-diff, and rebuild-from-filesystem together.
|
Code-vs-spec gaps from the 2026-07-18 spec sync. Each task names its authoritative spec. Schema changes go through generated Diesel migrations, and every new metadata field must be wired into publishing, metadata-diff, and rebuild-from-filesystem together.
|
||||||
|
|
||||||
### `bds-core/util`
|
### `bds-core/util`
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ These map to extension Buckets G (CLI + events) and K/L (server, TUI) in RUST_PL
|
|||||||
- **rfd for file dialogs**: cross-platform native open/save/folder dialogs. NSOpenPanel/NSSavePanel on macOS, equivalents elsewhere.
|
- **rfd for file dialogs**: cross-platform native open/save/folder dialogs. NSOpenPanel/NSSavePanel on macOS, equivalents elsewhere.
|
||||||
- **objc2 for macOS lifecycle shim** (cfg-gated): thin bridge for `application:openFile:`, `application:openURLs:`, and other `NSApplicationDelegate` hooks that muda/rfd do not cover. ~50 lines of platform code.
|
- **objc2 for macOS lifecycle shim** (cfg-gated): thin bridge for `application:openFile:`, `application:openURLs:`, and other `NSApplicationDelegate` hooks that muda/rfd do not cover. ~50 lines of platform code.
|
||||||
- **Custom editor widget (bds-editor crate)**: syntax-highlighting markdown/Liquid/Lua editor built on ropey (rope buffer), syntect (syntax highlighting), and cosmic-text (font shaping and text layout). This is the highest-risk custom component and gets a proof-of-concept in Wave 0.
|
- **Custom editor widget (bds-editor crate)**: syntax-highlighting markdown/Liquid/Lua editor built on ropey (rope buffer), syntect (syntax highlighting), and cosmic-text (font shaping and text layout). This is the highest-risk custom component and gets a proof-of-concept in Wave 0.
|
||||||
- **rusqlite + embedded SQL migrations**: no ORM. SQL stays explicit. Migrations managed via `refinery`.
|
- **Diesel + embedded migrations**: typed SQLite queries and generated schema, with migrations managed by `diesel_migrations`. Backend-only SQL is confined to connection setup and FTS5 operations.
|
||||||
- **tokio as the async runtime**: preview server (axum), SSH publishing, file watching, and rfd async dialogs all require an async executor. tokio is the standard choice and is used workspace-wide. Synchronous engine code in bds-core does not use tokio directly — it remains callable from both async (bds-ui) and sync (bds-cli) contexts.
|
- **tokio as the async runtime**: preview server (axum), SSH publishing, file watching, and rfd async dialogs all require an async executor. tokio is the standard choice and is used workspace-wide. Synchronous engine code in bds-core does not use tokio directly — it remains callable from both async (bds-ui) and sync (bds-cli) contexts.
|
||||||
- **Plain-text markdown editor first**: no WYSIWYG in core. Live preview is required. This is an intentional regression from the baseline app's rich editor. A rich editor can be added as an extension bucket after core ships — and the bds-editor widget provides a natural foundation for it.
|
- **Plain-text markdown editor first**: no WYSIWYG in core. Live preview is required. This is an intentional regression from the baseline app's rich editor. A rich editor can be added as an extension bucket after core ships — and the bds-editor widget provides a natural foundation for it.
|
||||||
- **Lua for user-authored scripting**: `mlua` with Lua 5.4. Only user-authored macros, transforms, and utility scripts run through Lua. Built-in macros are native Rust — see the macro architecture section below.
|
- **Lua for user-authored scripting**: `mlua` with Lua 5.4. Only user-authored macros, transforms, and utility scripts run through Lua. Built-in macros are native Rust — see the macro architecture section below.
|
||||||
@@ -311,7 +311,7 @@ The Rust app uses `deunicode` for Unicode-to-ASCII conversion in slugs; it must
|
|||||||
|
|
||||||
The app has two independent search systems — do not conflate them:
|
The app has two independent search systems — do not conflate them:
|
||||||
|
|
||||||
1. **FTS5 (in-app search)**: SQLite FTS5 virtual tables (`posts_fts`, `media_fts`) power the desktop app's search UI. These require custom Snowball tokenizers registered via `rusqlite`'s `fts5_tokenizer` API (using the `rust-stemmers` crate) for 24-language stemmed search. Both indexing and query-time stemming must match the content language. This is Wave 1 scope.
|
1. **FTS5 (in-app search)**: SQLite FTS5 virtual tables (`posts_fts`, `media_fts`) power the desktop app's search UI. Text is Snowball-stemmed in application code before indexing and querying. FTS5 virtual-table and `MATCH` operations form the explicitly isolated raw-SQL backend boundary; ordinary filtering uses Diesel's typed query builder. This is Wave 1 scope.
|
||||||
|
|
||||||
2. **Pagefind (generated site search)**: a client-side search index bundled with the generated static site. Pagefind indexes the generated HTML files and produces JavaScript/WASM artifacts that power search on the published website. This is Wave 4 scope, added to the generation pipeline via the `pagefind` crate's Rust library API.
|
2. **Pagefind (generated site search)**: a client-side search index bundled with the generated static site. Pagefind indexes the generated HTML files and produces JavaScript/WASM artifacts that power search on the published website. This is Wave 4 scope, added to the generation pipeline via the `pagefind` crate's Rust library API.
|
||||||
|
|
||||||
@@ -333,8 +333,9 @@ All crate choices for core scope, organized by subsystem. This prevents ad-hoc t
|
|||||||
|
|
||||||
| Crate | Purpose | Notes |
|
| Crate | Purpose | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `rusqlite` (bundled, vtab) | SQLite database access | Bundled compiles SQLite from source, vtab enables FTS5 |
|
| `diesel` (sqlite) | Typed SQLite database access | Query builder and generated schema |
|
||||||
| `refinery` | SQL migration management | Replaces hand-rolled migration loader |
|
| `diesel_migrations` | Embedded migration management | Runs generated Diesel migrations at startup |
|
||||||
|
| `libsqlite3-sys` (bundled) | SQLite native library | Compiles SQLite from source with FTS5 |
|
||||||
| `uuid` (v4) | Entity identifiers | |
|
| `uuid` (v4) | Entity identifiers | |
|
||||||
| `serde` + `serde_json` | Serialization/deserialization | Used everywhere |
|
| `serde` + `serde_json` | Serialization/deserialization | Used everywhere |
|
||||||
| `serde_yaml` | YAML frontmatter parsing/writing | Posts, translations, media sidecars |
|
| `serde_yaml` | YAML frontmatter parsing/writing | Posts, translations, media sidecars |
|
||||||
@@ -504,8 +505,9 @@ Core release happens only after M5.
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
# Core
|
# Core
|
||||||
rusqlite = { version = "0.33", features = ["bundled", "vtab"] }
|
diesel = { version = "2.3", features = ["sqlite", "returning_clauses_for_sqlite_3_35"] }
|
||||||
refinery = { version = "0.8", features = ["rusqlite"] }
|
diesel_migrations = "2.3"
|
||||||
|
libsqlite3-sys = { version = "0.37", features = ["bundled"] }
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
@@ -247,8 +247,8 @@ Alle kritischen Lücken geschlossen:
|
|||||||
|
|
||||||
### Wave 0 (Foundation)
|
### Wave 0 (Foundation)
|
||||||
1. Cargo-Workspace aufsetzen (bds-core, bds-editor, bds-ui, bds-cli)
|
1. Cargo-Workspace aufsetzen (bds-core, bds-editor, bds-ui, bds-cli)
|
||||||
2. SQLite-Connection mit rusqlite (bundled, vtab)
|
2. SQLite-Connection mit Diesel (gebündeltes SQLite)
|
||||||
3. Refinery-Migration-Loader
|
3. Eingebettete Diesel-Migrationen
|
||||||
4. bds-editor PoC (ropey + syntect + cosmic-text)
|
4. bds-editor PoC (ropey + syntect + cosmic-text)
|
||||||
5. Iced App Shell mit muda-Menüs
|
5. Iced App Shell mit muda-Menüs
|
||||||
6. Slug-Compatibility-Tests (deunicode vs transliteration)
|
6. Slug-Compatibility-Tests (deunicode vs transliteration)
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ version.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rusqlite = { workspace = true }
|
|
||||||
refinery = { workspace = true }
|
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
@@ -30,6 +28,9 @@ reqwest = { workspace = true }
|
|||||||
keyring = { workspace = true }
|
keyring = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
|
diesel = { workspace = true }
|
||||||
|
diesel_migrations = { workspace = true }
|
||||||
|
libsqlite3-sys = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
3
crates/bds-core/build.rs
Normal file
3
crates/bds-core/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=migrations");
|
||||||
|
}
|
||||||
0
crates/bds-core/migrations/.diesel_lock
Normal file
0
crates/bds-core/migrations/.diesel_lock
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
DROP TABLE IF EXISTS db_notifications;
|
||||||
|
DROP TABLE IF EXISTS import_definitions;
|
||||||
|
DROP TABLE IF EXISTS dismissed_duplicate_pairs;
|
||||||
|
DROP TABLE IF EXISTS embedding_keys;
|
||||||
|
DROP TABLE IF EXISTS ai_catalog_meta;
|
||||||
|
DROP TABLE IF EXISTS ai_model_modalities;
|
||||||
|
DROP TABLE IF EXISTS ai_models;
|
||||||
|
DROP TABLE IF EXISTS ai_providers;
|
||||||
|
DROP TABLE IF EXISTS chat_messages;
|
||||||
|
DROP TABLE IF EXISTS chat_conversations;
|
||||||
|
DROP TABLE IF EXISTS generated_file_hashes;
|
||||||
|
DROP TABLE IF EXISTS settings;
|
||||||
|
DROP TABLE IF EXISTS post_media;
|
||||||
|
DROP TABLE IF EXISTS post_links;
|
||||||
|
DROP TABLE IF EXISTS scripts;
|
||||||
|
DROP TABLE IF EXISTS templates;
|
||||||
|
DROP TABLE IF EXISTS tags;
|
||||||
|
DROP TABLE IF EXISTS media_translations;
|
||||||
|
DROP TABLE IF EXISTS media;
|
||||||
|
DROP TABLE IF EXISTS post_translations;
|
||||||
|
DROP TABLE IF EXISTS posts;
|
||||||
|
DROP TABLE IF EXISTS projects;
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
-- ================================================================
|
-- Generated with Diesel CLI, then completed with the constraints and defaults
|
||||||
-- CORE ENTITIES
|
-- that SQLite's schema introspection cannot reproduce.
|
||||||
-- ================================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS projects (
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
slug TEXT NOT NULL UNIQUE,
|
slug TEXT NOT NULL UNIQUE,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
@@ -14,7 +13,7 @@ CREATE TABLE IF NOT EXISTS projects (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS posts (
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
slug TEXT NOT NULL,
|
slug TEXT NOT NULL,
|
||||||
@@ -43,7 +42,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS posts_project_slug_idx
|
|||||||
ON posts(project_id, slug);
|
ON posts(project_id, slug);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS post_translations (
|
CREATE TABLE IF NOT EXISTS post_translations (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||||
translation_for TEXT NOT NULL REFERENCES posts(id),
|
translation_for TEXT NOT NULL REFERENCES posts(id),
|
||||||
language TEXT NOT NULL,
|
language TEXT NOT NULL,
|
||||||
@@ -62,7 +61,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS post_translations_translation_language_idx
|
|||||||
ON post_translations(translation_for, language);
|
ON post_translations(translation_for, language);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS media (
|
CREATE TABLE IF NOT EXISTS media (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||||
filename TEXT NOT NULL,
|
filename TEXT NOT NULL,
|
||||||
original_name TEXT NOT NULL,
|
original_name TEXT NOT NULL,
|
||||||
@@ -84,7 +83,7 @@ CREATE TABLE IF NOT EXISTS media (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS media_translations (
|
CREATE TABLE IF NOT EXISTS media_translations (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||||
translation_for TEXT NOT NULL REFERENCES media(id),
|
translation_for TEXT NOT NULL REFERENCES media(id),
|
||||||
language TEXT NOT NULL,
|
language TEXT NOT NULL,
|
||||||
@@ -99,7 +98,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS media_translations_translation_language_idx
|
|||||||
ON media_translations(translation_for, language);
|
ON media_translations(translation_for, language);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tags (
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
color TEXT,
|
color TEXT,
|
||||||
@@ -109,10 +108,10 @@ CREATE TABLE IF NOT EXISTS tags (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS tags_project_name_idx
|
CREATE UNIQUE INDEX IF NOT EXISTS tags_project_name_idx
|
||||||
ON tags(project_id, name);
|
ON tags(project_id, name COLLATE NOCASE);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS templates (
|
CREATE TABLE IF NOT EXISTS templates (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||||
slug TEXT NOT NULL,
|
slug TEXT NOT NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
@@ -120,7 +119,7 @@ CREATE TABLE IF NOT EXISTS templates (
|
|||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
version INTEGER NOT NULL DEFAULT 1,
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
file_path TEXT NOT NULL,
|
file_path TEXT NOT NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'published',
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
content TEXT,
|
content TEXT,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
@@ -130,7 +129,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS templates_project_slug_idx
|
|||||||
ON templates(project_id, slug);
|
ON templates(project_id, slug);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS scripts (
|
CREATE TABLE IF NOT EXISTS scripts (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||||
slug TEXT NOT NULL,
|
slug TEXT NOT NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
@@ -139,7 +138,7 @@ CREATE TABLE IF NOT EXISTS scripts (
|
|||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
version INTEGER NOT NULL DEFAULT 1,
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
file_path TEXT NOT NULL,
|
file_path TEXT NOT NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'published',
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
content TEXT,
|
content TEXT,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
@@ -148,12 +147,8 @@ CREATE TABLE IF NOT EXISTS scripts (
|
|||||||
CREATE UNIQUE INDEX IF NOT EXISTS scripts_project_slug_idx
|
CREATE UNIQUE INDEX IF NOT EXISTS scripts_project_slug_idx
|
||||||
ON scripts(project_id, slug);
|
ON scripts(project_id, slug);
|
||||||
|
|
||||||
-- ================================================================
|
|
||||||
-- RELATIONSHIP TABLES
|
|
||||||
-- ================================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS post_links (
|
CREATE TABLE IF NOT EXISTS post_links (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
source_post_id TEXT NOT NULL REFERENCES posts(id),
|
source_post_id TEXT NOT NULL REFERENCES posts(id),
|
||||||
target_post_id TEXT NOT NULL REFERENCES posts(id),
|
target_post_id TEXT NOT NULL REFERENCES posts(id),
|
||||||
link_text TEXT,
|
link_text TEXT,
|
||||||
@@ -161,7 +156,7 @@ CREATE TABLE IF NOT EXISTS post_links (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS post_media (
|
CREATE TABLE IF NOT EXISTS post_media (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||||
post_id TEXT NOT NULL REFERENCES posts(id),
|
post_id TEXT NOT NULL REFERENCES posts(id),
|
||||||
media_id TEXT NOT NULL REFERENCES media(id),
|
media_id TEXT NOT NULL REFERENCES media(id),
|
||||||
@@ -172,12 +167,8 @@ CREATE TABLE IF NOT EXISTS post_media (
|
|||||||
CREATE UNIQUE INDEX IF NOT EXISTS post_media_post_media_idx
|
CREATE UNIQUE INDEX IF NOT EXISTS post_media_post_media_idx
|
||||||
ON post_media(post_id, media_id);
|
ON post_media(post_id, media_id);
|
||||||
|
|
||||||
-- ================================================================
|
|
||||||
-- METADATA TABLES
|
|
||||||
-- ================================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT NOT NULL PRIMARY KEY,
|
||||||
value TEXT NOT NULL,
|
value TEXT NOT NULL,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
@@ -192,12 +183,8 @@ CREATE TABLE IF NOT EXISTS generated_file_hashes (
|
|||||||
CREATE UNIQUE INDEX IF NOT EXISTS generated_file_hashes_project_path_idx
|
CREATE UNIQUE INDEX IF NOT EXISTS generated_file_hashes_project_path_idx
|
||||||
ON generated_file_hashes(project_id, relative_path);
|
ON generated_file_hashes(project_id, relative_path);
|
||||||
|
|
||||||
-- ================================================================
|
|
||||||
-- AI / CHAT TABLES (read-only in Rust core, must not error)
|
|
||||||
-- ================================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS chat_conversations (
|
CREATE TABLE IF NOT EXISTS chat_conversations (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
model TEXT,
|
model TEXT,
|
||||||
copilot_session_id TEXT,
|
copilot_session_id TEXT,
|
||||||
@@ -206,20 +193,22 @@ CREATE TABLE IF NOT EXISTS chat_conversations (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
conversation_id TEXT NOT NULL REFERENCES chat_conversations(id),
|
conversation_id TEXT NOT NULL REFERENCES chat_conversations(id),
|
||||||
role TEXT NOT NULL,
|
role TEXT NOT NULL,
|
||||||
content TEXT,
|
content TEXT,
|
||||||
tool_call_id TEXT,
|
tool_call_id TEXT,
|
||||||
tool_calls TEXT,
|
tool_calls TEXT,
|
||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL,
|
||||||
|
cache_read_tokens INTEGER,
|
||||||
|
cache_write_tokens INTEGER
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ai_providers (
|
CREATE TABLE IF NOT EXISTS ai_providers (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
env TEXT,
|
env TEXT,
|
||||||
npm TEXT,
|
package_ref TEXT,
|
||||||
api TEXT,
|
api TEXT,
|
||||||
doc TEXT,
|
doc TEXT,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
@@ -248,7 +237,7 @@ CREATE TABLE IF NOT EXISTS ai_models (
|
|||||||
max_output_tokens INTEGER NOT NULL DEFAULT 0,
|
max_output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
interleaved TEXT,
|
interleaved TEXT,
|
||||||
status TEXT,
|
status TEXT,
|
||||||
provider_npm TEXT,
|
provider_package_ref TEXT,
|
||||||
updated_at INTEGER NOT NULL,
|
updated_at INTEGER NOT NULL,
|
||||||
PRIMARY KEY (provider, model_id)
|
PRIMARY KEY (provider, model_id)
|
||||||
);
|
);
|
||||||
@@ -262,16 +251,12 @@ CREATE TABLE IF NOT EXISTS ai_model_modalities (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ai_catalog_meta (
|
CREATE TABLE IF NOT EXISTS ai_catalog_meta (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT NOT NULL PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ================================================================
|
|
||||||
-- EMBEDDINGS TABLES (read-only in Rust core, must not error)
|
|
||||||
-- ================================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS embedding_keys (
|
CREATE TABLE IF NOT EXISTS embedding_keys (
|
||||||
label INTEGER PRIMARY KEY,
|
label INTEGER NOT NULL PRIMARY KEY,
|
||||||
post_id TEXT NOT NULL,
|
post_id TEXT NOT NULL,
|
||||||
project_id TEXT NOT NULL,
|
project_id TEXT NOT NULL,
|
||||||
content_hash TEXT NOT NULL,
|
content_hash TEXT NOT NULL,
|
||||||
@@ -279,7 +264,7 @@ CREATE TABLE IF NOT EXISTS embedding_keys (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS dismissed_duplicate_pairs (
|
CREATE TABLE IF NOT EXISTS dismissed_duplicate_pairs (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||||
post_id_a TEXT NOT NULL,
|
post_id_a TEXT NOT NULL,
|
||||||
post_id_b TEXT NOT NULL,
|
post_id_b TEXT NOT NULL,
|
||||||
@@ -289,12 +274,8 @@ CREATE TABLE IF NOT EXISTS dismissed_duplicate_pairs (
|
|||||||
CREATE UNIQUE INDEX IF NOT EXISTS dismissed_pairs_idx
|
CREATE UNIQUE INDEX IF NOT EXISTS dismissed_pairs_idx
|
||||||
ON dismissed_duplicate_pairs(project_id, post_id_a, post_id_b);
|
ON dismissed_duplicate_pairs(project_id, post_id_a, post_id_b);
|
||||||
|
|
||||||
-- ================================================================
|
|
||||||
-- IMPORT TABLES (read-only in Rust core, must not error)
|
|
||||||
-- ================================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS import_definitions (
|
CREATE TABLE IF NOT EXISTS import_definitions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
wxr_file_path TEXT,
|
wxr_file_path TEXT,
|
||||||
@@ -304,12 +285,8 @@ CREATE TABLE IF NOT EXISTS import_definitions (
|
|||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ================================================================
|
|
||||||
-- NOTIFICATION TABLES
|
|
||||||
-- ================================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS db_notifications (
|
CREATE TABLE IF NOT EXISTS db_notifications (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
entity_type TEXT NOT NULL,
|
entity_type TEXT NOT NULL,
|
||||||
entity_id TEXT NOT NULL,
|
entity_id TEXT NOT NULL,
|
||||||
action TEXT NOT NULL,
|
action TEXT NOT NULL,
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
-- Fix script and template default status from 'published' to 'draft'
|
|
||||||
-- and make tag name uniqueness case-insensitive.
|
|
||||||
--
|
|
||||||
-- SQLite cannot ALTER COLUMN defaults, so we recreate the affected tables.
|
|
||||||
|
|
||||||
-- ── Scripts: default status 'published' → 'draft' ──
|
|
||||||
|
|
||||||
CREATE TABLE scripts_new (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
|
||||||
slug TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
kind TEXT NOT NULL DEFAULT 'utility',
|
|
||||||
entrypoint TEXT NOT NULL DEFAULT 'render',
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
version INTEGER NOT NULL DEFAULT 1,
|
|
||||||
file_path TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'draft',
|
|
||||||
content TEXT,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO scripts_new SELECT * FROM scripts;
|
|
||||||
DROP TABLE scripts;
|
|
||||||
ALTER TABLE scripts_new RENAME TO scripts;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS scripts_project_slug_idx
|
|
||||||
ON scripts(project_id, slug);
|
|
||||||
|
|
||||||
-- ── Templates: default status 'published' → 'draft' ──
|
|
||||||
|
|
||||||
CREATE TABLE templates_new (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
|
||||||
slug TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
kind TEXT NOT NULL DEFAULT 'post',
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
version INTEGER NOT NULL DEFAULT 1,
|
|
||||||
file_path TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'draft',
|
|
||||||
content TEXT,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO templates_new SELECT * FROM templates;
|
|
||||||
DROP TABLE templates;
|
|
||||||
ALTER TABLE templates_new RENAME TO templates;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS templates_project_slug_idx
|
|
||||||
ON templates(project_id, slug);
|
|
||||||
|
|
||||||
-- ── Tags: case-insensitive unique index ──
|
|
||||||
|
|
||||||
DROP INDEX IF EXISTS tags_project_name_idx;
|
|
||||||
CREATE UNIQUE INDEX tags_project_name_idx ON tags(project_id, name COLLATE NOCASE);
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
ALTER TABLE chat_messages ADD COLUMN cache_read_tokens INTEGER;
|
|
||||||
ALTER TABLE chat_messages ADD COLUMN cache_write_tokens INTEGER;
|
|
||||||
|
|
||||||
ALTER TABLE ai_providers RENAME COLUMN npm TO package_ref;
|
|
||||||
ALTER TABLE ai_models RENAME COLUMN provider_npm TO provider_package_ref;
|
|
||||||
@@ -1,37 +1,88 @@
|
|||||||
use crate::db::migrations;
|
use std::cell::RefCell;
|
||||||
use rusqlite::Connection;
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
use diesel::connection::SimpleConnection;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
use crate::db::migrations;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum DatabaseError {
|
||||||
|
#[error("{0}")]
|
||||||
|
Connection(#[from] diesel::ConnectionError),
|
||||||
|
#[error("{0}")]
|
||||||
|
Query(#[from] diesel::result::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared synchronous Diesel connection used by the engine query API.
|
||||||
|
pub struct DbConnection(RefCell<SqliteConnection>);
|
||||||
|
|
||||||
|
impl DbConnection {
|
||||||
|
pub fn with<T>(
|
||||||
|
&self,
|
||||||
|
operation: impl FnOnce(&mut SqliteConnection) -> diesel::QueryResult<T>,
|
||||||
|
) -> diesel::QueryResult<T> {
|
||||||
|
operation(&mut self.0.borrow_mut())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn with_migrations<T>(
|
||||||
|
&self,
|
||||||
|
operation: impl FnOnce(&mut SqliteConnection) -> T,
|
||||||
|
) -> T {
|
||||||
|
operation(&mut self.0.borrow_mut())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn begin_savepoint(&self) -> diesel::QueryResult<()> {
|
||||||
|
self.0.borrow_mut().batch_execute("SAVEPOINT bds_operation")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn release_savepoint(&self) -> diesel::QueryResult<()> {
|
||||||
|
self.0.borrow_mut().batch_execute("RELEASE bds_operation")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn rollback_savepoint(&self) -> diesel::QueryResult<()> {
|
||||||
|
self.0
|
||||||
|
.borrow_mut()
|
||||||
|
.batch_execute("ROLLBACK TO bds_operation; RELEASE bds_operation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Database wrapper managing a SQLite connection.
|
/// Database wrapper managing a SQLite connection.
|
||||||
pub struct Database {
|
pub struct Database {
|
||||||
conn: Connection,
|
conn: DbConnection,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
/// Open an existing bDS project database.
|
/// Open an existing bDS project database.
|
||||||
pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
|
pub fn open(path: &Path) -> Result<Self, DatabaseError> {
|
||||||
let conn = Connection::open(path)?;
|
Self::establish(path.to_string_lossy().as_ref(), true)
|
||||||
conn.execute_batch(
|
|
||||||
"PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;",
|
|
||||||
)?;
|
|
||||||
Ok(Self { conn })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open an in-memory database (for tests).
|
/// Open an in-memory database (for tests).
|
||||||
pub fn open_in_memory() -> Result<Self, rusqlite::Error> {
|
pub fn open_in_memory() -> Result<Self, DatabaseError> {
|
||||||
let conn = Connection::open_in_memory()?;
|
Self::establish(":memory:", false)
|
||||||
conn.execute_batch("PRAGMA foreign_keys=ON;")?;
|
|
||||||
Ok(Self { conn })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a reference to the underlying connection.
|
fn establish(database_url: &str, wal: bool) -> Result<Self, DatabaseError> {
|
||||||
pub fn conn(&self) -> &Connection {
|
let mut conn = SqliteConnection::establish(database_url)?;
|
||||||
|
// SQLite connection configuration is backend-specific and not expressible in Diesel's DSL.
|
||||||
|
conn.batch_execute(if wal {
|
||||||
|
"PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;"
|
||||||
|
} else {
|
||||||
|
"PRAGMA foreign_keys=ON;"
|
||||||
|
})?;
|
||||||
|
Ok(Self {
|
||||||
|
conn: DbConnection(RefCell::new(conn)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn conn(&self) -> &DbConnection {
|
||||||
&self.conn
|
&self.conn
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run all pending migrations via refinery.
|
/// Run all pending embedded Diesel migrations.
|
||||||
pub fn migrate(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
pub fn migrate(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
migrations::run_migrations(&mut self.conn)
|
migrations::run_migrations(&self.conn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,9 +93,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn open_in_memory() {
|
fn open_in_memory() {
|
||||||
let db = Database::open_in_memory().expect("should open in-memory db");
|
let db = Database::open_in_memory().expect("should open in-memory db");
|
||||||
let result: i64 = db
|
let result = db
|
||||||
.conn()
|
.conn()
|
||||||
.query_row("SELECT 1", [], |row| row.get(0))
|
.with(|conn| {
|
||||||
|
diesel::select(1.into_sql::<diesel::sql_types::Integer>()).get_result::<i32>(conn)
|
||||||
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(result, 1);
|
assert_eq!(result, 1);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,57 @@
|
|||||||
use rusqlite::Connection;
|
use diesel::connection::SimpleConnection;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use diesel::sql_types::{BigInt, Text};
|
||||||
|
use diesel::sqlite::Sqlite;
|
||||||
use rust_stemmers::{Algorithm, Stemmer};
|
use rust_stemmers::{Algorithm, Stemmer};
|
||||||
|
|
||||||
|
use crate::db::DbConnection as Connection;
|
||||||
|
use crate::db::schema::{post_translations, posts};
|
||||||
use crate::util::calendar_range_unix_ms;
|
use crate::util::calendar_range_unix_ms;
|
||||||
|
|
||||||
|
diesel::define_sql_function!(fn instr(haystack: Text, needle: Text) -> diesel::sql_types::Integer);
|
||||||
|
diesel::define_sql_function!(fn lower(value: Text) -> Text);
|
||||||
|
|
||||||
|
#[derive(QueryableByName)]
|
||||||
|
#[diesel(check_for_backend(Sqlite))]
|
||||||
|
struct PostIdRow {
|
||||||
|
#[diesel(sql_type = Text)]
|
||||||
|
post_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(QueryableByName)]
|
||||||
|
#[diesel(check_for_backend(Sqlite))]
|
||||||
|
struct MediaIdRow {
|
||||||
|
#[diesel(sql_type = Text)]
|
||||||
|
media_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(QueryableByName)]
|
||||||
|
#[diesel(check_for_backend(Sqlite))]
|
||||||
|
struct TableCountRow {
|
||||||
|
#[diesel(sql_type = BigInt)]
|
||||||
|
count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether both application FTS5 virtual tables are present.
|
||||||
|
pub fn tables_exist(conn: &Connection) -> QueryResult<bool> {
|
||||||
|
conn.with(|c| {
|
||||||
|
diesel::sql_query(
|
||||||
|
"SELECT COUNT(*) AS count FROM sqlite_master \
|
||||||
|
WHERE type = 'table' AND name IN ('posts_fts', 'media_fts')",
|
||||||
|
)
|
||||||
|
.get_result::<TableCountRow>(c)
|
||||||
|
.map(|row| row.count == 2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Create FTS5 virtual tables at runtime (not in migrations per spec).
|
/// Create FTS5 virtual tables at runtime (not in migrations per spec).
|
||||||
///
|
///
|
||||||
/// Schema follows specs/schema.allium: multi-column FTS5 with separate fields
|
/// Schema follows specs/schema.allium: multi-column FTS5 with separate fields
|
||||||
/// for weighted search. Not content-sync — we manually manage stemmed content.
|
/// for weighted search. Not content-sync — we manually manage stemmed content.
|
||||||
pub fn ensure_fts_tables(conn: &Connection) -> rusqlite::Result<()> {
|
pub fn ensure_fts_tables(conn: &Connection) -> QueryResult<()> {
|
||||||
conn.execute_batch(
|
conn.with(|c| {
|
||||||
"CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
|
c.batch_execute(
|
||||||
|
"CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
|
||||||
post_id UNINDEXED,
|
post_id UNINDEXED,
|
||||||
title,
|
title,
|
||||||
excerpt,
|
excerpt,
|
||||||
@@ -25,8 +67,16 @@ pub fn ensure_fts_tables(conn: &Connection) -> rusqlite::Result<()> {
|
|||||||
original_name,
|
original_name,
|
||||||
tags
|
tags
|
||||||
);",
|
);",
|
||||||
)?;
|
)
|
||||||
Ok(())
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_indexes(conn: &Connection) -> QueryResult<()> {
|
||||||
|
conn.with(|c| c.batch_execute("DELETE FROM posts_fts; DELETE FROM media_fts;"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn drop_post_index(conn: &Connection) -> QueryResult<()> {
|
||||||
|
conn.with(|c| c.batch_execute("DROP TABLE posts_fts"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map ISO 639-1 language code to Snowball stemmer algorithm.
|
/// Map ISO 639-1 language code to Snowball stemmer algorithm.
|
||||||
@@ -98,7 +148,7 @@ pub fn index_post(
|
|||||||
categories: &[String],
|
categories: &[String],
|
||||||
translations: &[PostTranslationFts],
|
translations: &[PostTranslationFts],
|
||||||
language: &str,
|
language: &str,
|
||||||
) -> rusqlite::Result<()> {
|
) -> QueryResult<()> {
|
||||||
// Remove existing entry
|
// Remove existing entry
|
||||||
remove_post_from_index(conn, post_id)?;
|
remove_post_from_index(conn, post_id)?;
|
||||||
|
|
||||||
@@ -133,11 +183,10 @@ pub fn index_post(
|
|||||||
let stemmed_tags = stem_text(&tags.join(" "), language);
|
let stemmed_tags = stem_text(&tags.join(" "), language);
|
||||||
let stemmed_categories = stem_text(&categories.join(" "), language);
|
let stemmed_categories = stem_text(&categories.join(" "), language);
|
||||||
|
|
||||||
conn.execute(
|
conn.with(|c| diesel::sql_query("INSERT INTO posts_fts (post_id, title, excerpt, content, tags, categories) VALUES (?, ?, ?, ?, ?, ?)")
|
||||||
"INSERT INTO posts_fts (post_id, title, excerpt, content, tags, categories) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
.bind::<Text, _>(post_id).bind::<Text, _>(stemmed_title).bind::<Text, _>(stemmed_excerpt)
|
||||||
rusqlite::params![post_id, stemmed_title, stemmed_excerpt, stemmed_content, stemmed_tags, stemmed_categories],
|
.bind::<Text, _>(stemmed_content).bind::<Text, _>(stemmed_tags).bind::<Text, _>(stemmed_categories)
|
||||||
)?;
|
.execute(c).map(|_| ()))
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Index a media item in the FTS table with separate columns per spec.
|
/// Index a media item in the FTS table with separate columns per spec.
|
||||||
@@ -157,7 +206,7 @@ pub fn index_media(
|
|||||||
tags: &[String],
|
tags: &[String],
|
||||||
translations: &[MediaTranslationFts],
|
translations: &[MediaTranslationFts],
|
||||||
language: &str,
|
language: &str,
|
||||||
) -> rusqlite::Result<()> {
|
) -> QueryResult<()> {
|
||||||
remove_media_from_index(conn, media_id)?;
|
remove_media_from_index(conn, media_id)?;
|
||||||
|
|
||||||
// Title column: media title + all translation titles
|
// Title column: media title + all translation titles
|
||||||
@@ -193,42 +242,41 @@ pub fn index_media(
|
|||||||
let stemmed_name = stem_text(original_name, language);
|
let stemmed_name = stem_text(original_name, language);
|
||||||
let stemmed_tags = stem_text(&tags.join(" "), language);
|
let stemmed_tags = stem_text(&tags.join(" "), language);
|
||||||
|
|
||||||
conn.execute(
|
conn.with(|c| diesel::sql_query("INSERT INTO media_fts (media_id, title, alt, caption, original_name, tags) VALUES (?, ?, ?, ?, ?, ?)")
|
||||||
"INSERT INTO media_fts (media_id, title, alt, caption, original_name, tags) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
.bind::<Text, _>(media_id).bind::<Text, _>(stemmed_title).bind::<Text, _>(stemmed_alt)
|
||||||
rusqlite::params![media_id, stemmed_title, stemmed_alt, stemmed_caption, stemmed_name, stemmed_tags],
|
.bind::<Text, _>(stemmed_caption).bind::<Text, _>(stemmed_name).bind::<Text, _>(stemmed_tags)
|
||||||
)?;
|
.execute(c).map(|_| ()))
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a post from the FTS index.
|
/// Remove a post from the FTS index.
|
||||||
pub fn remove_post_from_index(conn: &Connection, post_id: &str) -> rusqlite::Result<()> {
|
pub fn remove_post_from_index(conn: &Connection, post_id: &str) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"DELETE FROM posts_fts WHERE post_id = ?1",
|
diesel::sql_query("DELETE FROM posts_fts WHERE post_id = ?")
|
||||||
rusqlite::params![post_id],
|
.bind::<Text, _>(post_id)
|
||||||
)?;
|
.execute(c)
|
||||||
Ok(())
|
.map(|_| ())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a media item from the FTS index.
|
/// Remove a media item from the FTS index.
|
||||||
pub fn remove_media_from_index(conn: &Connection, media_id: &str) -> rusqlite::Result<()> {
|
pub fn remove_media_from_index(conn: &Connection, media_id: &str) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"DELETE FROM media_fts WHERE media_id = ?1",
|
diesel::sql_query("DELETE FROM media_fts WHERE media_id = ?")
|
||||||
rusqlite::params![media_id],
|
.bind::<Text, _>(media_id)
|
||||||
)?;
|
.execute(c)
|
||||||
Ok(())
|
.map(|_| ())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Search posts by full-text query. Returns matching post IDs.
|
/// Search posts by full-text query. Returns matching post IDs.
|
||||||
pub fn search_posts(
|
pub fn search_posts(conn: &Connection, query: &str, language: &str) -> QueryResult<Vec<String>> {
|
||||||
conn: &Connection,
|
|
||||||
query: &str,
|
|
||||||
language: &str,
|
|
||||||
) -> rusqlite::Result<Vec<String>> {
|
|
||||||
let stemmed = stem_text(query, language);
|
let stemmed = stem_text(query, language);
|
||||||
let mut stmt =
|
conn.with(|c| {
|
||||||
conn.prepare("SELECT post_id FROM posts_fts WHERE posts_fts MATCH ?1 ORDER BY rank")?;
|
diesel::sql_query("SELECT post_id FROM posts_fts WHERE posts_fts MATCH ? ORDER BY rank")
|
||||||
let rows = stmt.query_map(rusqlite::params![stemmed], |row| row.get::<_, String>(0))?;
|
.bind::<Text, _>(stemmed)
|
||||||
rows.collect()
|
.load::<PostIdRow>(c)
|
||||||
|
.map(|rows| rows.into_iter().map(|row| row.post_id).collect())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Filters for post search.
|
/// Filters for post search.
|
||||||
@@ -262,7 +310,7 @@ pub fn search_posts_filtered(
|
|||||||
query: &str,
|
query: &str,
|
||||||
language: &str,
|
language: &str,
|
||||||
filters: &PostSearchFilters,
|
filters: &PostSearchFilters,
|
||||||
) -> rusqlite::Result<SearchResults> {
|
) -> QueryResult<SearchResults> {
|
||||||
// Get FTS matches first
|
// Get FTS matches first
|
||||||
let fts_ids = search_posts(conn, query, language)?;
|
let fts_ids = search_posts(conn, query, language)?;
|
||||||
if fts_ids.is_empty() {
|
if fts_ids.is_empty() {
|
||||||
@@ -274,115 +322,64 @@ pub fn search_posts_filtered(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply filters by querying posts table
|
|
||||||
let placeholders: Vec<String> = fts_ids
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, _)| format!("?{}", i + 1))
|
|
||||||
.collect();
|
|
||||||
let mut sql = format!(
|
|
||||||
"SELECT id FROM posts WHERE id IN ({}) ",
|
|
||||||
placeholders.join(",")
|
|
||||||
);
|
|
||||||
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = fts_ids
|
|
||||||
.iter()
|
|
||||||
.map(|id| Box::new(id.clone()) as Box<dyn rusqlite::types::ToSql>)
|
|
||||||
.collect();
|
|
||||||
let mut param_idx = fts_ids.len() + 1;
|
|
||||||
|
|
||||||
if let Some(status) = filters.status {
|
|
||||||
sql.push_str(&format!("AND status = ?{param_idx} "));
|
|
||||||
params.push(Box::new(status.to_string()));
|
|
||||||
param_idx += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(tags) = filters.tags {
|
|
||||||
// Filter posts whose JSON tags array contains ALL specified tags (case-insensitive)
|
|
||||||
for tag in tags {
|
|
||||||
sql.push_str(&format!(
|
|
||||||
"AND EXISTS (SELECT 1 FROM json_each(posts.tags) WHERE LOWER(json_each.value) = LOWER(?{param_idx})) "
|
|
||||||
));
|
|
||||||
params.push(Box::new(tag.clone()));
|
|
||||||
param_idx += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(categories) = filters.categories {
|
|
||||||
// Filter posts whose JSON categories array contains ALL specified categories (case-insensitive)
|
|
||||||
for cat in categories {
|
|
||||||
sql.push_str(&format!(
|
|
||||||
"AND EXISTS (SELECT 1 FROM json_each(posts.categories) WHERE LOWER(json_each.value) = LOWER(?{param_idx})) "
|
|
||||||
));
|
|
||||||
params.push(Box::new(cat.clone()));
|
|
||||||
param_idx += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(year) = filters.year {
|
|
||||||
let (start, end) =
|
|
||||||
calendar_range_unix_ms(year, filters.month).ok_or(rusqlite::Error::InvalidQuery)?;
|
|
||||||
sql.push_str(&format!(
|
|
||||||
"AND created_at >= ?{param_idx} AND created_at < ?{} ",
|
|
||||||
param_idx + 1
|
|
||||||
));
|
|
||||||
params.push(Box::new(start));
|
|
||||||
params.push(Box::new(end));
|
|
||||||
param_idx += 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(lang) = filters.language {
|
|
||||||
sql.push_str(&format!(
|
|
||||||
"AND (language = ?{param_idx} OR language IS NULL) "
|
|
||||||
));
|
|
||||||
params.push(Box::new(lang.to_string()));
|
|
||||||
param_idx += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(missing_lang) = filters.missing_translation_language {
|
|
||||||
sql.push_str(&format!(
|
|
||||||
"AND NOT EXISTS (SELECT 1 FROM post_translations WHERE post_translations.translation_for = posts.id AND post_translations.language = ?{param_idx}) "
|
|
||||||
));
|
|
||||||
params.push(Box::new(missing_lang.to_string()));
|
|
||||||
param_idx += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(from_ts) = filters.from {
|
|
||||||
sql.push_str(&format!("AND created_at >= ?{param_idx} "));
|
|
||||||
params.push(Box::new(from_ts));
|
|
||||||
param_idx += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(to_ts) = filters.to {
|
|
||||||
sql.push_str(&format!("AND created_at <= ?{param_idx} "));
|
|
||||||
params.push(Box::new(to_ts));
|
|
||||||
param_idx += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = param_idx; // suppress unused warning
|
|
||||||
|
|
||||||
// First get total count (without LIMIT/OFFSET)
|
|
||||||
let count_sql = sql.replace("SELECT id FROM posts", "SELECT COUNT(*) FROM posts");
|
|
||||||
let total: usize = {
|
|
||||||
let mut stmt = conn.prepare(&count_sql)?;
|
|
||||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
|
|
||||||
params.iter().map(|p| p.as_ref()).collect();
|
|
||||||
stmt.query_row(params_refs.as_slice(), |row| row.get::<_, usize>(0))?
|
|
||||||
};
|
|
||||||
|
|
||||||
sql.push_str("ORDER BY created_at DESC ");
|
|
||||||
|
|
||||||
let offset = filters.offset.unwrap_or(0);
|
let offset = filters.offset.unwrap_or(0);
|
||||||
|
let mut post_ids = conn.with(|c| {
|
||||||
|
let mut query = posts::table.filter(posts::id.eq_any(fts_ids)).into_boxed();
|
||||||
|
if let Some(status) = filters.status {
|
||||||
|
query = query.filter(posts::status.eq(status));
|
||||||
|
}
|
||||||
|
if let Some(tags) = filters.tags {
|
||||||
|
for tag in tags {
|
||||||
|
query = query.filter(
|
||||||
|
instr(
|
||||||
|
lower(posts::tags),
|
||||||
|
serde_json::to_string(&tag.to_lowercase()).unwrap(),
|
||||||
|
)
|
||||||
|
.gt(0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(categories) = filters.categories {
|
||||||
|
for category in categories {
|
||||||
|
query = query.filter(
|
||||||
|
instr(
|
||||||
|
lower(posts::categories),
|
||||||
|
serde_json::to_string(&category.to_lowercase()).unwrap(),
|
||||||
|
)
|
||||||
|
.gt(0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(year) = filters.year {
|
||||||
|
let (start, end) = calendar_range_unix_ms(year, filters.month).ok_or_else(|| {
|
||||||
|
diesel::result::Error::SerializationError("invalid calendar range".into())
|
||||||
|
})?;
|
||||||
|
query = query.filter(posts::created_at.ge(start).and(posts::created_at.lt(end)));
|
||||||
|
}
|
||||||
|
if let Some(language) = filters.language {
|
||||||
|
query = query.filter(posts::language.eq(language).or(posts::language.is_null()));
|
||||||
|
}
|
||||||
|
if let Some(language) = filters.missing_translation_language {
|
||||||
|
query = query.filter(diesel::dsl::not(diesel::dsl::exists(
|
||||||
|
post_translations::table
|
||||||
|
.filter(post_translations::translation_for.eq(posts::id))
|
||||||
|
.filter(post_translations::language.eq(language)),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if let Some(from) = filters.from {
|
||||||
|
query = query.filter(posts::created_at.ge(from));
|
||||||
|
}
|
||||||
|
if let Some(to) = filters.to {
|
||||||
|
query = query.filter(posts::created_at.le(to));
|
||||||
|
}
|
||||||
|
query
|
||||||
|
.order(posts::created_at.desc())
|
||||||
|
.select(posts::id)
|
||||||
|
.load::<String>(c)
|
||||||
|
})?;
|
||||||
|
let total = post_ids.len();
|
||||||
let limit = filters.limit.unwrap_or(total);
|
let limit = filters.limit.unwrap_or(total);
|
||||||
|
post_ids = post_ids.into_iter().skip(offset).take(limit).collect();
|
||||||
if filters.limit.is_some() {
|
|
||||||
sql.push_str(&format!("LIMIT {limit} "));
|
|
||||||
sql.push_str(&format!("OFFSET {offset} "));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut stmt = conn.prepare(&sql)?;
|
|
||||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
|
||||||
let rows = stmt.query_map(params_refs.as_slice(), |row| row.get::<_, String>(0))?;
|
|
||||||
let post_ids: Vec<String> = rows.collect::<rusqlite::Result<Vec<_>>>()?;
|
|
||||||
Ok(SearchResults {
|
Ok(SearchResults {
|
||||||
post_ids,
|
post_ids,
|
||||||
total,
|
total,
|
||||||
@@ -392,41 +389,51 @@ pub fn search_posts_filtered(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Search media by full-text query. Returns matching media IDs.
|
/// Search media by full-text query. Returns matching media IDs.
|
||||||
pub fn search_media(
|
pub fn search_media(conn: &Connection, query: &str, language: &str) -> QueryResult<Vec<String>> {
|
||||||
conn: &Connection,
|
|
||||||
query: &str,
|
|
||||||
language: &str,
|
|
||||||
) -> rusqlite::Result<Vec<String>> {
|
|
||||||
let stemmed = stem_text(query, language);
|
let stemmed = stem_text(query, language);
|
||||||
let mut stmt =
|
conn.with(|c| {
|
||||||
conn.prepare("SELECT media_id FROM media_fts WHERE media_fts MATCH ?1 ORDER BY rank")?;
|
diesel::sql_query("SELECT media_id FROM media_fts WHERE media_fts MATCH ? ORDER BY rank")
|
||||||
let rows = stmt.query_map(rusqlite::params![stemmed], |row| row.get::<_, String>(0))?;
|
.bind::<Text, _>(stemmed)
|
||||||
rows.collect()
|
.load::<MediaIdRow>(c)
|
||||||
|
.map(|rows| rows.into_iter().map(|row| row.media_id).collect())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
|
use crate::db::queries::post::{insert_post, make_test_post};
|
||||||
|
use crate::model::PostStatus;
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
ensure_fts_tables(db.conn()).unwrap();
|
ensure_fts_tables(db.conn()).unwrap();
|
||||||
db
|
db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn insert_test_post(
|
||||||
|
db: &Database,
|
||||||
|
id: &str,
|
||||||
|
slug: &str,
|
||||||
|
title: &str,
|
||||||
|
status: PostStatus,
|
||||||
|
timestamp: i64,
|
||||||
|
) {
|
||||||
|
let mut post = make_test_post(id, "p1", slug);
|
||||||
|
post.title = title.into();
|
||||||
|
post.status = status;
|
||||||
|
post.created_at = timestamp;
|
||||||
|
post.updated_at = timestamp;
|
||||||
|
insert_post(db.conn(), &post).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fts_tables_created() {
|
fn fts_tables_created() {
|
||||||
let db = setup();
|
let db = setup();
|
||||||
let count: i64 = db.conn()
|
assert!(search_posts(db.conn(), "nothing", "en").unwrap().is_empty());
|
||||||
.query_row(
|
assert!(search_media(db.conn(), "nothing", "en").unwrap().is_empty());
|
||||||
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name IN ('posts_fts', 'media_fts')",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(count, 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -438,16 +445,30 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn fts_multi_column_schema() {
|
fn fts_multi_column_schema() {
|
||||||
let db = setup();
|
let db = setup();
|
||||||
// Verify posts_fts has the expected columns by inserting into each
|
index_post(
|
||||||
db.conn().execute(
|
db.conn(),
|
||||||
"INSERT INTO posts_fts (post_id, title, excerpt, content, tags, categories) VALUES ('p1', 'T', 'E', 'C', 'tg', 'ct')",
|
"p1",
|
||||||
[],
|
"T",
|
||||||
).unwrap();
|
Some("E"),
|
||||||
// Verify media_fts has the expected columns
|
Some("C"),
|
||||||
db.conn().execute(
|
&["tg".into()],
|
||||||
"INSERT INTO media_fts (media_id, title, alt, caption, original_name, tags) VALUES ('m1', 'T', 'A', 'C', 'N', 'tg')",
|
&["ct".into()],
|
||||||
[],
|
&[],
|
||||||
).unwrap();
|
"en",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
index_media(
|
||||||
|
db.conn(),
|
||||||
|
"m1",
|
||||||
|
Some("T"),
|
||||||
|
Some("A"),
|
||||||
|
Some("C"),
|
||||||
|
"N",
|
||||||
|
&["tg".into()],
|
||||||
|
&[],
|
||||||
|
"en",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -694,16 +715,22 @@ mod tests {
|
|||||||
// Insert a post in the posts table (needed for the filter query to join)
|
// Insert a post in the posts table (needed for the filter query to join)
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||||
db.conn().execute(
|
insert_test_post(
|
||||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
&db,
|
||||||
VALUES ('post1', 'p1', 'Test Post', 'test', 'published', 1700000000000, 1700000000000)",
|
"post1",
|
||||||
[],
|
"test",
|
||||||
).unwrap();
|
"Test Post",
|
||||||
db.conn().execute(
|
PostStatus::Published,
|
||||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
1700000000000,
|
||||||
VALUES ('post2', 'p1', 'Draft Post', 'draft-post', 'draft', 1700000000000, 1700000000000)",
|
);
|
||||||
[],
|
insert_test_post(
|
||||||
).unwrap();
|
&db,
|
||||||
|
"post2",
|
||||||
|
"draft-post",
|
||||||
|
"Draft Post",
|
||||||
|
PostStatus::Draft,
|
||||||
|
1700000000000,
|
||||||
|
);
|
||||||
|
|
||||||
index_post(
|
index_post(
|
||||||
db.conn(),
|
db.conn(),
|
||||||
@@ -750,20 +777,22 @@ mod tests {
|
|||||||
// 2023-06-15 in unix ms
|
// 2023-06-15 in unix ms
|
||||||
let ts_2023: i64 = 1686873600000;
|
let ts_2023: i64 = 1686873600000;
|
||||||
|
|
||||||
db.conn()
|
insert_test_post(
|
||||||
.execute(
|
&db,
|
||||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
"p2024",
|
||||||
VALUES ('p2024', 'p1', 'Year 2024', 'y2024', 'draft', ?1, ?1)",
|
"y2024",
|
||||||
rusqlite::params![ts_2024],
|
"Year 2024",
|
||||||
)
|
PostStatus::Draft,
|
||||||
.unwrap();
|
ts_2024,
|
||||||
db.conn()
|
);
|
||||||
.execute(
|
insert_test_post(
|
||||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
&db,
|
||||||
VALUES ('p2023', 'p1', 'Year 2023', 'y2023', 'draft', ?1, ?1)",
|
"p2023",
|
||||||
rusqlite::params![ts_2023],
|
"y2023",
|
||||||
)
|
"Year 2023",
|
||||||
.unwrap();
|
PostStatus::Draft,
|
||||||
|
ts_2023,
|
||||||
|
);
|
||||||
|
|
||||||
index_post(
|
index_post(
|
||||||
db.conn(),
|
db.conn(),
|
||||||
@@ -808,11 +837,14 @@ mod tests {
|
|||||||
for i in 0..5 {
|
for i in 0..5 {
|
||||||
let id = format!("p{i}");
|
let id = format!("p{i}");
|
||||||
let slug = format!("post-{i}");
|
let slug = format!("post-{i}");
|
||||||
db.conn().execute(
|
insert_test_post(
|
||||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
&db,
|
||||||
VALUES (?1, 'p1', 'Searchable', ?2, 'draft', ?3, ?3)",
|
&id,
|
||||||
rusqlite::params![id, slug, 1700000000000i64 - i as i64 * 1000],
|
&slug,
|
||||||
).unwrap();
|
"Searchable",
|
||||||
|
PostStatus::Draft,
|
||||||
|
1700000000000i64 - i as i64 * 1000,
|
||||||
|
);
|
||||||
index_post(
|
index_post(
|
||||||
db.conn(),
|
db.conn(),
|
||||||
&id,
|
&id,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,8 @@ pub mod from_row;
|
|||||||
pub mod fts;
|
pub mod fts;
|
||||||
mod migrations;
|
mod migrations;
|
||||||
pub mod queries;
|
pub mod queries;
|
||||||
|
pub mod schema;
|
||||||
|
|
||||||
pub use connection::Database;
|
pub use connection::{Database, DatabaseError, DbConnection};
|
||||||
|
pub use diesel::result::Error as DbQueryError;
|
||||||
pub use migrations::run_migrations;
|
pub use migrations::run_migrations;
|
||||||
|
|||||||
@@ -1,62 +1,74 @@
|
|||||||
use rusqlite::{Connection, params};
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use crate::db::from_row::{GENERATED_FILE_HASH_COLUMNS, generated_file_hash_from_row};
|
use crate::db::DbConnection;
|
||||||
|
use crate::db::from_row::GeneratedFileHashRecord;
|
||||||
|
use crate::db::schema::generated_file_hashes;
|
||||||
use crate::model::GeneratedFileHash;
|
use crate::model::GeneratedFileHash;
|
||||||
|
|
||||||
pub fn get_generated_file_hash(
|
pub fn get_generated_file_hash(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
relative_path: &str,
|
relative_path: &str,
|
||||||
) -> rusqlite::Result<GeneratedFileHash> {
|
) -> QueryResult<GeneratedFileHash> {
|
||||||
conn.query_row(
|
conn.with(|c| {
|
||||||
&format!(
|
generated_file_hashes::table
|
||||||
"SELECT {GENERATED_FILE_HASH_COLUMNS} FROM generated_file_hashes WHERE project_id = ?1 AND relative_path = ?2"
|
.filter(generated_file_hashes::project_id.eq(project_id))
|
||||||
),
|
.filter(generated_file_hashes::relative_path.eq(relative_path))
|
||||||
params![project_id, relative_path],
|
.select(GeneratedFileHashRecord::as_select())
|
||||||
generated_file_hash_from_row,
|
.first(c)
|
||||||
)
|
.map(Into::into)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn upsert_generated_file_hash(
|
pub fn upsert_generated_file_hash(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
hash: &GeneratedFileHash,
|
hash: &GeneratedFileHash,
|
||||||
) -> rusqlite::Result<()> {
|
) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"INSERT INTO generated_file_hashes (project_id, relative_path, content_hash, updated_at)
|
diesel::insert_into(generated_file_hashes::table)
|
||||||
VALUES (?1, ?2, ?3, ?4)
|
.values(GeneratedFileHashRecord::from(hash))
|
||||||
ON CONFLICT(project_id, relative_path)
|
.on_conflict((
|
||||||
DO UPDATE SET content_hash = excluded.content_hash, updated_at = excluded.updated_at",
|
generated_file_hashes::project_id,
|
||||||
params![
|
generated_file_hashes::relative_path,
|
||||||
hash.project_id,
|
))
|
||||||
hash.relative_path,
|
.do_update()
|
||||||
hash.content_hash,
|
.set((
|
||||||
hash.updated_at
|
generated_file_hashes::content_hash.eq(&hash.content_hash),
|
||||||
],
|
generated_file_hashes::updated_at.eq(hash.updated_at),
|
||||||
)?;
|
))
|
||||||
Ok(())
|
.execute(c)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_generated_file_hash(
|
pub fn delete_generated_file_hash(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
relative_path: &str,
|
relative_path: &str,
|
||||||
) -> rusqlite::Result<()> {
|
) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"DELETE FROM generated_file_hashes WHERE project_id = ?1 AND relative_path = ?2",
|
diesel::delete(
|
||||||
params![project_id, relative_path],
|
generated_file_hashes::table
|
||||||
)?;
|
.filter(generated_file_hashes::project_id.eq(project_id))
|
||||||
Ok(())
|
.filter(generated_file_hashes::relative_path.eq(relative_path)),
|
||||||
|
)
|
||||||
|
.execute(c)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_generated_file_hashes_by_project(
|
pub fn list_generated_file_hashes_by_project(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
) -> rusqlite::Result<Vec<GeneratedFileHash>> {
|
) -> QueryResult<Vec<GeneratedFileHash>> {
|
||||||
let mut stmt = conn.prepare(&format!(
|
conn.with(|c| {
|
||||||
"SELECT {GENERATED_FILE_HASH_COLUMNS} FROM generated_file_hashes WHERE project_id = ?1 ORDER BY relative_path"
|
generated_file_hashes::table
|
||||||
))?;
|
.filter(generated_file_hashes::project_id.eq(project_id))
|
||||||
let rows = stmt.query_map(params![project_id], generated_file_hash_from_row)?;
|
.order(generated_file_hashes::relative_path)
|
||||||
rows.collect()
|
.select(GeneratedFileHashRecord::as_select())
|
||||||
|
.load(c)
|
||||||
|
.map(|rows: Vec<GeneratedFileHashRecord>| rows.into_iter().map(Into::into).collect())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -66,7 +78,7 @@ mod tests {
|
|||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||||
db
|
db
|
||||||
|
|||||||
@@ -1,120 +1,87 @@
|
|||||||
use rusqlite::{Connection, params};
|
use chrono::{Datelike, TimeZone, Utc};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use diesel::sql_types::Text;
|
||||||
|
|
||||||
use crate::db::from_row::{MEDIA_COLUMNS, media_from_row};
|
use crate::db::DbConnection;
|
||||||
|
use crate::db::from_row::{MediaRecord, convert, convert_all};
|
||||||
|
use crate::db::schema::media;
|
||||||
use crate::model::Media;
|
use crate::model::Media;
|
||||||
use crate::util::calendar_range_unix_ms;
|
use crate::util::calendar_range_unix_ms;
|
||||||
|
|
||||||
fn tags_to_json(tags: &[String]) -> String {
|
diesel::define_sql_function!(fn instr(haystack: Text, needle: Text) -> Integer);
|
||||||
serde_json::to_string(tags).unwrap_or_else(|_| "[]".into())
|
|
||||||
|
pub fn insert_media(conn: &DbConnection, m: &Media) -> QueryResult<()> {
|
||||||
|
conn.with(|c| {
|
||||||
|
diesel::insert_into(media::table)
|
||||||
|
.values(MediaRecord::from(m))
|
||||||
|
.execute(c)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn insert_media(conn: &Connection, m: &Media) -> rusqlite::Result<()> {
|
pub fn get_media_by_id(conn: &DbConnection, id: &str) -> QueryResult<Media> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"INSERT INTO media (
|
media::table
|
||||||
id, project_id, filename, original_name, mime_type, size,
|
.filter(media::id.eq(id))
|
||||||
width, height, title, alt, caption, author, language,
|
.select(MediaRecord::as_select())
|
||||||
file_path, sidecar_path, checksum, tags, created_at, updated_at
|
.first(c)
|
||||||
) VALUES (
|
.and_then(convert)
|
||||||
?1, ?2, ?3, ?4, ?5, ?6,
|
})
|
||||||
?7, ?8, ?9, ?10, ?11, ?12, ?13,
|
|
||||||
?14, ?15, ?16, ?17, ?18, ?19
|
|
||||||
)",
|
|
||||||
params![
|
|
||||||
m.id,
|
|
||||||
m.project_id,
|
|
||||||
m.filename,
|
|
||||||
m.original_name,
|
|
||||||
m.mime_type,
|
|
||||||
m.size,
|
|
||||||
m.width,
|
|
||||||
m.height,
|
|
||||||
m.title,
|
|
||||||
m.alt,
|
|
||||||
m.caption,
|
|
||||||
m.author,
|
|
||||||
m.language,
|
|
||||||
m.file_path,
|
|
||||||
m.sidecar_path,
|
|
||||||
m.checksum,
|
|
||||||
tags_to_json(&m.tags),
|
|
||||||
m.created_at,
|
|
||||||
m.updated_at,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_media_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Media> {
|
pub fn list_media_by_project(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<Media>> {
|
||||||
conn.query_row(
|
conn.with(|c| {
|
||||||
&format!("SELECT {MEDIA_COLUMNS} FROM media WHERE id = ?1"),
|
media::table
|
||||||
params![id],
|
.filter(media::project_id.eq(project_id))
|
||||||
media_from_row,
|
.order(media::created_at.desc())
|
||||||
)
|
.select(MediaRecord::as_select())
|
||||||
}
|
.load(c)
|
||||||
|
.and_then(convert_all)
|
||||||
pub fn list_media_by_project(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<Media>> {
|
})
|
||||||
let mut stmt = conn.prepare(&format!(
|
|
||||||
"SELECT {MEDIA_COLUMNS} FROM media WHERE project_id = ?1 ORDER BY created_at DESC"
|
|
||||||
))?;
|
|
||||||
let rows = stmt.query_map(params![project_id], media_from_row)?;
|
|
||||||
rows.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_media_by_project_limited(
|
pub fn list_media_by_project_limited(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> rusqlite::Result<Vec<Media>> {
|
) -> QueryResult<Vec<Media>> {
|
||||||
let mut stmt = conn.prepare(&format!(
|
conn.with(|c| {
|
||||||
"SELECT {MEDIA_COLUMNS} FROM media WHERE project_id = ?1 ORDER BY created_at DESC LIMIT ?2 OFFSET ?3"
|
media::table
|
||||||
))?;
|
.filter(media::project_id.eq(project_id))
|
||||||
let rows = stmt.query_map(params![project_id, limit, offset], media_from_row)?;
|
.order(media::created_at.desc())
|
||||||
rows.collect()
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
.select(MediaRecord::as_select())
|
||||||
|
.load(c)
|
||||||
|
.and_then(convert_all)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn count_media_by_project(conn: &Connection, project_id: &str) -> rusqlite::Result<i64> {
|
pub fn count_media_by_project(conn: &DbConnection, project_id: &str) -> QueryResult<i64> {
|
||||||
conn.query_row(
|
conn.with(|c| {
|
||||||
"SELECT COUNT(*) FROM media WHERE project_id = ?1",
|
media::table
|
||||||
params![project_id],
|
.filter(media::project_id.eq(project_id))
|
||||||
|row| row.get(0),
|
.count()
|
||||||
)
|
.get_result(c)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_media(conn: &Connection, m: &Media) -> rusqlite::Result<()> {
|
pub fn update_media(conn: &DbConnection, m: &Media) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"UPDATE media SET
|
diesel::update(media::table.filter(media::id.eq(&m.id)))
|
||||||
filename = ?1, original_name = ?2, mime_type = ?3, size = ?4,
|
.set(MediaRecord::from(m))
|
||||||
width = ?5, height = ?6, title = ?7, alt = ?8, caption = ?9,
|
.execute(c)
|
||||||
author = ?10, language = ?11, file_path = ?12, sidecar_path = ?13,
|
.map(|_| ())
|
||||||
checksum = ?14, tags = ?15, updated_at = ?16
|
})
|
||||||
WHERE id = ?17",
|
|
||||||
params![
|
|
||||||
m.filename,
|
|
||||||
m.original_name,
|
|
||||||
m.mime_type,
|
|
||||||
m.size,
|
|
||||||
m.width,
|
|
||||||
m.height,
|
|
||||||
m.title,
|
|
||||||
m.alt,
|
|
||||||
m.caption,
|
|
||||||
m.author,
|
|
||||||
m.language,
|
|
||||||
m.file_path,
|
|
||||||
m.sidecar_path,
|
|
||||||
m.checksum,
|
|
||||||
tags_to_json(&m.tags),
|
|
||||||
m.updated_at,
|
|
||||||
m.id,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_media(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
pub fn delete_media(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||||
conn.execute("DELETE FROM media WHERE id = ?1", params![id])?;
|
conn.with(|c| {
|
||||||
Ok(())
|
diesel::delete(media::table.filter(media::id.eq(id)))
|
||||||
|
.execute(c)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Filtered queries (per sidebar_views.allium MediaView) ───
|
// ── Filtered queries (per sidebar_views.allium MediaView) ───
|
||||||
@@ -140,95 +107,86 @@ impl MediaFilterParams {
|
|||||||
|
|
||||||
/// List media with optional filters applied.
|
/// List media with optional filters applied.
|
||||||
pub fn list_media_filtered(
|
pub fn list_media_filtered(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
filters: &MediaFilterParams,
|
filters: &MediaFilterParams,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> rusqlite::Result<Vec<Media>> {
|
) -> QueryResult<Vec<Media>> {
|
||||||
let mut conditions = vec!["project_id = ?1".to_string()];
|
conn.with(|c| {
|
||||||
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
let mut query = media::table
|
||||||
param_values.push(Box::new(project_id.to_string()));
|
.filter(media::project_id.eq(project_id))
|
||||||
|
.into_boxed();
|
||||||
if !filters.search_query.is_empty() {
|
if !filters.search_query.is_empty() {
|
||||||
let idx = param_values.len() + 1;
|
query = query.filter(
|
||||||
let pattern = format!("%{}%", filters.search_query.replace('%', "\\%"));
|
media::title
|
||||||
conditions.push(format!(
|
.is_not_null()
|
||||||
"(COALESCE(title, original_name) LIKE ?{idx} ESCAPE '\\')"
|
.and(instr(media::title.assume_not_null(), &filters.search_query).gt(0))
|
||||||
));
|
.or(media::title
|
||||||
param_values.push(Box::new(pattern));
|
.is_null()
|
||||||
}
|
.and(instr(media::original_name, &filters.search_query).gt(0))),
|
||||||
|
);
|
||||||
if let Some(year) = filters.year {
|
}
|
||||||
let (start, end) =
|
if let Some(year) = filters.year {
|
||||||
calendar_range_unix_ms(year, filters.month).ok_or(rusqlite::Error::InvalidQuery)?;
|
let (start, end) = calendar_range_unix_ms(year, filters.month).ok_or_else(|| {
|
||||||
let idx1 = param_values.len() + 1;
|
diesel::result::Error::SerializationError("invalid calendar range".into())
|
||||||
let idx2 = param_values.len() + 2;
|
})?;
|
||||||
conditions.push(format!("(created_at >= ?{idx1} AND created_at < ?{idx2})"));
|
query = query.filter(media::created_at.ge(start).and(media::created_at.lt(end)));
|
||||||
param_values.push(Box::new(start));
|
}
|
||||||
param_values.push(Box::new(end));
|
for tag in &filters.tags {
|
||||||
}
|
query = query.filter(instr(media::tags, serde_json::to_string(tag).unwrap()).gt(0));
|
||||||
|
}
|
||||||
for tag in &filters.tags {
|
query
|
||||||
let idx = param_values.len() + 1;
|
.order(media::created_at.desc())
|
||||||
let pattern = format!("%\"{}\"%", tag.replace('"', "\\\""));
|
.limit(limit)
|
||||||
conditions.push(format!("(tags LIKE ?{idx})"));
|
.offset(offset)
|
||||||
param_values.push(Box::new(pattern));
|
.select(MediaRecord::as_select())
|
||||||
}
|
.load(c)
|
||||||
|
.and_then(convert_all)
|
||||||
let where_clause = conditions.join(" AND ");
|
})
|
||||||
let idx_limit = param_values.len() + 1;
|
|
||||||
let idx_offset = param_values.len() + 2;
|
|
||||||
param_values.push(Box::new(limit));
|
|
||||||
param_values.push(Box::new(offset));
|
|
||||||
|
|
||||||
let sql = format!(
|
|
||||||
"SELECT {MEDIA_COLUMNS} FROM media WHERE {where_clause} ORDER BY created_at DESC LIMIT ?{idx_limit} OFFSET ?{idx_offset}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
|
|
||||||
param_values.iter().map(|b| b.as_ref()).collect();
|
|
||||||
|
|
||||||
let mut stmt = conn.prepare(&sql)?;
|
|
||||||
let rows = stmt.query_map(params_refs.as_slice(), media_from_row)?;
|
|
||||||
rows.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Year/month counts for the media calendar archive widget.
|
/// Year/month counts for the media calendar archive widget.
|
||||||
pub fn media_calendar_counts(
|
pub fn media_calendar_counts(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
) -> rusqlite::Result<Vec<(i32, u32, usize)>> {
|
) -> QueryResult<Vec<(i32, u32, usize)>> {
|
||||||
let mut stmt = conn.prepare(
|
conn.with(|c| {
|
||||||
"SELECT
|
let timestamps = media::table
|
||||||
CAST(strftime('%Y', created_at / 1000, 'unixepoch') AS INTEGER) AS y,
|
.filter(media::project_id.eq(project_id))
|
||||||
CAST(strftime('%m', created_at / 1000, 'unixepoch') AS INTEGER) AS m,
|
.select(media::created_at)
|
||||||
COUNT(*) AS cnt
|
.load::<i64>(c)?;
|
||||||
FROM media
|
let mut counts = std::collections::BTreeMap::new();
|
||||||
WHERE project_id = ?1
|
for timestamp in timestamps {
|
||||||
GROUP BY y, m
|
let date = Utc
|
||||||
ORDER BY y DESC, m DESC",
|
.timestamp_millis_opt(timestamp)
|
||||||
)?;
|
.single()
|
||||||
let rows = stmt.query_map(params![project_id], |row| {
|
.ok_or_else(|| {
|
||||||
Ok((
|
diesel::result::Error::DeserializationError("invalid timestamp".into())
|
||||||
row.get::<_, i32>(0)?,
|
})?;
|
||||||
row.get::<_, u32>(1)?,
|
*counts.entry((date.year(), date.month())).or_insert(0) += 1;
|
||||||
row.get::<_, usize>(2)?,
|
}
|
||||||
))
|
Ok(counts
|
||||||
})?;
|
.into_iter()
|
||||||
rows.collect()
|
.rev()
|
||||||
|
.map(|((year, month), count)| (year, month, count))
|
||||||
|
.collect())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collect all distinct tag values across media for a project.
|
/// Collect all distinct tag values across media for a project.
|
||||||
pub fn distinct_media_tags(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<String>> {
|
pub fn distinct_media_tags(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<String>> {
|
||||||
let mut stmt =
|
let rows = conn.with(|c| {
|
||||||
conn.prepare("SELECT DISTINCT tags FROM media WHERE project_id = ?1 AND tags != '[]'")?;
|
media::table
|
||||||
let rows = stmt.query_map(params![project_id], |row| row.get::<_, String>(0))?;
|
.filter(media::project_id.eq(project_id))
|
||||||
|
.filter(media::tags.ne("[]"))
|
||||||
|
.select(media::tags)
|
||||||
|
.distinct()
|
||||||
|
.load::<String>(c)
|
||||||
|
})?;
|
||||||
let mut all_tags = std::collections::BTreeSet::new();
|
let mut all_tags = std::collections::BTreeSet::new();
|
||||||
for json_str in rows {
|
for json_str in rows {
|
||||||
if let Ok(json_str) = json_str
|
if let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str) {
|
||||||
&& let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str)
|
|
||||||
{
|
|
||||||
all_tags.extend(tags);
|
all_tags.extend(tags);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,7 +226,7 @@ mod tests {
|
|||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||||
db
|
db
|
||||||
|
|||||||
@@ -1,92 +1,82 @@
|
|||||||
use rusqlite::{Connection, params};
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use crate::db::from_row::{MEDIA_TRANSLATION_COLUMNS, media_translation_from_row};
|
use crate::db::DbConnection;
|
||||||
|
use crate::db::from_row::MediaTranslationRecord;
|
||||||
|
use crate::db::schema::media_translations;
|
||||||
use crate::model::MediaTranslation;
|
use crate::model::MediaTranslation;
|
||||||
|
|
||||||
pub fn insert_media_translation(conn: &Connection, t: &MediaTranslation) -> rusqlite::Result<()> {
|
pub fn insert_media_translation(conn: &DbConnection, t: &MediaTranslation) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"INSERT INTO media_translations (
|
diesel::insert_into(media_translations::table)
|
||||||
id, project_id, translation_for, language, title, alt, caption,
|
.values(MediaTranslationRecord::from(t))
|
||||||
created_at, updated_at
|
.execute(c)
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
.map(|_| ())
|
||||||
params![
|
})
|
||||||
t.id,
|
|
||||||
t.project_id,
|
|
||||||
t.translation_for,
|
|
||||||
t.language,
|
|
||||||
t.title,
|
|
||||||
t.alt,
|
|
||||||
t.caption,
|
|
||||||
t.created_at,
|
|
||||||
t.updated_at,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_media_translation_by_media_and_language(
|
pub fn get_media_translation_by_media_and_language(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
translation_for: &str,
|
translation_for: &str,
|
||||||
language: &str,
|
language: &str,
|
||||||
) -> rusqlite::Result<MediaTranslation> {
|
) -> QueryResult<MediaTranslation> {
|
||||||
conn.query_row(
|
conn.with(|c| {
|
||||||
&format!(
|
media_translations::table
|
||||||
"SELECT {MEDIA_TRANSLATION_COLUMNS} FROM media_translations
|
.filter(media_translations::translation_for.eq(translation_for))
|
||||||
WHERE translation_for = ?1 AND language = ?2"
|
.filter(media_translations::language.eq(language))
|
||||||
),
|
.select(MediaTranslationRecord::as_select())
|
||||||
params![translation_for, language],
|
.first(c)
|
||||||
media_translation_from_row,
|
.map(Into::into)
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_media_translations_by_media(
|
pub fn list_media_translations_by_media(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
translation_for: &str,
|
translation_for: &str,
|
||||||
) -> rusqlite::Result<Vec<MediaTranslation>> {
|
) -> QueryResult<Vec<MediaTranslation>> {
|
||||||
let mut stmt = conn.prepare(&format!(
|
conn.with(|c| {
|
||||||
"SELECT {MEDIA_TRANSLATION_COLUMNS} FROM media_translations
|
media_translations::table
|
||||||
WHERE translation_for = ?1 ORDER BY language"
|
.filter(media_translations::translation_for.eq(translation_for))
|
||||||
))?;
|
.order(media_translations::language)
|
||||||
let rows = stmt.query_map(params![translation_for], media_translation_from_row)?;
|
.select(MediaTranslationRecord::as_select())
|
||||||
rows.collect()
|
.load(c)
|
||||||
|
.map(|rows: Vec<MediaTranslationRecord>| rows.into_iter().map(Into::into).collect())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn upsert_media_translation(conn: &Connection, t: &MediaTranslation) -> rusqlite::Result<()> {
|
pub fn upsert_media_translation(conn: &DbConnection, t: &MediaTranslation) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"INSERT INTO media_translations (
|
diesel::insert_into(media_translations::table)
|
||||||
id, project_id, translation_for, language, title, alt, caption,
|
.values(MediaTranslationRecord::from(t))
|
||||||
created_at, updated_at
|
.on_conflict((
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
media_translations::translation_for,
|
||||||
ON CONFLICT(translation_for, language) DO UPDATE SET
|
media_translations::language,
|
||||||
title = excluded.title,
|
))
|
||||||
alt = excluded.alt,
|
.do_update()
|
||||||
caption = excluded.caption,
|
.set((
|
||||||
updated_at = excluded.updated_at",
|
media_translations::title.eq(&t.title),
|
||||||
params![
|
media_translations::alt.eq(&t.alt),
|
||||||
t.id,
|
media_translations::caption.eq(&t.caption),
|
||||||
t.project_id,
|
media_translations::updated_at.eq(t.updated_at),
|
||||||
t.translation_for,
|
))
|
||||||
t.language,
|
.execute(c)
|
||||||
t.title,
|
.map(|_| ())
|
||||||
t.alt,
|
})
|
||||||
t.caption,
|
|
||||||
t.created_at,
|
|
||||||
t.updated_at,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_media_translation(
|
pub fn delete_media_translation(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
translation_for: &str,
|
translation_for: &str,
|
||||||
language: &str,
|
language: &str,
|
||||||
) -> rusqlite::Result<()> {
|
) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"DELETE FROM media_translations WHERE translation_for = ?1 AND language = ?2",
|
diesel::delete(
|
||||||
params![translation_for, language],
|
media_translations::table
|
||||||
)?;
|
.filter(media_translations::translation_for.eq(translation_for))
|
||||||
Ok(())
|
.filter(media_translations::language.eq(language)),
|
||||||
|
)
|
||||||
|
.execute(c)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -97,7 +87,7 @@ mod tests {
|
|||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||||
insert_media(db.conn(), &make_test_media("m1", "p1")).unwrap();
|
insert_media(db.conn(), &make_test_media("m1", "p1")).unwrap();
|
||||||
|
|||||||
@@ -1,169 +1,142 @@
|
|||||||
use rusqlite::{Connection, params};
|
use chrono::{Datelike, TimeZone, Utc};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use diesel::sql_types::Text;
|
||||||
|
use diesel::sqlite::Sqlite;
|
||||||
|
|
||||||
use crate::db::from_row::{POST_COLUMNS, post_from_row, post_status_to_str};
|
use crate::db::DbConnection;
|
||||||
|
use crate::db::from_row::{PostRecord, convert, convert_all, post_status_to_str};
|
||||||
|
use crate::db::schema::posts;
|
||||||
use crate::model::{Post, PostStatus};
|
use crate::model::{Post, PostStatus};
|
||||||
use crate::util::calendar_range_unix_ms;
|
use crate::util::calendar_range_unix_ms;
|
||||||
|
|
||||||
fn tags_to_json(tags: &[String]) -> String {
|
diesel::define_sql_function!(fn instr(haystack: Text, needle: Text) -> Integer);
|
||||||
serde_json::to_string(tags).unwrap_or_else(|_| "[]".into())
|
diesel::define_sql_function!(fn lower(value: Text) -> Text);
|
||||||
|
|
||||||
|
pub fn insert_post(conn: &DbConnection, post: &Post) -> QueryResult<()> {
|
||||||
|
conn.with(|c| {
|
||||||
|
diesel::insert_into(posts::table)
|
||||||
|
.values(PostRecord::from(post))
|
||||||
|
.execute(c)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn insert_post(conn: &Connection, post: &Post) -> rusqlite::Result<()> {
|
pub fn get_post_by_id(conn: &DbConnection, id: &str) -> QueryResult<Post> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"INSERT INTO posts (
|
posts::table
|
||||||
id, project_id, title, slug, excerpt, content, status, author,
|
.filter(posts::id.eq(id))
|
||||||
language, do_not_translate, template_slug, file_path, checksum,
|
.select(PostRecord::as_select())
|
||||||
tags, categories,
|
.first(c)
|
||||||
published_title, published_content, published_tags,
|
.and_then(convert)
|
||||||
published_categories, published_excerpt,
|
})
|
||||||
created_at, updated_at, published_at
|
|
||||||
) VALUES (
|
|
||||||
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8,
|
|
||||||
?9, ?10, ?11, ?12, ?13,
|
|
||||||
?14, ?15,
|
|
||||||
?16, ?17, ?18, ?19, ?20,
|
|
||||||
?21, ?22, ?23
|
|
||||||
)",
|
|
||||||
params![
|
|
||||||
post.id,
|
|
||||||
post.project_id,
|
|
||||||
post.title,
|
|
||||||
post.slug,
|
|
||||||
post.excerpt,
|
|
||||||
post.content,
|
|
||||||
post_status_to_str(&post.status),
|
|
||||||
post.author,
|
|
||||||
post.language,
|
|
||||||
post.do_not_translate as i64,
|
|
||||||
post.template_slug,
|
|
||||||
post.file_path,
|
|
||||||
post.checksum,
|
|
||||||
tags_to_json(&post.tags),
|
|
||||||
tags_to_json(&post.categories),
|
|
||||||
post.published_title,
|
|
||||||
post.published_content,
|
|
||||||
post.published_tags,
|
|
||||||
post.published_categories,
|
|
||||||
post.published_excerpt,
|
|
||||||
post.created_at,
|
|
||||||
post.updated_at,
|
|
||||||
post.published_at,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_post_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Post> {
|
|
||||||
conn.query_row(
|
|
||||||
&format!("SELECT {POST_COLUMNS} FROM posts WHERE id = ?1"),
|
|
||||||
params![id],
|
|
||||||
post_from_row,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_post_by_project_and_slug(
|
pub fn get_post_by_project_and_slug(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
slug: &str,
|
slug: &str,
|
||||||
) -> rusqlite::Result<Post> {
|
) -> QueryResult<Post> {
|
||||||
conn.query_row(
|
conn.with(|c| {
|
||||||
&format!("SELECT {POST_COLUMNS} FROM posts WHERE project_id = ?1 AND slug = ?2"),
|
posts::table
|
||||||
params![project_id, slug],
|
.filter(posts::project_id.eq(project_id))
|
||||||
post_from_row,
|
.filter(posts::slug.eq(slug))
|
||||||
)
|
.select(PostRecord::as_select())
|
||||||
|
.first(c)
|
||||||
|
.and_then(convert)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_posts_by_project(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<Post>> {
|
pub fn list_posts_by_project(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<Post>> {
|
||||||
let mut stmt = conn.prepare(&format!(
|
conn.with(|c| {
|
||||||
"SELECT {POST_COLUMNS} FROM posts WHERE project_id = ?1 ORDER BY created_at DESC"
|
posts::table
|
||||||
))?;
|
.filter(posts::project_id.eq(project_id))
|
||||||
let rows = stmt.query_map(params![project_id], post_from_row)?;
|
.order(posts::created_at.desc())
|
||||||
rows.collect()
|
.select(PostRecord::as_select())
|
||||||
|
.load(c)
|
||||||
|
.and_then(convert_all)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_posts_by_project_limited(
|
pub fn list_posts_by_project_limited(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> rusqlite::Result<Vec<Post>> {
|
) -> QueryResult<Vec<Post>> {
|
||||||
let mut stmt = conn.prepare(&format!(
|
conn.with(|c| {
|
||||||
"SELECT {POST_COLUMNS} FROM posts WHERE project_id = ?1 ORDER BY created_at DESC LIMIT ?2 OFFSET ?3"
|
posts::table
|
||||||
))?;
|
.filter(posts::project_id.eq(project_id))
|
||||||
let rows = stmt.query_map(params![project_id, limit, offset], post_from_row)?;
|
.order(posts::created_at.desc())
|
||||||
rows.collect()
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
.select(PostRecord::as_select())
|
||||||
|
.load(c)
|
||||||
|
.and_then(convert_all)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_post(conn: &Connection, post: &Post) -> rusqlite::Result<()> {
|
pub fn update_post(conn: &DbConnection, post: &Post) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"UPDATE posts SET
|
diesel::update(posts::table.filter(posts::id.eq(&post.id)))
|
||||||
title = ?1, slug = ?2, excerpt = ?3, content = ?4, status = ?5,
|
.set(PostRecord::from(post))
|
||||||
author = ?6, language = ?7, do_not_translate = ?8, template_slug = ?9,
|
.execute(c)
|
||||||
file_path = ?10, checksum = ?11, tags = ?12, categories = ?13,
|
.map(|_| ())
|
||||||
published_title = ?14, published_content = ?15, published_tags = ?16,
|
})
|
||||||
published_categories = ?17, published_excerpt = ?18,
|
|
||||||
created_at = ?19, updated_at = ?20, published_at = ?21
|
|
||||||
WHERE id = ?22",
|
|
||||||
params![
|
|
||||||
post.title,
|
|
||||||
post.slug,
|
|
||||||
post.excerpt,
|
|
||||||
post.content,
|
|
||||||
post_status_to_str(&post.status),
|
|
||||||
post.author,
|
|
||||||
post.language,
|
|
||||||
post.do_not_translate as i64,
|
|
||||||
post.template_slug,
|
|
||||||
post.file_path,
|
|
||||||
post.checksum,
|
|
||||||
tags_to_json(&post.tags),
|
|
||||||
tags_to_json(&post.categories),
|
|
||||||
post.published_title,
|
|
||||||
post.published_content,
|
|
||||||
post.published_tags,
|
|
||||||
post.published_categories,
|
|
||||||
post.published_excerpt,
|
|
||||||
post.created_at,
|
|
||||||
post.updated_at,
|
|
||||||
post.published_at,
|
|
||||||
post.id,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_post_status(
|
pub fn update_post_status(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
id: &str,
|
id: &str,
|
||||||
status: &PostStatus,
|
status: &PostStatus,
|
||||||
updated_at: i64,
|
updated_at: i64,
|
||||||
) -> rusqlite::Result<()> {
|
) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"UPDATE posts SET status = ?1, updated_at = ?2 WHERE id = ?3",
|
diesel::update(posts::table.filter(posts::id.eq(id)))
|
||||||
params![post_status_to_str(status), updated_at, id],
|
.set((
|
||||||
)?;
|
posts::status.eq(post_status_to_str(status)),
|
||||||
Ok(())
|
posts::updated_at.eq(updated_at),
|
||||||
|
))
|
||||||
|
.execute(c)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_post_content(conn: &Connection, id: &str, updated_at: i64) -> rusqlite::Result<()> {
|
pub fn clear_post_content(conn: &DbConnection, id: &str, updated_at: i64) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"UPDATE posts SET content = NULL, updated_at = ?1 WHERE id = ?2",
|
diesel::update(posts::table.filter(posts::id.eq(id)))
|
||||||
params![updated_at, id],
|
.set((
|
||||||
)?;
|
posts::content.eq(None::<String>),
|
||||||
Ok(())
|
posts::updated_at.eq(updated_at),
|
||||||
|
))
|
||||||
|
.execute(c)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_post_file_path(
|
pub fn set_post_file_path(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
id: &str,
|
id: &str,
|
||||||
file_path: &str,
|
file_path: &str,
|
||||||
updated_at: i64,
|
updated_at: i64,
|
||||||
) -> rusqlite::Result<()> {
|
) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"UPDATE posts SET file_path = ?1, updated_at = ?2 WHERE id = ?3",
|
diesel::update(posts::table.filter(posts::id.eq(id)))
|
||||||
params![file_path, updated_at, id],
|
.set((
|
||||||
)?;
|
posts::file_path.eq(file_path),
|
||||||
Ok(())
|
posts::updated_at.eq(updated_at),
|
||||||
|
))
|
||||||
|
.execute(c)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_post_checksum(conn: &DbConnection, id: &str, checksum: Option<&str>) -> QueryResult<()> {
|
||||||
|
conn.with(|c| {
|
||||||
|
diesel::update(posts::table.filter(posts::id.eq(id)))
|
||||||
|
.set(posts::checksum.eq(checksum))
|
||||||
|
.execute(c)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(
|
#[expect(
|
||||||
@@ -171,7 +144,7 @@ pub fn set_post_file_path(
|
|||||||
reason = "arguments mirror the published snapshot columns"
|
reason = "arguments mirror the published snapshot columns"
|
||||||
)]
|
)]
|
||||||
pub fn set_published_snapshot(
|
pub fn set_published_snapshot(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
id: &str,
|
id: &str,
|
||||||
title: &str,
|
title: &str,
|
||||||
content: &str,
|
content: &str,
|
||||||
@@ -180,38 +153,38 @@ pub fn set_published_snapshot(
|
|||||||
excerpt: Option<&str>,
|
excerpt: Option<&str>,
|
||||||
published_at: i64,
|
published_at: i64,
|
||||||
updated_at: i64,
|
updated_at: i64,
|
||||||
) -> rusqlite::Result<()> {
|
) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"UPDATE posts SET
|
diesel::update(posts::table.filter(posts::id.eq(id)))
|
||||||
published_title = ?1, published_content = ?2, published_tags = ?3,
|
.set((
|
||||||
published_categories = ?4, published_excerpt = ?5,
|
posts::published_title.eq(title),
|
||||||
published_at = ?6, updated_at = ?7
|
posts::published_content.eq(content),
|
||||||
WHERE id = ?8",
|
posts::published_tags.eq(tags),
|
||||||
params![
|
posts::published_categories.eq(categories),
|
||||||
title,
|
posts::published_excerpt.eq(excerpt),
|
||||||
content,
|
posts::published_at.eq(published_at),
|
||||||
tags,
|
posts::updated_at.eq(updated_at),
|
||||||
categories,
|
))
|
||||||
excerpt,
|
.execute(c)
|
||||||
published_at,
|
.map(|_| ())
|
||||||
updated_at,
|
})
|
||||||
id
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_post(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
pub fn delete_post(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||||
conn.execute("DELETE FROM posts WHERE id = ?1", params![id])?;
|
conn.with(|c| {
|
||||||
Ok(())
|
diesel::delete(posts::table.filter(posts::id.eq(id)))
|
||||||
|
.execute(c)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn count_posts_by_project(conn: &Connection, project_id: &str) -> rusqlite::Result<i64> {
|
pub fn count_posts_by_project(conn: &DbConnection, project_id: &str) -> QueryResult<i64> {
|
||||||
conn.query_row(
|
conn.with(|c| {
|
||||||
"SELECT COUNT(*) FROM posts WHERE project_id = ?1",
|
posts::table
|
||||||
params![project_id],
|
.filter(posts::project_id.eq(project_id))
|
||||||
|row| row.get(0),
|
.count()
|
||||||
)
|
.get_result(c)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Filtered queries (per sidebar_views.allium PostsView) ───
|
// ── Filtered queries (per sidebar_views.allium PostsView) ───
|
||||||
@@ -266,163 +239,155 @@ impl PostFilterParams {
|
|||||||
///
|
///
|
||||||
/// Returns all matching posts (up to `limit`), ordered by created_at DESC.
|
/// Returns all matching posts (up to `limit`), ordered by created_at DESC.
|
||||||
/// Caller splits into draft/published/archived sections.
|
/// Caller splits into draft/published/archived sections.
|
||||||
|
fn post_query<'a>(
|
||||||
|
project_id: &'a str,
|
||||||
|
filters: &'a PostFilterParams,
|
||||||
|
apply_filters: bool,
|
||||||
|
exclude_drafts: bool,
|
||||||
|
) -> posts::BoxedQuery<'a, Sqlite> {
|
||||||
|
let mut query = posts::table
|
||||||
|
.filter(posts::project_id.eq(project_id))
|
||||||
|
.into_boxed();
|
||||||
|
|
||||||
|
let page = serde_json::to_string("page").unwrap();
|
||||||
|
if filters.pages_only {
|
||||||
|
query = query.filter(instr(lower(posts::categories), page.clone()).gt(0));
|
||||||
|
} else if filters.exclude_pages {
|
||||||
|
query = query.filter(instr(lower(posts::categories), page).eq(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
if exclude_drafts {
|
||||||
|
query = query.filter(posts::status.ne("draft"));
|
||||||
|
}
|
||||||
|
if !apply_filters {
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !filters.search_query.is_empty() {
|
||||||
|
query = query.filter(instr(posts::title, &filters.search_query).gt(0));
|
||||||
|
}
|
||||||
|
if let Some(status) = &filters.status {
|
||||||
|
query = query.filter(posts::status.eq(status));
|
||||||
|
}
|
||||||
|
if let Some(language) = &filters.language {
|
||||||
|
query = query.filter(posts::language.eq(language).or(posts::language.is_null()));
|
||||||
|
}
|
||||||
|
if let Some(year) = filters.year
|
||||||
|
&& let Some((start, end)) = calendar_range_unix_ms(year, filters.month)
|
||||||
|
{
|
||||||
|
query = query.filter(posts::created_at.ge(start).and(posts::created_at.lt(end)));
|
||||||
|
}
|
||||||
|
for tag in &filters.tags {
|
||||||
|
query = query.filter(instr(posts::tags, serde_json::to_string(tag).unwrap()).gt(0));
|
||||||
|
}
|
||||||
|
for category in &filters.categories {
|
||||||
|
query =
|
||||||
|
query.filter(instr(posts::categories, serde_json::to_string(category).unwrap()).gt(0));
|
||||||
|
}
|
||||||
|
if let Some(from) = filters.from {
|
||||||
|
query = query.filter(posts::created_at.ge(from));
|
||||||
|
}
|
||||||
|
if let Some(to) = filters.to {
|
||||||
|
query = query.filter(posts::created_at.le(to));
|
||||||
|
}
|
||||||
|
query
|
||||||
|
}
|
||||||
|
|
||||||
pub fn list_posts_filtered(
|
pub fn list_posts_filtered(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
filters: &PostFilterParams,
|
filters: &PostFilterParams,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> rusqlite::Result<Vec<Post>> {
|
) -> QueryResult<Vec<Post>> {
|
||||||
// Build dynamic WHERE clause
|
conn.with(|c| {
|
||||||
let mut conditions = vec!["p.project_id = ?1".to_string()];
|
let content_filters = filters.has_active_filters();
|
||||||
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
let records = if filters.status.is_some() {
|
||||||
param_values.push(Box::new(project_id.to_string()));
|
post_query(project_id, filters, true, false)
|
||||||
|
.order(posts::created_at.desc())
|
||||||
// pages_only / exclude_pages
|
.limit(limit)
|
||||||
if filters.pages_only {
|
.offset(offset)
|
||||||
conditions.push("LOWER(p.categories) LIKE '%\"page\"%'".to_string());
|
.select(PostRecord::as_select())
|
||||||
} else if filters.exclude_pages {
|
.load(c)?
|
||||||
conditions.push("LOWER(p.categories) NOT LIKE '%\"page\"%'".to_string());
|
} else if content_filters {
|
||||||
}
|
let mut records: Vec<PostRecord> = post_query(project_id, filters, false, false)
|
||||||
|
.filter(posts::status.eq("draft"))
|
||||||
// For non-draft posts, apply filters. Drafts always pass.
|
.select(PostRecord::as_select())
|
||||||
// We build this as: (status = 'draft') OR (filter conditions)
|
.load(c)?;
|
||||||
let mut filter_conditions: Vec<String> = Vec::new();
|
records.extend(
|
||||||
|
post_query(project_id, filters, true, true)
|
||||||
if !filters.search_query.is_empty() {
|
.select(PostRecord::as_select())
|
||||||
let idx = param_values.len() + 1;
|
.load::<PostRecord>(c)?,
|
||||||
let pattern = format!("%{}%", filters.search_query.replace('%', "\\%"));
|
);
|
||||||
filter_conditions.push(format!("(p.title LIKE ?{idx} ESCAPE '\\')"));
|
records.sort_unstable_by_key(|record| std::cmp::Reverse(record.created_at));
|
||||||
param_values.push(Box::new(pattern));
|
records
|
||||||
}
|
.into_iter()
|
||||||
|
.skip(offset.max(0) as usize)
|
||||||
if let Some(status) = &filters.status {
|
.take(limit.max(0) as usize)
|
||||||
let idx = param_values.len() + 1;
|
.collect()
|
||||||
filter_conditions.push(format!("(p.status = ?{idx})"));
|
|
||||||
param_values.push(Box::new(status.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(language) = &filters.language {
|
|
||||||
let idx = param_values.len() + 1;
|
|
||||||
filter_conditions.push(format!("(p.language = ?{idx} OR p.language IS NULL)"));
|
|
||||||
param_values.push(Box::new(language.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(year) = filters.year {
|
|
||||||
let (start, end) =
|
|
||||||
calendar_range_unix_ms(year, filters.month).ok_or(rusqlite::Error::InvalidQuery)?;
|
|
||||||
let idx1 = param_values.len() + 1;
|
|
||||||
let idx2 = param_values.len() + 2;
|
|
||||||
filter_conditions.push(format!(
|
|
||||||
"(p.created_at >= ?{idx1} AND p.created_at < ?{idx2})"
|
|
||||||
));
|
|
||||||
param_values.push(Box::new(start));
|
|
||||||
param_values.push(Box::new(end));
|
|
||||||
}
|
|
||||||
|
|
||||||
for tag in &filters.tags {
|
|
||||||
let idx = param_values.len() + 1;
|
|
||||||
let pattern = format!("%\"{}\"%", tag.replace('"', "\\\""));
|
|
||||||
filter_conditions.push(format!("(p.tags LIKE ?{idx})"));
|
|
||||||
param_values.push(Box::new(pattern));
|
|
||||||
}
|
|
||||||
|
|
||||||
for cat in &filters.categories {
|
|
||||||
let idx = param_values.len() + 1;
|
|
||||||
let pattern = format!("%\"{}\"%", cat.replace('"', "\\\""));
|
|
||||||
filter_conditions.push(format!("(p.categories LIKE ?{idx})"));
|
|
||||||
param_values.push(Box::new(pattern));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(from) = filters.from {
|
|
||||||
let idx = param_values.len() + 1;
|
|
||||||
filter_conditions.push(format!("(p.created_at >= ?{idx})"));
|
|
||||||
param_values.push(Box::new(from));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(to) = filters.to {
|
|
||||||
let idx = param_values.len() + 1;
|
|
||||||
filter_conditions.push(format!("(p.created_at <= ?{idx})"));
|
|
||||||
param_values.push(Box::new(to));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Without an explicit status filter, drafts remain visible regardless of the
|
|
||||||
// rest of the active filters.
|
|
||||||
if !filter_conditions.is_empty() {
|
|
||||||
let combined = filter_conditions.join(" AND ");
|
|
||||||
if filters.status.is_some() {
|
|
||||||
conditions.push(format!("({combined})"));
|
|
||||||
} else {
|
} else {
|
||||||
conditions.push(format!("(p.status = 'draft' OR ({combined}))"));
|
post_query(project_id, filters, false, false)
|
||||||
}
|
.order(posts::created_at.desc())
|
||||||
}
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
let where_clause = conditions.join(" AND ");
|
.select(PostRecord::as_select())
|
||||||
let idx_limit = param_values.len() + 1;
|
.load(c)?
|
||||||
let idx_offset = param_values.len() + 2;
|
};
|
||||||
param_values.push(Box::new(limit));
|
convert_all(records)
|
||||||
param_values.push(Box::new(offset));
|
})
|
||||||
|
|
||||||
let sql = format!(
|
|
||||||
"SELECT {POST_COLUMNS} FROM posts p WHERE {where_clause} ORDER BY p.created_at DESC LIMIT ?{idx_limit} OFFSET ?{idx_offset}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
|
|
||||||
param_values.iter().map(|b| b.as_ref()).collect();
|
|
||||||
|
|
||||||
let mut stmt = conn.prepare(&sql)?;
|
|
||||||
let rows = stmt.query_map(params_refs.as_slice(), post_from_row)?;
|
|
||||||
rows.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Year/month counts for the calendar archive widget.
|
/// Year/month counts for the calendar archive widget.
|
||||||
/// Returns (year, month, count) tuples, ordered by year DESC, month DESC.
|
/// Returns (year, month, count) tuples, ordered by year DESC, month DESC.
|
||||||
pub fn post_calendar_counts(
|
pub fn post_calendar_counts(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
pages_only: bool,
|
pages_only: bool,
|
||||||
exclude_pages: bool,
|
exclude_pages: bool,
|
||||||
) -> rusqlite::Result<Vec<(i32, u32, usize)>> {
|
) -> QueryResult<Vec<(i32, u32, usize)>> {
|
||||||
let page_filter = if pages_only {
|
conn.with(|c| {
|
||||||
" AND LOWER(categories) LIKE '%\"page\"%'"
|
let rows = posts::table
|
||||||
} else if exclude_pages {
|
.filter(posts::project_id.eq(project_id))
|
||||||
" AND LOWER(categories) NOT LIKE '%\"page\"%'"
|
.select((posts::created_at, posts::categories))
|
||||||
} else {
|
.load::<(i64, String)>(c)?;
|
||||||
""
|
let mut counts = std::collections::BTreeMap::new();
|
||||||
};
|
for (timestamp, categories) in rows {
|
||||||
|
let categories: Vec<String> = serde_json::from_str(&categories).unwrap_or_default();
|
||||||
let sql = format!(
|
let is_page = categories
|
||||||
"SELECT
|
.iter()
|
||||||
CAST(strftime('%Y', created_at / 1000, 'unixepoch') AS INTEGER) AS y,
|
.any(|category| category.eq_ignore_ascii_case("page"));
|
||||||
CAST(strftime('%m', created_at / 1000, 'unixepoch') AS INTEGER) AS m,
|
if (pages_only && !is_page) || (exclude_pages && is_page) {
|
||||||
COUNT(*) AS cnt
|
continue;
|
||||||
FROM posts
|
}
|
||||||
WHERE project_id = ?1{page_filter}
|
let date = Utc
|
||||||
GROUP BY y, m
|
.timestamp_millis_opt(timestamp)
|
||||||
ORDER BY y DESC, m DESC"
|
.single()
|
||||||
);
|
.ok_or_else(|| {
|
||||||
|
diesel::result::Error::DeserializationError("invalid timestamp".into())
|
||||||
let mut stmt = conn.prepare(&sql)?;
|
})?;
|
||||||
let rows = stmt.query_map(params![project_id], |row| {
|
*counts.entry((date.year(), date.month())).or_insert(0) += 1;
|
||||||
Ok((
|
}
|
||||||
row.get::<_, i32>(0)?,
|
Ok(counts
|
||||||
row.get::<_, u32>(1)?,
|
.into_iter()
|
||||||
row.get::<_, usize>(2)?,
|
.rev()
|
||||||
))
|
.map(|((year, month), count)| (year, month, count))
|
||||||
})?;
|
.collect())
|
||||||
rows.collect()
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collect all distinct tag values across posts for a project.
|
/// Collect all distinct tag values across posts for a project.
|
||||||
pub fn distinct_post_tags(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<String>> {
|
pub fn distinct_post_tags(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<String>> {
|
||||||
let mut stmt =
|
let rows = conn.with(|c| {
|
||||||
conn.prepare("SELECT DISTINCT tags FROM posts WHERE project_id = ?1 AND tags != '[]'")?;
|
posts::table
|
||||||
let rows = stmt.query_map(params![project_id], |row| row.get::<_, String>(0))?;
|
.filter(posts::project_id.eq(project_id))
|
||||||
|
.filter(posts::tags.ne("[]"))
|
||||||
|
.select(posts::tags)
|
||||||
|
.distinct()
|
||||||
|
.load::<String>(c)
|
||||||
|
})?;
|
||||||
let mut all_tags = std::collections::BTreeSet::new();
|
let mut all_tags = std::collections::BTreeSet::new();
|
||||||
for json_str in rows {
|
for json_str in rows {
|
||||||
if let Ok(json_str) = json_str
|
if let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str) {
|
||||||
&& let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str)
|
|
||||||
{
|
|
||||||
all_tags.extend(tags);
|
all_tags.extend(tags);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -430,25 +395,53 @@ pub fn distinct_post_tags(conn: &Connection, project_id: &str) -> rusqlite::Resu
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Collect all distinct category values across posts for a project.
|
/// Collect all distinct category values across posts for a project.
|
||||||
pub fn distinct_post_categories(
|
pub fn distinct_post_categories(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<String>> {
|
||||||
conn: &Connection,
|
let rows = conn.with(|c| {
|
||||||
project_id: &str,
|
posts::table
|
||||||
) -> rusqlite::Result<Vec<String>> {
|
.filter(posts::project_id.eq(project_id))
|
||||||
let mut stmt = conn.prepare(
|
.filter(posts::categories.ne("[]"))
|
||||||
"SELECT DISTINCT categories FROM posts WHERE project_id = ?1 AND categories != '[]'",
|
.select(posts::categories)
|
||||||
)?;
|
.distinct()
|
||||||
let rows = stmt.query_map(params![project_id], |row| row.get::<_, String>(0))?;
|
.load::<String>(c)
|
||||||
|
})?;
|
||||||
let mut all_cats = std::collections::BTreeSet::new();
|
let mut all_cats = std::collections::BTreeSet::new();
|
||||||
for json_str in rows {
|
for json_str in rows {
|
||||||
if let Ok(json_str) = json_str
|
if let Ok(cats) = serde_json::from_str::<Vec<String>>(&json_str) {
|
||||||
&& let Ok(cats) = serde_json::from_str::<Vec<String>>(&json_str)
|
|
||||||
{
|
|
||||||
all_cats.extend(cats);
|
all_cats.extend(cats);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(all_cats.into_iter().collect())
|
Ok(all_cats.into_iter().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn make_test_post(id: &str, project_id: &str, slug: &str) -> Post {
|
||||||
|
Post {
|
||||||
|
id: id.into(),
|
||||||
|
project_id: project_id.into(),
|
||||||
|
title: id.into(),
|
||||||
|
slug: slug.into(),
|
||||||
|
excerpt: None,
|
||||||
|
content: None,
|
||||||
|
status: PostStatus::Draft,
|
||||||
|
author: None,
|
||||||
|
language: None,
|
||||||
|
do_not_translate: false,
|
||||||
|
template_slug: None,
|
||||||
|
file_path: String::new(),
|
||||||
|
checksum: None,
|
||||||
|
tags: Vec::new(),
|
||||||
|
categories: Vec::new(),
|
||||||
|
published_title: None,
|
||||||
|
published_content: None,
|
||||||
|
published_tags: None,
|
||||||
|
published_categories: None,
|
||||||
|
published_excerpt: None,
|
||||||
|
created_at: 1000,
|
||||||
|
updated_at: 1000,
|
||||||
|
published_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -456,7 +449,7 @@ mod tests {
|
|||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||||
db
|
db
|
||||||
|
|||||||
@@ -1,82 +1,77 @@
|
|||||||
use rusqlite::{Connection, params};
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use crate::db::from_row::{POST_LINK_COLUMNS, post_link_from_row};
|
use crate::db::DbConnection;
|
||||||
|
use crate::db::from_row::PostLinkRecord;
|
||||||
|
use crate::db::schema::post_links;
|
||||||
use crate::model::PostLink;
|
use crate::model::PostLink;
|
||||||
|
|
||||||
pub fn insert_post_link(conn: &Connection, link: &PostLink) -> rusqlite::Result<()> {
|
pub fn insert_post_link(conn: &DbConnection, link: &PostLink) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"INSERT INTO post_links (id, source_post_id, target_post_id, link_text, created_at)
|
diesel::insert_into(post_links::table)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
.values(PostLinkRecord::from(link))
|
||||||
params![
|
.execute(c)
|
||||||
link.id,
|
.map(|_| ())
|
||||||
link.source_post_id,
|
})
|
||||||
link.target_post_id,
|
|
||||||
link.link_text,
|
|
||||||
link.created_at,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_links_by_source(conn: &Connection, source_post_id: &str) -> rusqlite::Result<()> {
|
pub fn delete_links_by_source(conn: &DbConnection, source_post_id: &str) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"DELETE FROM post_links WHERE source_post_id = ?1",
|
diesel::delete(post_links::table.filter(post_links::source_post_id.eq(source_post_id)))
|
||||||
params![source_post_id],
|
.execute(c)
|
||||||
)?;
|
.map(|_| ())
|
||||||
Ok(())
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_links_by_target(conn: &DbConnection, target_post_id: &str) -> QueryResult<()> {
|
||||||
|
conn.with(|c| {
|
||||||
|
diesel::delete(post_links::table.filter(post_links::target_post_id.eq(target_post_id)))
|
||||||
|
.execute(c)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_links_by_source(
|
pub fn list_links_by_source(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
source_post_id: &str,
|
source_post_id: &str,
|
||||||
) -> rusqlite::Result<Vec<PostLink>> {
|
) -> QueryResult<Vec<PostLink>> {
|
||||||
let mut stmt = conn.prepare(&format!(
|
conn.with(|c| {
|
||||||
"SELECT {POST_LINK_COLUMNS} FROM post_links WHERE source_post_id = ?1 ORDER BY created_at"
|
post_links::table
|
||||||
))?;
|
.filter(post_links::source_post_id.eq(source_post_id))
|
||||||
let rows = stmt.query_map(params![source_post_id], post_link_from_row)?;
|
.order(post_links::created_at)
|
||||||
rows.collect()
|
.select(PostLinkRecord::as_select())
|
||||||
|
.load(c)
|
||||||
|
.map(|rows: Vec<PostLinkRecord>| rows.into_iter().map(Into::into).collect())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_links_by_target(
|
pub fn list_links_by_target(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
target_post_id: &str,
|
target_post_id: &str,
|
||||||
) -> rusqlite::Result<Vec<PostLink>> {
|
) -> QueryResult<Vec<PostLink>> {
|
||||||
let mut stmt = conn.prepare(&format!(
|
conn.with(|c| {
|
||||||
"SELECT {POST_LINK_COLUMNS} FROM post_links WHERE target_post_id = ?1 ORDER BY created_at"
|
post_links::table
|
||||||
))?;
|
.filter(post_links::target_post_id.eq(target_post_id))
|
||||||
let rows = stmt.query_map(params![target_post_id], post_link_from_row)?;
|
.order(post_links::created_at)
|
||||||
rows.collect()
|
.select(PostLinkRecord::as_select())
|
||||||
|
.load(c)
|
||||||
|
.map(|rows: Vec<PostLinkRecord>| rows.into_iter().map(Into::into).collect())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
|
use crate::db::queries::post::{insert_post, make_test_post};
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||||
let c = db.conn();
|
insert_post(db.conn(), &make_test_post("a", "p1", "a")).unwrap();
|
||||||
c.execute(
|
insert_post(db.conn(), &make_test_post("b", "p1", "b")).unwrap();
|
||||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
insert_post(db.conn(), &make_test_post("c", "p1", "c")).unwrap();
|
||||||
VALUES ('a', 'p1', 'A', 'a', 'draft', 1000, 1000)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
c.execute(
|
|
||||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
|
||||||
VALUES ('b', 'p1', 'B', 'b', 'draft', 1000, 1000)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
c.execute(
|
|
||||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
|
||||||
VALUES ('c', 'p1', 'C', 'c', 'draft', 1000, 1000)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
db
|
db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,65 +1,80 @@
|
|||||||
use rusqlite::{Connection, params};
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use crate::db::from_row::{POST_MEDIA_COLUMNS, post_media_from_row};
|
use crate::db::DbConnection;
|
||||||
|
use crate::db::from_row::PostMediaRecord;
|
||||||
|
use crate::db::schema::post_media;
|
||||||
use crate::model::PostMedia;
|
use crate::model::PostMedia;
|
||||||
|
|
||||||
pub fn link_media(conn: &Connection, pm: &PostMedia) -> rusqlite::Result<()> {
|
pub fn link_media(conn: &DbConnection, pm: &PostMedia) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at)
|
diesel::insert_into(post_media::table)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
.values(PostMediaRecord::from(pm))
|
||||||
params![
|
.execute(c)
|
||||||
pm.id,
|
.map(|_| ())
|
||||||
pm.project_id,
|
})
|
||||||
pm.post_id,
|
|
||||||
pm.media_id,
|
|
||||||
pm.sort_order,
|
|
||||||
pm.created_at,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unlink_media(conn: &Connection, post_id: &str, media_id: &str) -> rusqlite::Result<()> {
|
pub fn unlink_media(conn: &DbConnection, post_id: &str, media_id: &str) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"DELETE FROM post_media WHERE post_id = ?1 AND media_id = ?2",
|
diesel::delete(
|
||||||
params![post_id, media_id],
|
post_media::table
|
||||||
)?;
|
.filter(post_media::post_id.eq(post_id))
|
||||||
Ok(())
|
.filter(post_media::media_id.eq(media_id)),
|
||||||
|
)
|
||||||
|
.execute(c)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_post_media_by_post(
|
pub fn delete_post_media_by_post(conn: &DbConnection, post_id: &str) -> QueryResult<()> {
|
||||||
conn: &Connection,
|
conn.with(|c| {
|
||||||
post_id: &str,
|
diesel::delete(post_media::table.filter(post_media::post_id.eq(post_id)))
|
||||||
) -> rusqlite::Result<Vec<PostMedia>> {
|
.execute(c)
|
||||||
let mut stmt = conn.prepare(&format!(
|
.map(|_| ())
|
||||||
"SELECT {POST_MEDIA_COLUMNS} FROM post_media WHERE post_id = ?1 ORDER BY sort_order"
|
})
|
||||||
))?;
|
}
|
||||||
let rows = stmt.query_map(params![post_id], post_media_from_row)?;
|
|
||||||
rows.collect()
|
pub fn list_post_media_by_post(conn: &DbConnection, post_id: &str) -> QueryResult<Vec<PostMedia>> {
|
||||||
|
conn.with(|c| {
|
||||||
|
post_media::table
|
||||||
|
.filter(post_media::post_id.eq(post_id))
|
||||||
|
.order(post_media::sort_order)
|
||||||
|
.select(PostMediaRecord::as_select())
|
||||||
|
.load(c)
|
||||||
|
.map(|rows: Vec<PostMediaRecord>| rows.into_iter().map(Into::into).collect())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_post_media_by_media(
|
pub fn list_post_media_by_media(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
media_id: &str,
|
media_id: &str,
|
||||||
) -> rusqlite::Result<Vec<PostMedia>> {
|
) -> QueryResult<Vec<PostMedia>> {
|
||||||
let mut stmt = conn.prepare(&format!(
|
conn.with(|c| {
|
||||||
"SELECT {POST_MEDIA_COLUMNS} FROM post_media WHERE media_id = ?1 ORDER BY created_at"
|
post_media::table
|
||||||
))?;
|
.filter(post_media::media_id.eq(media_id))
|
||||||
let rows = stmt.query_map(params![media_id], post_media_from_row)?;
|
.order(post_media::created_at)
|
||||||
rows.collect()
|
.select(PostMediaRecord::as_select())
|
||||||
|
.load(c)
|
||||||
|
.map(|rows: Vec<PostMediaRecord>| rows.into_iter().map(Into::into).collect())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_sort_order(
|
pub fn update_sort_order(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
post_id: &str,
|
post_id: &str,
|
||||||
media_id: &str,
|
media_id: &str,
|
||||||
sort_order: i32,
|
sort_order: i32,
|
||||||
) -> rusqlite::Result<()> {
|
) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"UPDATE post_media SET sort_order = ?1 WHERE post_id = ?2 AND media_id = ?3",
|
diesel::update(
|
||||||
params![sort_order, post_id, media_id],
|
post_media::table
|
||||||
)?;
|
.filter(post_media::post_id.eq(post_id))
|
||||||
Ok(())
|
.filter(post_media::media_id.eq(media_id)),
|
||||||
|
)
|
||||||
|
.set(post_media::sort_order.eq(sort_order))
|
||||||
|
.execute(c)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -67,19 +82,14 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
use crate::db::queries::media::{insert_media, make_test_media};
|
use crate::db::queries::media::{insert_media, make_test_media};
|
||||||
|
use crate::db::queries::post::{insert_post, make_test_post};
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||||
db.conn()
|
insert_post(db.conn(), &make_test_post("post1", "p1", "hello")).unwrap();
|
||||||
.execute(
|
|
||||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
|
||||||
VALUES ('post1', 'p1', 'Hello', 'hello', 'draft', 1000, 1000)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
insert_media(db.conn(), &make_test_media("m1", "p1")).unwrap();
|
insert_media(db.conn(), &make_test_media("m1", "p1")).unwrap();
|
||||||
insert_media(db.conn(), &make_test_media("m2", "p1")).unwrap();
|
insert_media(db.conn(), &make_test_media("m2", "p1")).unwrap();
|
||||||
db
|
db
|
||||||
|
|||||||
@@ -1,139 +1,111 @@
|
|||||||
use rusqlite::{Connection, params};
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use crate::db::from_row::{
|
use crate::db::DbConnection;
|
||||||
POST_TRANSLATION_COLUMNS, post_status_to_str, post_translation_from_row,
|
use crate::db::from_row::{PostTranslationRecord, convert, convert_all};
|
||||||
};
|
use crate::db::schema::post_translations;
|
||||||
use crate::model::PostTranslation;
|
use crate::model::PostTranslation;
|
||||||
|
|
||||||
pub fn insert_post_translation(conn: &Connection, t: &PostTranslation) -> rusqlite::Result<()> {
|
pub fn insert_post_translation(conn: &DbConnection, t: &PostTranslation) -> QueryResult<()> {
|
||||||
if !t.status.is_valid_for_translation() {
|
if !t.status.is_valid_for_translation() {
|
||||||
return Err(rusqlite::Error::InvalidParameterName(
|
return Err(diesel::result::Error::SerializationError(
|
||||||
"translation status must be draft or published".to_string(),
|
"translation status must be draft or published".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"INSERT INTO post_translations (
|
diesel::insert_into(post_translations::table)
|
||||||
id, project_id, translation_for, language, title, excerpt, content,
|
.values(PostTranslationRecord::from(t))
|
||||||
status, file_path, checksum, created_at, updated_at, published_at
|
.execute(c)
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
.map(|_| ())
|
||||||
params![
|
})
|
||||||
t.id,
|
|
||||||
t.project_id,
|
|
||||||
t.translation_for,
|
|
||||||
t.language,
|
|
||||||
t.title,
|
|
||||||
t.excerpt,
|
|
||||||
t.content,
|
|
||||||
post_status_to_str(&t.status),
|
|
||||||
t.file_path,
|
|
||||||
t.checksum,
|
|
||||||
t.created_at,
|
|
||||||
t.updated_at,
|
|
||||||
t.published_at,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_post_translation_by_id(
|
pub fn get_post_translation_by_id(conn: &DbConnection, id: &str) -> QueryResult<PostTranslation> {
|
||||||
conn: &Connection,
|
conn.with(|c| {
|
||||||
id: &str,
|
post_translations::table
|
||||||
) -> rusqlite::Result<PostTranslation> {
|
.filter(post_translations::id.eq(id))
|
||||||
conn.query_row(
|
.select(PostTranslationRecord::as_select())
|
||||||
&format!("SELECT {POST_TRANSLATION_COLUMNS} FROM post_translations WHERE id = ?1"),
|
.first(c)
|
||||||
params![id],
|
.and_then(convert)
|
||||||
post_translation_from_row,
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_post_translation_by_post_and_language(
|
pub fn get_post_translation_by_post_and_language(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
translation_for: &str,
|
translation_for: &str,
|
||||||
language: &str,
|
language: &str,
|
||||||
) -> rusqlite::Result<PostTranslation> {
|
) -> QueryResult<PostTranslation> {
|
||||||
conn.query_row(
|
conn.with(|c| {
|
||||||
&format!(
|
post_translations::table
|
||||||
"SELECT {POST_TRANSLATION_COLUMNS} FROM post_translations
|
.filter(post_translations::translation_for.eq(translation_for))
|
||||||
WHERE translation_for = ?1 AND language = ?2"
|
.filter(post_translations::language.eq(language))
|
||||||
),
|
.select(PostTranslationRecord::as_select())
|
||||||
params![translation_for, language],
|
.first(c)
|
||||||
post_translation_from_row,
|
.and_then(convert)
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_post_translations_by_post(
|
pub fn list_post_translations_by_post(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
translation_for: &str,
|
translation_for: &str,
|
||||||
) -> rusqlite::Result<Vec<PostTranslation>> {
|
) -> QueryResult<Vec<PostTranslation>> {
|
||||||
let mut stmt = conn.prepare(&format!(
|
conn.with(|c| {
|
||||||
"SELECT {POST_TRANSLATION_COLUMNS} FROM post_translations
|
post_translations::table
|
||||||
WHERE translation_for = ?1 ORDER BY language"
|
.filter(post_translations::translation_for.eq(translation_for))
|
||||||
))?;
|
.order(post_translations::language)
|
||||||
let rows = stmt.query_map(params![translation_for], post_translation_from_row)?;
|
.select(PostTranslationRecord::as_select())
|
||||||
rows.collect()
|
.load(c)
|
||||||
|
.and_then(convert_all)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_post_translation(conn: &Connection, t: &PostTranslation) -> rusqlite::Result<()> {
|
pub fn update_post_translation(conn: &DbConnection, t: &PostTranslation) -> QueryResult<()> {
|
||||||
if !t.status.is_valid_for_translation() {
|
if !t.status.is_valid_for_translation() {
|
||||||
return Err(rusqlite::Error::InvalidParameterName(
|
return Err(diesel::result::Error::SerializationError(
|
||||||
"translation status must be draft or published".to_string(),
|
"translation status must be draft or published".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"UPDATE post_translations SET
|
diesel::update(post_translations::table.filter(post_translations::id.eq(&t.id)))
|
||||||
title = ?1, excerpt = ?2, content = ?3, status = ?4,
|
.set(PostTranslationRecord::from(t))
|
||||||
file_path = ?5, checksum = ?6, updated_at = ?7, published_at = ?8
|
.execute(c)
|
||||||
WHERE id = ?9",
|
.map(|_| ())
|
||||||
params![
|
})
|
||||||
t.title,
|
|
||||||
t.excerpt,
|
|
||||||
t.content,
|
|
||||||
post_status_to_str(&t.status),
|
|
||||||
t.file_path,
|
|
||||||
t.checksum,
|
|
||||||
t.updated_at,
|
|
||||||
t.published_at,
|
|
||||||
t.id,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_post_translation(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
pub fn delete_post_translation(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||||
conn.execute("DELETE FROM post_translations WHERE id = ?1", params![id])?;
|
conn.with(|c| {
|
||||||
Ok(())
|
diesel::delete(post_translations::table.filter(post_translations::id.eq(id)))
|
||||||
|
.execute(c)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_all_translations_for_post(
|
pub fn delete_all_translations_for_post(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
translation_for: &str,
|
translation_for: &str,
|
||||||
) -> rusqlite::Result<()> {
|
) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"DELETE FROM post_translations WHERE translation_for = ?1",
|
diesel::delete(
|
||||||
params![translation_for],
|
post_translations::table.filter(post_translations::translation_for.eq(translation_for)),
|
||||||
)?;
|
)
|
||||||
Ok(())
|
.execute(c)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
|
use crate::db::queries::post::{insert_post, make_test_post};
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
use crate::model::PostStatus;
|
use crate::model::PostStatus;
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||||
// insert a parent post
|
insert_post(db.conn(), &make_test_post("post1", "p1", "hello")).unwrap();
|
||||||
db.conn()
|
|
||||||
.execute(
|
|
||||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
|
||||||
VALUES ('post1', 'p1', 'Hello', 'hello', 'draft', 1000, 1000)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
db
|
db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,90 +1,88 @@
|
|||||||
use rusqlite::{Connection, params};
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use crate::db::from_row::{PROJECT_COLUMNS, project_from_row};
|
use crate::db::DbConnection;
|
||||||
|
use crate::db::from_row::ProjectRecord;
|
||||||
|
use crate::db::schema::projects;
|
||||||
use crate::model::Project;
|
use crate::model::Project;
|
||||||
|
|
||||||
pub fn insert_project(conn: &Connection, project: &Project) -> rusqlite::Result<()> {
|
pub fn insert_project(conn: &DbConnection, project: &Project) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"INSERT INTO projects (id, name, slug, description, data_path, is_active, created_at, updated_at)
|
diesel::insert_into(projects::table)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
.values(ProjectRecord::from(project))
|
||||||
params![
|
.execute(c)
|
||||||
project.id,
|
.map(|_| ())
|
||||||
project.name,
|
})
|
||||||
project.slug,
|
|
||||||
project.description,
|
|
||||||
project.data_path,
|
|
||||||
project.is_active as i64,
|
|
||||||
project.created_at,
|
|
||||||
project.updated_at,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_project_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Project> {
|
pub fn get_project_by_id(conn: &DbConnection, id: &str) -> QueryResult<Project> {
|
||||||
conn.query_row(
|
conn.with(|c| {
|
||||||
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE id = ?1"),
|
projects::table
|
||||||
params![id],
|
.filter(projects::id.eq(id))
|
||||||
project_from_row,
|
.select(ProjectRecord::as_select())
|
||||||
)
|
.first(c)
|
||||||
|
.map(Into::into)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_project_by_slug(conn: &Connection, slug: &str) -> rusqlite::Result<Project> {
|
pub fn get_project_by_slug(conn: &DbConnection, slug: &str) -> QueryResult<Project> {
|
||||||
conn.query_row(
|
conn.with(|c| {
|
||||||
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE slug = ?1"),
|
projects::table
|
||||||
params![slug],
|
.filter(projects::slug.eq(slug))
|
||||||
project_from_row,
|
.select(ProjectRecord::as_select())
|
||||||
)
|
.first(c)
|
||||||
|
.map(Into::into)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_active_project(conn: &Connection) -> rusqlite::Result<Project> {
|
pub fn get_active_project(conn: &DbConnection) -> QueryResult<Project> {
|
||||||
conn.query_row(
|
conn.with(|c| {
|
||||||
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE is_active = 1 LIMIT 1"),
|
projects::table
|
||||||
[],
|
.filter(projects::is_active.eq(1))
|
||||||
project_from_row,
|
.select(ProjectRecord::as_select())
|
||||||
)
|
.first(c)
|
||||||
|
.map(Into::into)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_active_project(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
pub fn set_active_project(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||||
let tx = conn.unchecked_transaction()?;
|
conn.with(|c| {
|
||||||
tx.execute("UPDATE projects SET is_active = 0 WHERE is_active = 1", [])?;
|
c.transaction(|c| {
|
||||||
tx.execute(
|
diesel::update(projects::table.filter(projects::is_active.eq(1)))
|
||||||
"UPDATE projects SET is_active = 1 WHERE id = ?1",
|
.set(projects::is_active.eq(0))
|
||||||
params![id],
|
.execute(c)?;
|
||||||
)?;
|
diesel::update(projects::table.filter(projects::id.eq(id)))
|
||||||
tx.commit()?;
|
.set(projects::is_active.eq(1))
|
||||||
Ok(())
|
.execute(c)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_projects(conn: &Connection) -> rusqlite::Result<Vec<Project>> {
|
pub fn list_projects(conn: &DbConnection) -> QueryResult<Vec<Project>> {
|
||||||
let mut stmt = conn.prepare(&format!(
|
conn.with(|c| {
|
||||||
"SELECT {PROJECT_COLUMNS} FROM projects ORDER BY name"
|
projects::table
|
||||||
))?;
|
.order(projects::name)
|
||||||
let rows = stmt.query_map([], project_from_row)?;
|
.select(ProjectRecord::as_select())
|
||||||
rows.collect()
|
.load(c)
|
||||||
|
.map(|rows: Vec<ProjectRecord>| rows.into_iter().map(Into::into).collect())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_project(conn: &Connection, project: &Project) -> rusqlite::Result<()> {
|
pub fn update_project(conn: &DbConnection, project: &Project) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"UPDATE projects SET name = ?1, slug = ?2, description = ?3, data_path = ?4,
|
diesel::update(projects::table.filter(projects::id.eq(&project.id)))
|
||||||
is_active = ?5, updated_at = ?6
|
.set(ProjectRecord::from(project))
|
||||||
WHERE id = ?7",
|
.execute(c)
|
||||||
params![
|
.map(|_| ())
|
||||||
project.name,
|
})
|
||||||
project.slug,
|
|
||||||
project.description,
|
|
||||||
project.data_path,
|
|
||||||
project.is_active as i64,
|
|
||||||
project.updated_at,
|
|
||||||
project.id,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_project(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
pub fn delete_project(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||||
conn.execute("DELETE FROM projects WHERE id = ?1", params![id])?;
|
conn.with(|c| {
|
||||||
Ok(())
|
diesel::delete(projects::table.filter(projects::id.eq(id)))
|
||||||
|
.execute(c)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test helper: create a minimal Project value (available to sibling test modules).
|
/// Test helper: create a minimal Project value (available to sibling test modules).
|
||||||
@@ -108,7 +106,7 @@ mod tests {
|
|||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
db
|
db
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,92 +1,70 @@
|
|||||||
use rusqlite::{Connection, params};
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use crate::db::from_row::{
|
use crate::db::DbConnection;
|
||||||
SCRIPT_COLUMNS, script_from_row, script_kind_to_str, script_status_to_str,
|
use crate::db::from_row::{ScriptRecord, convert, convert_all};
|
||||||
};
|
use crate::db::schema::scripts;
|
||||||
use crate::model::Script;
|
use crate::model::Script;
|
||||||
|
|
||||||
pub fn insert_script(conn: &Connection, s: &Script) -> rusqlite::Result<()> {
|
pub fn insert_script(conn: &DbConnection, s: &Script) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"INSERT INTO scripts (
|
diesel::insert_into(scripts::table)
|
||||||
id, project_id, slug, title, kind, entrypoint, enabled, version,
|
.values(ScriptRecord::from(s))
|
||||||
file_path, status, content, created_at, updated_at
|
.execute(c)
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
.map(|_| ())
|
||||||
params![
|
})
|
||||||
s.id,
|
|
||||||
s.project_id,
|
|
||||||
s.slug,
|
|
||||||
s.title,
|
|
||||||
script_kind_to_str(&s.kind),
|
|
||||||
s.entrypoint,
|
|
||||||
s.enabled as i64,
|
|
||||||
s.version,
|
|
||||||
s.file_path,
|
|
||||||
script_status_to_str(&s.status),
|
|
||||||
s.content,
|
|
||||||
s.created_at,
|
|
||||||
s.updated_at,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_script_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Script> {
|
pub fn get_script_by_id(conn: &DbConnection, id: &str) -> QueryResult<Script> {
|
||||||
conn.query_row(
|
conn.with(|c| {
|
||||||
&format!("SELECT {SCRIPT_COLUMNS} FROM scripts WHERE id = ?1"),
|
scripts::table
|
||||||
params![id],
|
.filter(scripts::id.eq(id))
|
||||||
script_from_row,
|
.select(ScriptRecord::as_select())
|
||||||
)
|
.first(c)
|
||||||
|
.and_then(convert)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_script_by_slug(
|
pub fn get_script_by_slug(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
slug: &str,
|
slug: &str,
|
||||||
) -> rusqlite::Result<Script> {
|
) -> QueryResult<Script> {
|
||||||
conn.query_row(
|
conn.with(|c| {
|
||||||
&format!("SELECT {SCRIPT_COLUMNS} FROM scripts WHERE project_id = ?1 AND slug = ?2"),
|
scripts::table
|
||||||
params![project_id, slug],
|
.filter(scripts::project_id.eq(project_id))
|
||||||
script_from_row,
|
.filter(scripts::slug.eq(slug))
|
||||||
)
|
.select(ScriptRecord::as_select())
|
||||||
|
.first(c)
|
||||||
|
.and_then(convert)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_scripts_by_project(
|
pub fn list_scripts_by_project(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<Script>> {
|
||||||
conn: &Connection,
|
conn.with(|c| {
|
||||||
project_id: &str,
|
scripts::table
|
||||||
) -> rusqlite::Result<Vec<Script>> {
|
.filter(scripts::project_id.eq(project_id))
|
||||||
let mut stmt = conn.prepare(&format!(
|
.order(scripts::title)
|
||||||
"SELECT {SCRIPT_COLUMNS} FROM scripts WHERE project_id = ?1 ORDER BY title"
|
.select(ScriptRecord::as_select())
|
||||||
))?;
|
.load(c)
|
||||||
let rows = stmt.query_map(params![project_id], script_from_row)?;
|
.and_then(convert_all)
|
||||||
rows.collect()
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_script(conn: &Connection, s: &Script) -> rusqlite::Result<()> {
|
pub fn update_script(conn: &DbConnection, s: &Script) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"UPDATE scripts SET
|
diesel::update(scripts::table.filter(scripts::id.eq(&s.id)))
|
||||||
slug = ?1, title = ?2, kind = ?3, entrypoint = ?4, enabled = ?5,
|
.set(ScriptRecord::from(s))
|
||||||
version = ?6, file_path = ?7, status = ?8, content = ?9, updated_at = ?10
|
.execute(c)
|
||||||
WHERE id = ?11",
|
.map(|_| ())
|
||||||
params![
|
})
|
||||||
s.slug,
|
|
||||||
s.title,
|
|
||||||
script_kind_to_str(&s.kind),
|
|
||||||
s.entrypoint,
|
|
||||||
s.enabled as i64,
|
|
||||||
s.version,
|
|
||||||
s.file_path,
|
|
||||||
script_status_to_str(&s.status),
|
|
||||||
s.content,
|
|
||||||
s.updated_at,
|
|
||||||
s.id,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_script(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
pub fn delete_script(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||||
conn.execute("DELETE FROM scripts WHERE id = ?1", params![id])?;
|
conn.with(|c| {
|
||||||
Ok(())
|
diesel::delete(scripts::table.filter(scripts::id.eq(id)))
|
||||||
|
.execute(c)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -97,7 +75,7 @@ mod tests {
|
|||||||
use crate::model::{ScriptKind, ScriptStatus};
|
use crate::model::{ScriptKind, ScriptStatus};
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||||
db
|
db
|
||||||
|
|||||||
@@ -1,36 +1,52 @@
|
|||||||
use rusqlite::{Connection, params};
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use crate::db::from_row::{SETTING_COLUMNS, setting_from_row};
|
use crate::db::DbConnection;
|
||||||
|
use crate::db::from_row::SettingRecord;
|
||||||
|
use crate::db::schema::settings;
|
||||||
use crate::model::Setting;
|
use crate::model::Setting;
|
||||||
|
|
||||||
pub fn get_setting_by_key(conn: &Connection, key: &str) -> rusqlite::Result<Setting> {
|
pub fn get_setting_by_key(conn: &DbConnection, key: &str) -> QueryResult<Setting> {
|
||||||
conn.query_row(
|
conn.with(|c| {
|
||||||
&format!("SELECT {SETTING_COLUMNS} FROM settings WHERE key = ?1"),
|
settings::table
|
||||||
params![key],
|
.filter(settings::key.eq(key))
|
||||||
setting_from_row,
|
.select(SettingRecord::as_select())
|
||||||
)
|
.first(c)
|
||||||
|
.map(Into::into)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_setting_value(
|
pub fn set_setting_value(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
key: &str,
|
key: &str,
|
||||||
value: &str,
|
value: &str,
|
||||||
updated_at: i64,
|
updated_at: i64,
|
||||||
) -> rusqlite::Result<()> {
|
) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"INSERT INTO settings (key, value, updated_at) VALUES (?1, ?2, ?3)
|
diesel::insert_into(settings::table)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
|
.values((
|
||||||
params![key, value, updated_at],
|
settings::key.eq(key),
|
||||||
)?;
|
settings::value.eq(value),
|
||||||
Ok(())
|
settings::updated_at.eq(updated_at),
|
||||||
|
))
|
||||||
|
.on_conflict(settings::key)
|
||||||
|
.do_update()
|
||||||
|
.set((
|
||||||
|
settings::value.eq(value),
|
||||||
|
settings::updated_at.eq(updated_at),
|
||||||
|
))
|
||||||
|
.execute(c)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_all_settings(conn: &Connection) -> rusqlite::Result<Vec<Setting>> {
|
pub fn list_all_settings(conn: &DbConnection) -> QueryResult<Vec<Setting>> {
|
||||||
let mut stmt = conn.prepare(&format!(
|
conn.with(|c| {
|
||||||
"SELECT {SETTING_COLUMNS} FROM settings ORDER BY key"
|
settings::table
|
||||||
))?;
|
.order(settings::key)
|
||||||
let rows = stmt.query_map([], setting_from_row)?;
|
.select(SettingRecord::as_select())
|
||||||
rows.collect()
|
.load(c)
|
||||||
|
.map(|rows: Vec<SettingRecord>| rows.into_iter().map(Into::into).collect())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -39,7 +55,7 @@ mod tests {
|
|||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
db
|
db
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +1,73 @@
|
|||||||
use rusqlite::{Connection, params};
|
use diesel::prelude::*;
|
||||||
|
use diesel::sql_types::Text;
|
||||||
|
|
||||||
use crate::db::from_row::{TAG_COLUMNS, tag_from_row};
|
use crate::db::DbConnection;
|
||||||
|
use crate::db::from_row::TagRecord;
|
||||||
|
use crate::db::schema::tags;
|
||||||
use crate::model::Tag;
|
use crate::model::Tag;
|
||||||
|
|
||||||
pub fn insert_tag(conn: &Connection, tag: &Tag) -> rusqlite::Result<()> {
|
diesel::define_sql_function!(fn lower(value: Text) -> Text);
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO tags (id, project_id, name, color, post_template_slug, created_at, updated_at)
|
pub fn insert_tag(conn: &DbConnection, tag: &Tag) -> QueryResult<()> {
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
conn.with(|c| {
|
||||||
params![
|
diesel::insert_into(tags::table)
|
||||||
tag.id,
|
.values(TagRecord::from(tag))
|
||||||
tag.project_id,
|
.execute(c)
|
||||||
tag.name,
|
.map(|_| ())
|
||||||
tag.color,
|
})
|
||||||
tag.post_template_slug,
|
|
||||||
tag.created_at,
|
|
||||||
tag.updated_at,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_tag_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Tag> {
|
pub fn get_tag_by_id(conn: &DbConnection, id: &str) -> QueryResult<Tag> {
|
||||||
conn.query_row(
|
conn.with(|c| {
|
||||||
&format!("SELECT {TAG_COLUMNS} FROM tags WHERE id = ?1"),
|
tags::table
|
||||||
params![id],
|
.filter(tags::id.eq(id))
|
||||||
tag_from_row,
|
.select(TagRecord::as_select())
|
||||||
)
|
.first(c)
|
||||||
|
.map(Into::into)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_tag_by_project_and_name(
|
pub fn get_tag_by_project_and_name(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> rusqlite::Result<Tag> {
|
) -> QueryResult<Tag> {
|
||||||
conn.query_row(
|
conn.with(|c| {
|
||||||
&format!(
|
tags::table
|
||||||
"SELECT {TAG_COLUMNS} FROM tags WHERE project_id = ?1 AND LOWER(name) = LOWER(?2)"
|
.filter(tags::project_id.eq(project_id))
|
||||||
),
|
.filter(lower(tags::name).eq(name.to_lowercase()))
|
||||||
params![project_id, name],
|
.select(TagRecord::as_select())
|
||||||
tag_from_row,
|
.first(c)
|
||||||
)
|
.map(Into::into)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_tags_by_project(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<Tag>> {
|
pub fn list_tags_by_project(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<Tag>> {
|
||||||
let mut stmt = conn.prepare(&format!(
|
conn.with(|c| {
|
||||||
"SELECT {TAG_COLUMNS} FROM tags WHERE project_id = ?1 ORDER BY name"
|
tags::table
|
||||||
))?;
|
.filter(tags::project_id.eq(project_id))
|
||||||
let rows = stmt.query_map(params![project_id], tag_from_row)?;
|
.order(tags::name)
|
||||||
rows.collect()
|
.select(TagRecord::as_select())
|
||||||
|
.load(c)
|
||||||
|
.map(|rows: Vec<TagRecord>| rows.into_iter().map(Into::into).collect())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_tag(conn: &Connection, tag: &Tag) -> rusqlite::Result<()> {
|
pub fn update_tag(conn: &DbConnection, tag: &Tag) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"UPDATE tags SET name = ?1, color = ?2, post_template_slug = ?3, updated_at = ?4
|
diesel::update(tags::table.filter(tags::id.eq(&tag.id)))
|
||||||
WHERE id = ?5",
|
.set(TagRecord::from(tag))
|
||||||
params![
|
.execute(c)
|
||||||
tag.name,
|
.map(|_| ())
|
||||||
tag.color,
|
})
|
||||||
tag.post_template_slug,
|
|
||||||
tag.updated_at,
|
|
||||||
tag.id,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_tag(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
pub fn delete_tag(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||||
conn.execute("DELETE FROM tags WHERE id = ?1", params![id])?;
|
conn.with(|c| {
|
||||||
Ok(())
|
diesel::delete(tags::table.filter(tags::id.eq(id)))
|
||||||
|
.execute(c)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -77,7 +77,7 @@ mod tests {
|
|||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||||
db
|
db
|
||||||
|
|||||||
@@ -1,90 +1,73 @@
|
|||||||
use rusqlite::{Connection, params};
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use crate::db::from_row::{
|
use crate::db::DbConnection;
|
||||||
TEMPLATE_COLUMNS, template_from_row, template_kind_to_str, template_status_to_str,
|
use crate::db::from_row::{TemplateRecord, convert, convert_all};
|
||||||
};
|
use crate::db::schema::templates;
|
||||||
use crate::model::Template;
|
use crate::model::Template;
|
||||||
|
|
||||||
pub fn insert_template(conn: &Connection, t: &Template) -> rusqlite::Result<()> {
|
pub fn insert_template(conn: &DbConnection, t: &Template) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"INSERT INTO templates (
|
diesel::insert_into(templates::table)
|
||||||
id, project_id, slug, title, kind, enabled, version,
|
.values(TemplateRecord::from(t))
|
||||||
file_path, status, content, created_at, updated_at
|
.execute(c)
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
.map(|_| ())
|
||||||
params![
|
})
|
||||||
t.id,
|
|
||||||
t.project_id,
|
|
||||||
t.slug,
|
|
||||||
t.title,
|
|
||||||
template_kind_to_str(&t.kind),
|
|
||||||
t.enabled as i64,
|
|
||||||
t.version,
|
|
||||||
t.file_path,
|
|
||||||
template_status_to_str(&t.status),
|
|
||||||
t.content,
|
|
||||||
t.created_at,
|
|
||||||
t.updated_at,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_template_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Template> {
|
pub fn get_template_by_id(conn: &DbConnection, id: &str) -> QueryResult<Template> {
|
||||||
conn.query_row(
|
conn.with(|c| {
|
||||||
&format!("SELECT {TEMPLATE_COLUMNS} FROM templates WHERE id = ?1"),
|
templates::table
|
||||||
params![id],
|
.filter(templates::id.eq(id))
|
||||||
template_from_row,
|
.select(TemplateRecord::as_select())
|
||||||
)
|
.first(c)
|
||||||
|
.and_then(convert)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_template_by_slug(
|
pub fn get_template_by_slug(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
slug: &str,
|
slug: &str,
|
||||||
) -> rusqlite::Result<Template> {
|
) -> QueryResult<Template> {
|
||||||
conn.query_row(
|
conn.with(|c| {
|
||||||
&format!("SELECT {TEMPLATE_COLUMNS} FROM templates WHERE project_id = ?1 AND slug = ?2"),
|
templates::table
|
||||||
params![project_id, slug],
|
.filter(templates::project_id.eq(project_id))
|
||||||
template_from_row,
|
.filter(templates::slug.eq(slug))
|
||||||
)
|
.select(TemplateRecord::as_select())
|
||||||
|
.first(c)
|
||||||
|
.and_then(convert)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_templates_by_project(
|
pub fn list_templates_by_project(
|
||||||
conn: &Connection,
|
conn: &DbConnection,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
) -> rusqlite::Result<Vec<Template>> {
|
) -> QueryResult<Vec<Template>> {
|
||||||
let mut stmt = conn.prepare(&format!(
|
conn.with(|c| {
|
||||||
"SELECT {TEMPLATE_COLUMNS} FROM templates WHERE project_id = ?1 ORDER BY title"
|
templates::table
|
||||||
))?;
|
.filter(templates::project_id.eq(project_id))
|
||||||
let rows = stmt.query_map(params![project_id], template_from_row)?;
|
.order(templates::title)
|
||||||
rows.collect()
|
.select(TemplateRecord::as_select())
|
||||||
|
.load(c)
|
||||||
|
.and_then(convert_all)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_template(conn: &Connection, t: &Template) -> rusqlite::Result<()> {
|
pub fn update_template(conn: &DbConnection, t: &Template) -> QueryResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"UPDATE templates SET
|
diesel::update(templates::table.filter(templates::id.eq(&t.id)))
|
||||||
slug = ?1, title = ?2, kind = ?3, enabled = ?4, version = ?5,
|
.set(TemplateRecord::from(t))
|
||||||
file_path = ?6, status = ?7, content = ?8, updated_at = ?9
|
.execute(c)
|
||||||
WHERE id = ?10",
|
.map(|_| ())
|
||||||
params![
|
})
|
||||||
t.slug,
|
|
||||||
t.title,
|
|
||||||
template_kind_to_str(&t.kind),
|
|
||||||
t.enabled as i64,
|
|
||||||
t.version,
|
|
||||||
t.file_path,
|
|
||||||
template_status_to_str(&t.status),
|
|
||||||
t.content,
|
|
||||||
t.updated_at,
|
|
||||||
t.id,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_template(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
pub fn delete_template(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||||
conn.execute("DELETE FROM templates WHERE id = ?1", params![id])?;
|
conn.with(|c| {
|
||||||
Ok(())
|
diesel::delete(templates::table.filter(templates::id.eq(id)))
|
||||||
|
.execute(c)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -95,7 +78,7 @@ mod tests {
|
|||||||
use crate::model::{TemplateKind, TemplateStatus};
|
use crate::model::{TemplateKind, TemplateStatus};
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||||
db
|
db
|
||||||
|
|||||||
91
crates/bds-core/src/db/schema.patch
Normal file
91
crates/bds-core/src/db/schema.patch
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
--- schema.rs
|
||||||
|
+++ schema.rs
|
||||||
|
@@ -45 +45 @@
|
||||||
|
- updated_at -> Integer,
|
||||||
|
+ updated_at -> BigInt,
|
||||||
|
@@ -57 +57 @@
|
||||||
|
- updated_at -> Integer,
|
||||||
|
+ updated_at -> BigInt,
|
||||||
|
@@ -67,2 +67,2 @@
|
||||||
|
- created_at -> Integer,
|
||||||
|
- updated_at -> Integer,
|
||||||
|
+ created_at -> BigInt,
|
||||||
|
+ updated_at -> BigInt,
|
||||||
|
@@ -80 +80 @@
|
||||||
|
- created_at -> Integer,
|
||||||
|
+ created_at -> BigInt,
|
||||||
|
@@ -93,2 +93,2 @@
|
||||||
|
- seen_at -> Nullable<Integer>,
|
||||||
|
- created_at -> Integer,
|
||||||
|
+ seen_at -> Nullable<BigInt>,
|
||||||
|
+ created_at -> BigInt,
|
||||||
|
@@ -104 +104 @@
|
||||||
|
- dismissed_at -> Integer,
|
||||||
|
+ dismissed_at -> BigInt,
|
||||||
|
@@ -110 +110 @@
|
||||||
|
- label -> Integer,
|
||||||
|
+ label -> BigInt,
|
||||||
|
@@ -124 +124 @@
|
||||||
|
- updated_at -> Integer,
|
||||||
|
+ updated_at -> BigInt,
|
||||||
|
@@ -136,2 +136,2 @@
|
||||||
|
- created_at -> Integer,
|
||||||
|
- updated_at -> Integer,
|
||||||
|
+ created_at -> BigInt,
|
||||||
|
+ updated_at -> BigInt,
|
||||||
|
@@ -148 +148 @@
|
||||||
|
- size -> Integer,
|
||||||
|
+ size -> BigInt,
|
||||||
|
@@ -157,2 +157,2 @@
|
||||||
|
- created_at -> Integer,
|
||||||
|
- updated_at -> Integer,
|
||||||
|
+ created_at -> BigInt,
|
||||||
|
+ updated_at -> BigInt,
|
||||||
|
@@ -174,2 +174,2 @@
|
||||||
|
- created_at -> Integer,
|
||||||
|
- updated_at -> Integer,
|
||||||
|
+ created_at -> BigInt,
|
||||||
|
+ updated_at -> BigInt,
|
||||||
|
@@ -185 +185 @@
|
||||||
|
- created_at -> Integer,
|
||||||
|
+ created_at -> BigInt,
|
||||||
|
@@ -196 +196 @@
|
||||||
|
- created_at -> Integer,
|
||||||
|
+ created_at -> BigInt,
|
||||||
|
@@ -210,3 +210,3 @@
|
||||||
|
- created_at -> Integer,
|
||||||
|
- updated_at -> Integer,
|
||||||
|
- published_at -> Nullable<Integer>,
|
||||||
|
+ created_at -> BigInt,
|
||||||
|
+ updated_at -> BigInt,
|
||||||
|
+ published_at -> Nullable<BigInt>,
|
||||||
|
@@ -228,3 +228,3 @@
|
||||||
|
- created_at -> Integer,
|
||||||
|
- updated_at -> Integer,
|
||||||
|
- published_at -> Nullable<Integer>,
|
||||||
|
+ created_at -> BigInt,
|
||||||
|
+ updated_at -> BigInt,
|
||||||
|
+ published_at -> Nullable<BigInt>,
|
||||||
|
@@ -254,2 +254,2 @@
|
||||||
|
- created_at -> Integer,
|
||||||
|
- updated_at -> Integer,
|
||||||
|
+ created_at -> BigInt,
|
||||||
|
+ updated_at -> BigInt,
|
||||||
|
@@ -272,2 +272,2 @@
|
||||||
|
- created_at -> Integer,
|
||||||
|
- updated_at -> Integer,
|
||||||
|
+ created_at -> BigInt,
|
||||||
|
+ updated_at -> BigInt,
|
||||||
|
@@ -281 +281 @@
|
||||||
|
- updated_at -> Integer,
|
||||||
|
+ updated_at -> BigInt,
|
||||||
|
@@ -292,2 +292,2 @@
|
||||||
|
- created_at -> Integer,
|
||||||
|
- updated_at -> Integer,
|
||||||
|
+ created_at -> BigInt,
|
||||||
|
+ updated_at -> BigInt,
|
||||||
|
@@ -309,2 +309,2 @@
|
||||||
|
- created_at -> Integer,
|
||||||
|
- updated_at -> Integer,
|
||||||
|
+ created_at -> BigInt,
|
||||||
|
+ updated_at -> BigInt,
|
||||||
355
crates/bds-core/src/db/schema.rs
Normal file
355
crates/bds-core/src/db/schema.rs
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
// @generated automatically by Diesel CLI.
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
ai_catalog_meta (key) {
|
||||||
|
key -> Text,
|
||||||
|
value -> Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
ai_model_modalities (rowid) {
|
||||||
|
rowid -> Integer,
|
||||||
|
provider -> Text,
|
||||||
|
model_id -> Text,
|
||||||
|
direction -> Text,
|
||||||
|
modality -> Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
ai_models (provider, model_id) {
|
||||||
|
provider -> Text,
|
||||||
|
model_id -> Text,
|
||||||
|
name -> Text,
|
||||||
|
family -> Nullable<Text>,
|
||||||
|
attachment -> Integer,
|
||||||
|
reasoning -> Integer,
|
||||||
|
tool_call -> Integer,
|
||||||
|
structured_output -> Integer,
|
||||||
|
temperature -> Integer,
|
||||||
|
knowledge -> Nullable<Text>,
|
||||||
|
release_date -> Nullable<Text>,
|
||||||
|
last_updated_date -> Nullable<Text>,
|
||||||
|
open_weights -> Integer,
|
||||||
|
input_price -> Nullable<Integer>,
|
||||||
|
output_price -> Nullable<Integer>,
|
||||||
|
cache_read_price -> Nullable<Integer>,
|
||||||
|
cache_write_price -> Nullable<Integer>,
|
||||||
|
context_window -> Integer,
|
||||||
|
max_input_tokens -> Integer,
|
||||||
|
max_output_tokens -> Integer,
|
||||||
|
interleaved -> Nullable<Text>,
|
||||||
|
status -> Nullable<Text>,
|
||||||
|
provider_package_ref -> Nullable<Text>,
|
||||||
|
updated_at -> BigInt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
ai_providers (id) {
|
||||||
|
id -> Text,
|
||||||
|
name -> Text,
|
||||||
|
env -> Nullable<Text>,
|
||||||
|
package_ref -> Nullable<Text>,
|
||||||
|
api -> Nullable<Text>,
|
||||||
|
doc -> Nullable<Text>,
|
||||||
|
updated_at -> BigInt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
chat_conversations (id) {
|
||||||
|
id -> Text,
|
||||||
|
title -> Text,
|
||||||
|
model -> Nullable<Text>,
|
||||||
|
copilot_session_id -> Nullable<Text>,
|
||||||
|
created_at -> BigInt,
|
||||||
|
updated_at -> BigInt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
chat_messages (id) {
|
||||||
|
id -> Integer,
|
||||||
|
conversation_id -> Text,
|
||||||
|
role -> Text,
|
||||||
|
content -> Nullable<Text>,
|
||||||
|
tool_call_id -> Nullable<Text>,
|
||||||
|
tool_calls -> Nullable<Text>,
|
||||||
|
created_at -> BigInt,
|
||||||
|
cache_read_tokens -> Nullable<Integer>,
|
||||||
|
cache_write_tokens -> Nullable<Integer>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
db_notifications (id) {
|
||||||
|
id -> Integer,
|
||||||
|
entity_type -> Text,
|
||||||
|
entity_id -> Text,
|
||||||
|
action -> Text,
|
||||||
|
from_cli -> Integer,
|
||||||
|
seen_at -> Nullable<BigInt>,
|
||||||
|
created_at -> BigInt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
dismissed_duplicate_pairs (id) {
|
||||||
|
id -> Text,
|
||||||
|
project_id -> Text,
|
||||||
|
post_id_a -> Text,
|
||||||
|
post_id_b -> Text,
|
||||||
|
dismissed_at -> BigInt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
embedding_keys (label) {
|
||||||
|
label -> BigInt,
|
||||||
|
post_id -> Text,
|
||||||
|
project_id -> Text,
|
||||||
|
content_hash -> Text,
|
||||||
|
vector -> Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
generated_file_hashes (rowid) {
|
||||||
|
rowid -> Integer,
|
||||||
|
project_id -> Text,
|
||||||
|
relative_path -> Text,
|
||||||
|
content_hash -> Text,
|
||||||
|
updated_at -> BigInt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
import_definitions (id) {
|
||||||
|
id -> Text,
|
||||||
|
project_id -> Text,
|
||||||
|
name -> Text,
|
||||||
|
wxr_file_path -> Nullable<Text>,
|
||||||
|
uploads_folder_path -> Nullable<Text>,
|
||||||
|
last_analysis_result -> Nullable<Text>,
|
||||||
|
created_at -> BigInt,
|
||||||
|
updated_at -> BigInt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
media (id) {
|
||||||
|
id -> Text,
|
||||||
|
project_id -> Text,
|
||||||
|
filename -> Text,
|
||||||
|
original_name -> Text,
|
||||||
|
mime_type -> Text,
|
||||||
|
size -> BigInt,
|
||||||
|
width -> Nullable<Integer>,
|
||||||
|
height -> Nullable<Integer>,
|
||||||
|
title -> Nullable<Text>,
|
||||||
|
alt -> Nullable<Text>,
|
||||||
|
caption -> Nullable<Text>,
|
||||||
|
author -> Nullable<Text>,
|
||||||
|
file_path -> Text,
|
||||||
|
sidecar_path -> Text,
|
||||||
|
created_at -> BigInt,
|
||||||
|
updated_at -> BigInt,
|
||||||
|
checksum -> Nullable<Text>,
|
||||||
|
tags -> Text,
|
||||||
|
language -> Nullable<Text>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
media_translations (id) {
|
||||||
|
id -> Text,
|
||||||
|
project_id -> Text,
|
||||||
|
translation_for -> Text,
|
||||||
|
language -> Text,
|
||||||
|
title -> Nullable<Text>,
|
||||||
|
alt -> Nullable<Text>,
|
||||||
|
caption -> Nullable<Text>,
|
||||||
|
created_at -> BigInt,
|
||||||
|
updated_at -> BigInt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
post_links (id) {
|
||||||
|
id -> Text,
|
||||||
|
source_post_id -> Text,
|
||||||
|
target_post_id -> Text,
|
||||||
|
link_text -> Nullable<Text>,
|
||||||
|
created_at -> BigInt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
post_media (id) {
|
||||||
|
id -> Text,
|
||||||
|
project_id -> Text,
|
||||||
|
post_id -> Text,
|
||||||
|
media_id -> Text,
|
||||||
|
sort_order -> Integer,
|
||||||
|
created_at -> BigInt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
post_translations (id) {
|
||||||
|
id -> Text,
|
||||||
|
project_id -> Text,
|
||||||
|
translation_for -> Text,
|
||||||
|
language -> Text,
|
||||||
|
title -> Text,
|
||||||
|
excerpt -> Nullable<Text>,
|
||||||
|
content -> Nullable<Text>,
|
||||||
|
status -> Text,
|
||||||
|
created_at -> BigInt,
|
||||||
|
updated_at -> BigInt,
|
||||||
|
published_at -> Nullable<BigInt>,
|
||||||
|
file_path -> Text,
|
||||||
|
checksum -> Nullable<Text>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
posts (id) {
|
||||||
|
id -> Text,
|
||||||
|
project_id -> Text,
|
||||||
|
title -> Text,
|
||||||
|
slug -> Text,
|
||||||
|
excerpt -> Nullable<Text>,
|
||||||
|
content -> Nullable<Text>,
|
||||||
|
status -> Text,
|
||||||
|
author -> Nullable<Text>,
|
||||||
|
created_at -> BigInt,
|
||||||
|
updated_at -> BigInt,
|
||||||
|
published_at -> Nullable<BigInt>,
|
||||||
|
file_path -> Text,
|
||||||
|
checksum -> Nullable<Text>,
|
||||||
|
tags -> Text,
|
||||||
|
categories -> Text,
|
||||||
|
template_slug -> Nullable<Text>,
|
||||||
|
language -> Nullable<Text>,
|
||||||
|
do_not_translate -> Integer,
|
||||||
|
published_title -> Nullable<Text>,
|
||||||
|
published_content -> Nullable<Text>,
|
||||||
|
published_tags -> Nullable<Text>,
|
||||||
|
published_categories -> Nullable<Text>,
|
||||||
|
published_excerpt -> Nullable<Text>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
projects (id) {
|
||||||
|
id -> Text,
|
||||||
|
name -> Text,
|
||||||
|
slug -> Text,
|
||||||
|
description -> Nullable<Text>,
|
||||||
|
data_path -> Nullable<Text>,
|
||||||
|
is_active -> Integer,
|
||||||
|
created_at -> BigInt,
|
||||||
|
updated_at -> BigInt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
scripts (id) {
|
||||||
|
id -> Text,
|
||||||
|
project_id -> Text,
|
||||||
|
slug -> Text,
|
||||||
|
title -> Text,
|
||||||
|
kind -> Text,
|
||||||
|
entrypoint -> Text,
|
||||||
|
enabled -> Integer,
|
||||||
|
version -> Integer,
|
||||||
|
file_path -> Text,
|
||||||
|
status -> Text,
|
||||||
|
content -> Nullable<Text>,
|
||||||
|
created_at -> BigInt,
|
||||||
|
updated_at -> BigInt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
settings (key) {
|
||||||
|
key -> Text,
|
||||||
|
value -> Text,
|
||||||
|
updated_at -> BigInt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
tags (id) {
|
||||||
|
id -> Text,
|
||||||
|
project_id -> Text,
|
||||||
|
name -> Text,
|
||||||
|
color -> Nullable<Text>,
|
||||||
|
post_template_slug -> Nullable<Text>,
|
||||||
|
created_at -> BigInt,
|
||||||
|
updated_at -> BigInt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
templates (id) {
|
||||||
|
id -> Text,
|
||||||
|
project_id -> Text,
|
||||||
|
slug -> Text,
|
||||||
|
title -> Text,
|
||||||
|
kind -> Text,
|
||||||
|
enabled -> Integer,
|
||||||
|
version -> Integer,
|
||||||
|
file_path -> Text,
|
||||||
|
status -> Text,
|
||||||
|
content -> Nullable<Text>,
|
||||||
|
created_at -> BigInt,
|
||||||
|
updated_at -> BigInt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::joinable!(ai_models -> ai_providers (provider));
|
||||||
|
diesel::joinable!(chat_messages -> chat_conversations (conversation_id));
|
||||||
|
diesel::joinable!(dismissed_duplicate_pairs -> projects (project_id));
|
||||||
|
diesel::joinable!(generated_file_hashes -> projects (project_id));
|
||||||
|
diesel::joinable!(import_definitions -> projects (project_id));
|
||||||
|
diesel::joinable!(media -> projects (project_id));
|
||||||
|
diesel::joinable!(media_translations -> media (translation_for));
|
||||||
|
diesel::joinable!(media_translations -> projects (project_id));
|
||||||
|
diesel::joinable!(post_media -> media (media_id));
|
||||||
|
diesel::joinable!(post_media -> posts (post_id));
|
||||||
|
diesel::joinable!(post_media -> projects (project_id));
|
||||||
|
diesel::joinable!(post_translations -> posts (translation_for));
|
||||||
|
diesel::joinable!(post_translations -> projects (project_id));
|
||||||
|
diesel::joinable!(posts -> projects (project_id));
|
||||||
|
diesel::joinable!(scripts -> projects (project_id));
|
||||||
|
diesel::joinable!(tags -> projects (project_id));
|
||||||
|
diesel::joinable!(templates -> projects (project_id));
|
||||||
|
|
||||||
|
diesel::allow_tables_to_appear_in_same_query!(
|
||||||
|
ai_catalog_meta,
|
||||||
|
ai_model_modalities,
|
||||||
|
ai_models,
|
||||||
|
ai_providers,
|
||||||
|
chat_conversations,
|
||||||
|
chat_messages,
|
||||||
|
db_notifications,
|
||||||
|
dismissed_duplicate_pairs,
|
||||||
|
embedding_keys,
|
||||||
|
generated_file_hashes,
|
||||||
|
import_definitions,
|
||||||
|
media,
|
||||||
|
media_translations,
|
||||||
|
post_links,
|
||||||
|
post_media,
|
||||||
|
post_translations,
|
||||||
|
posts,
|
||||||
|
projects,
|
||||||
|
scripts,
|
||||||
|
settings,
|
||||||
|
tags,
|
||||||
|
templates,
|
||||||
|
);
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::db::DbConnection as Connection;
|
||||||
use keyring::Entry;
|
use keyring::Entry;
|
||||||
use reqwest::blocking::Client;
|
use reqwest::blocking::Client;
|
||||||
use rusqlite::Connection;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
@@ -681,7 +681,7 @@ fn get_optional_setting(conn: &Connection, key: &str) -> EngineResult<Option<Str
|
|||||||
match setting::get_setting_by_key(conn, key) {
|
match setting::get_setting_by_key(conn, key) {
|
||||||
Ok(setting) if setting.value.trim().is_empty() => Ok(None),
|
Ok(setting) if setting.value.trim().is_empty() => Ok(None),
|
||||||
Ok(setting) => Ok(Some(setting.value)),
|
Ok(setting) => Ok(Some(setting.value)),
|
||||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
Err(diesel::result::Error::NotFound) => Ok(None),
|
||||||
Err(error) => Err(EngineError::Db(error)),
|
Err(error) => Err(EngineError::Db(error)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -724,7 +724,7 @@ mod tests {
|
|||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
|
|
||||||
fn setup() -> Database {
|
fn setup() -> Database {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
db
|
db
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use crate::db::DbConnection as Connection;
|
||||||
|
|
||||||
use crate::db::queries::post as post_q;
|
use crate::db::queries::post as post_q;
|
||||||
use crate::engine::EngineResult;
|
use crate::engine::EngineResult;
|
||||||
@@ -65,7 +65,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn calendar_empty_project() {
|
fn calendar_empty_project() {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
let _ = db.migrate();
|
let _ = db.migrate();
|
||||||
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
/// Shared context passed to engine operations.
|
/// Shared context passed to engine operations.
|
||||||
pub struct EngineContext<'a> {
|
pub struct EngineContext<'a> {
|
||||||
pub conn: &'a rusqlite::Connection,
|
pub conn: &'a crate::db::DbConnection,
|
||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
pub data_dir: PathBuf,
|
pub data_dir: PathBuf,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ use std::fmt;
|
|||||||
/// Errors produced by engine operations.
|
/// Errors produced by engine operations.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum EngineError {
|
pub enum EngineError {
|
||||||
Db(rusqlite::Error),
|
Db(diesel::result::Error),
|
||||||
|
DbConnection(diesel::ConnectionError),
|
||||||
Io(std::io::Error),
|
Io(std::io::Error),
|
||||||
Parse(String),
|
Parse(String),
|
||||||
NotFound(String),
|
NotFound(String),
|
||||||
@@ -15,6 +16,7 @@ impl fmt::Display for EngineError {
|
|||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::Db(e) => write!(f, "database error: {e}"),
|
Self::Db(e) => write!(f, "database error: {e}"),
|
||||||
|
Self::DbConnection(e) => write!(f, "database connection error: {e}"),
|
||||||
Self::Io(e) => write!(f, "I/O error: {e}"),
|
Self::Io(e) => write!(f, "I/O error: {e}"),
|
||||||
Self::Parse(msg) => write!(f, "parse error: {msg}"),
|
Self::Parse(msg) => write!(f, "parse error: {msg}"),
|
||||||
Self::NotFound(msg) => write!(f, "not found: {msg}"),
|
Self::NotFound(msg) => write!(f, "not found: {msg}"),
|
||||||
@@ -28,18 +30,34 @@ impl std::error::Error for EngineError {
|
|||||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
match self {
|
match self {
|
||||||
Self::Db(e) => Some(e),
|
Self::Db(e) => Some(e),
|
||||||
|
Self::DbConnection(e) => Some(e),
|
||||||
Self::Io(e) => Some(e),
|
Self::Io(e) => Some(e),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<rusqlite::Error> for EngineError {
|
impl From<diesel::result::Error> for EngineError {
|
||||||
fn from(e: rusqlite::Error) -> Self {
|
fn from(e: diesel::result::Error) -> Self {
|
||||||
Self::Db(e)
|
Self::Db(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<diesel::ConnectionError> for EngineError {
|
||||||
|
fn from(e: diesel::ConnectionError) -> Self {
|
||||||
|
Self::DbConnection(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<crate::db::DatabaseError> for EngineError {
|
||||||
|
fn from(e: crate::db::DatabaseError) -> Self {
|
||||||
|
match e {
|
||||||
|
crate::db::DatabaseError::Connection(e) => Self::DbConnection(e),
|
||||||
|
crate::db::DatabaseError::Query(e) => Self::Db(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<std::io::Error> for EngineError {
|
impl From<std::io::Error> for EngineError {
|
||||||
fn from(e: std::io::Error) -> Self {
|
fn from(e: std::io::Error) -> Self {
|
||||||
Self::Io(e)
|
Self::Io(e)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::db::DbConnection as Connection;
|
||||||
use chrono::{DateTime, TimeZone, Utc};
|
use chrono::{DateTime, TimeZone, Utc};
|
||||||
use pagefind::api::PagefindIndex;
|
use pagefind::api::PagefindIndex;
|
||||||
use pagefind::options::PagefindServiceConfig;
|
use pagefind::options::PagefindServiceConfig;
|
||||||
use rusqlite::Connection;
|
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
use crate::db::queries;
|
use crate::db::queries;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use crate::db::DbConnection as Connection;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
@@ -670,7 +670,7 @@ mod tests {
|
|||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
ensure_fts_tables(db.conn()).unwrap();
|
ensure_fts_tables(db.conn()).unwrap();
|
||||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::collections::HashSet;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use crate::db::DbConnection as Connection;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
use crate::db::from_row::{script_kind_to_str, template_kind_to_str};
|
use crate::db::from_row::{script_kind_to_str, template_kind_to_str};
|
||||||
@@ -629,7 +629,7 @@ mod tests {
|
|||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
ensure_fts_tables(db.conn()).unwrap();
|
ensure_fts_tables(db.conn()).unwrap();
|
||||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use rusqlite::{Connection, params};
|
use crate::db::DbConnection as Connection;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ pub fn update_post(
|
|||||||
|
|
||||||
/// Publish a post: write file, clear content, set published_at.
|
/// Publish a post: write file, clear content, set published_at.
|
||||||
pub fn publish_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult<Post> {
|
pub fn publish_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult<Post> {
|
||||||
let mut post = qp::get_post_by_id(conn, post_id)?;
|
let post = qp::get_post_by_id(conn, post_id)?;
|
||||||
|
|
||||||
// Require Draft or Archived status
|
// Require Draft or Archived status
|
||||||
match post.status {
|
match post.status {
|
||||||
@@ -201,10 +201,27 @@ pub fn publish_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute file_path from created_at + slug
|
conn.begin_savepoint()?;
|
||||||
// Use a savepoint for atomicity
|
match publish_post_in_savepoint(conn, data_dir, post) {
|
||||||
// Note: savepoint auto-rolls-back if not released (on error propagation)
|
Ok(post) => {
|
||||||
conn.execute_batch("SAVEPOINT publish_post")?;
|
conn.release_savepoint()?;
|
||||||
|
Ok(post)
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let _ = conn.rollback_savepoint();
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_post_in_savepoint(
|
||||||
|
conn: &Connection,
|
||||||
|
data_dir: &Path,
|
||||||
|
mut post: Post,
|
||||||
|
) -> EngineResult<Post> {
|
||||||
|
let post_id = post.id.clone();
|
||||||
|
|
||||||
|
// Compute file_path from created_at + slug.
|
||||||
let rel_path = post_file_path(post.created_at, &post.slug);
|
let rel_path = post_file_path(post.created_at, &post.slug);
|
||||||
let abs_path = data_dir.join(&rel_path);
|
let abs_path = data_dir.join(&rel_path);
|
||||||
|
|
||||||
@@ -245,7 +262,7 @@ pub fn publish_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine
|
|||||||
|
|
||||||
qp::set_published_snapshot(
|
qp::set_published_snapshot(
|
||||||
conn,
|
conn,
|
||||||
post_id,
|
&post_id,
|
||||||
&post.title,
|
&post.title,
|
||||||
&body,
|
&body,
|
||||||
&tags_json,
|
&tags_json,
|
||||||
@@ -256,27 +273,24 @@ pub fn publish_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Set file_path and checksum in DB
|
// Set file_path and checksum in DB
|
||||||
qp::set_post_file_path(conn, post_id, &post.file_path, now)?;
|
qp::set_post_file_path(conn, &post_id, &post.file_path, now)?;
|
||||||
conn.execute(
|
qp::set_post_checksum(conn, &post_id, post.checksum.as_deref())?;
|
||||||
"UPDATE posts SET checksum = ?1 WHERE id = ?2",
|
|
||||||
params![post.checksum, post_id],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// Clear content in DB
|
// Clear content in DB
|
||||||
qp::clear_post_content(conn, post_id, now)?;
|
qp::clear_post_content(conn, &post_id, now)?;
|
||||||
post.content = None;
|
post.content = None;
|
||||||
|
|
||||||
// Set status = Published
|
// Set status = Published
|
||||||
qp::update_post_status(conn, post_id, &PostStatus::Published, now)?;
|
qp::update_post_status(conn, &post_id, &PostStatus::Published, now)?;
|
||||||
|
|
||||||
// Publish all translations
|
// Publish all translations
|
||||||
let translations = qt::list_post_translations_by_post(conn, post_id)?;
|
let translations = qt::list_post_translations_by_post(conn, &post_id)?;
|
||||||
for mut t in translations {
|
for mut t in translations {
|
||||||
publish_translation(conn, data_dir, &mut t, &post)?;
|
publish_translation(conn, data_dir, &mut t, &post)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse inter-post links and update link graph
|
// Parse inter-post links and update link graph
|
||||||
ql::delete_links_by_source(conn, post_id)?;
|
ql::delete_links_by_source(conn, &post_id)?;
|
||||||
let link_body = if let Some(ref pc) = post.published_content {
|
let link_body = if let Some(ref pc) = post.published_content {
|
||||||
pc.as_str()
|
pc.as_str()
|
||||||
} else {
|
} else {
|
||||||
@@ -287,7 +301,7 @@ pub fn publish_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine
|
|||||||
if let Ok(target) = qp::get_post_by_project_and_slug(conn, &post.project_id, target_slug) {
|
if let Ok(target) = qp::get_post_by_project_and_slug(conn, &post.project_id, target_slug) {
|
||||||
let link = PostLink {
|
let link = PostLink {
|
||||||
id: Uuid::new_v4().to_string(),
|
id: Uuid::new_v4().to_string(),
|
||||||
source_post_id: post_id.to_string(),
|
source_post_id: post_id.clone(),
|
||||||
target_post_id: target.id.clone(),
|
target_post_id: target.id.clone(),
|
||||||
link_text: Some(link_text.clone()),
|
link_text: Some(link_text.clone()),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
@@ -299,8 +313,6 @@ pub fn publish_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine
|
|||||||
// Re-index FTS
|
// Re-index FTS
|
||||||
fts_index_post(conn, &post)?;
|
fts_index_post(conn, &post)?;
|
||||||
|
|
||||||
conn.execute_batch("RELEASE publish_post")?;
|
|
||||||
|
|
||||||
Ok(post)
|
Ok(post)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,16 +527,10 @@ pub fn delete_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineR
|
|||||||
|
|
||||||
// Delete post links (source and target)
|
// Delete post links (source and target)
|
||||||
ql::delete_links_by_source(conn, post_id)?;
|
ql::delete_links_by_source(conn, post_id)?;
|
||||||
conn.execute(
|
ql::delete_links_by_target(conn, post_id)?;
|
||||||
"DELETE FROM post_links WHERE target_post_id = ?1",
|
|
||||||
params![post_id],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// Delete post-media associations
|
// Delete post-media associations
|
||||||
conn.execute(
|
crate::db::queries::post_media::delete_post_media_by_post(conn, post_id)?;
|
||||||
"DELETE FROM post_media WHERE post_id = ?1",
|
|
||||||
params![post_id],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// Remove from FTS
|
// Remove from FTS
|
||||||
fts::remove_post_from_index(conn, post_id)?;
|
fts::remove_post_from_index(conn, post_id)?;
|
||||||
@@ -882,13 +888,6 @@ fn publish_translation(
|
|||||||
t.content = None;
|
t.content = None;
|
||||||
|
|
||||||
qt::update_post_translation(conn, t)?;
|
qt::update_post_translation(conn, t)?;
|
||||||
// Clear content after update (the update already set content=None via the struct)
|
|
||||||
// but we also do an explicit clear to be safe
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE post_translations SET content = NULL WHERE id = ?1",
|
|
||||||
params![t.id],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1149,7 +1148,7 @@ mod tests {
|
|||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
ensure_fts_tables(db.conn()).unwrap();
|
ensure_fts_tables(db.conn()).unwrap();
|
||||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use crate::db::DbConnection as Connection;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::db::queries::media as qm;
|
use crate::db::queries::media as qm;
|
||||||
@@ -109,22 +109,17 @@ mod tests {
|
|||||||
|
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
use crate::db::queries::media::{insert_media, make_test_media};
|
use crate::db::queries::media::{insert_media, make_test_media};
|
||||||
|
use crate::db::queries::post::{insert_post, make_test_post};
|
||||||
use crate::db::queries::post_media::list_post_media_by_post;
|
use crate::db::queries::post_media::list_post_media_by_post;
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
use crate::util::sidecar::read_sidecar;
|
use crate::util::sidecar::read_sidecar;
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
// Create a post
|
insert_post(db.conn(), &make_test_post("post1", "p1", "test")).unwrap();
|
||||||
db.conn()
|
|
||||||
.execute(
|
|
||||||
"INSERT INTO posts (id, project_id, title, slug, status, file_path, created_at, updated_at) VALUES ('post1', 'p1', 'Test', 'test', 'draft', '', 1000, 1000)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
(db, dir)
|
(db, dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,13 +192,10 @@ mod tests {
|
|||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
insert_test_media(&db, dir.path(), "m1");
|
insert_test_media(&db, dir.path(), "m1");
|
||||||
|
|
||||||
// Create a second post
|
let mut post = make_test_post("post2", "p1", "test2");
|
||||||
db.conn()
|
post.created_at = 2000;
|
||||||
.execute(
|
post.updated_at = 2000;
|
||||||
"INSERT INTO posts (id, project_id, title, slug, status, file_path, created_at, updated_at) VALUES ('post2', 'p1', 'Test2', 'test2', 'draft', '', 2000, 2000)",
|
insert_post(db.conn(), &post).unwrap();
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
link_media_to_post(db.conn(), dir.path(), "p1", "post1", "m1", 0).unwrap();
|
link_media_to_post(db.conn(), dir.path(), "p1", "post1", "m1", 0).unwrap();
|
||||||
link_media_to_post(db.conn(), dir.path(), "p1", "post2", "m1", 0).unwrap();
|
link_media_to_post(db.conn(), dir.path(), "p1", "post2", "m1", 0).unwrap();
|
||||||
|
|||||||
@@ -453,7 +453,7 @@ mod tests {
|
|||||||
meta::write_project_json(dir.path(), &make_metadata()).unwrap();
|
meta::write_project_json(dir.path(), &make_metadata()).unwrap();
|
||||||
|
|
||||||
let db_path = dir.path().join("bds.db");
|
let db_path = dir.path().join("bds.db");
|
||||||
let mut db = Database::open(&db_path).unwrap();
|
let db = Database::open(&db_path).unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
queries::project::insert_project(
|
queries::project::insert_project(
|
||||||
db.conn(),
|
db.conn(),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::collections::HashMap;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use crate::db::DbConnection as Connection;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::db::queries::project as q;
|
use crate::db::queries::project as q;
|
||||||
@@ -64,7 +64,7 @@ pub fn ensure_default_project(
|
|||||||
) -> EngineResult<Project> {
|
) -> EngineResult<Project> {
|
||||||
match q::get_project_by_id(conn, DEFAULT_PROJECT_ID) {
|
match q::get_project_by_id(conn, DEFAULT_PROJECT_ID) {
|
||||||
Ok(p) => Ok(p),
|
Ok(p) => Ok(p),
|
||||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
Err(diesel::result::Error::NotFound) => {
|
||||||
let now = now_unix_ms();
|
let now = now_unix_ms();
|
||||||
let project = Project {
|
let project = Project {
|
||||||
id: DEFAULT_PROJECT_ID.to_string(),
|
id: DEFAULT_PROJECT_ID.to_string(),
|
||||||
@@ -94,7 +94,7 @@ pub fn ensure_default_project(
|
|||||||
pub fn get_active_project(conn: &Connection) -> EngineResult<Option<Project>> {
|
pub fn get_active_project(conn: &Connection) -> EngineResult<Option<Project>> {
|
||||||
match q::get_active_project(conn) {
|
match q::get_active_project(conn) {
|
||||||
Ok(p) => Ok(Some(p)),
|
Ok(p) => Ok(Some(p)),
|
||||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
Err(diesel::result::Error::NotFound) => Ok(None),
|
||||||
Err(e) => Err(EngineError::Db(e)),
|
Err(e) => Err(EngineError::Db(e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,7 +280,7 @@ mod tests {
|
|||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
(db, dir)
|
(db, dir)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use crate::db::DbConnection as Connection;
|
||||||
|
|
||||||
use crate::db::fts;
|
use crate::db::fts;
|
||||||
use crate::engine::EngineResult;
|
use crate::engine::EngineResult;
|
||||||
@@ -157,7 +157,7 @@ mod tests {
|
|||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
fts::ensure_fts_tables(db.conn()).unwrap();
|
fts::ensure_fts_tables(db.conn()).unwrap();
|
||||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use crate::db::DbConnection as Connection;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::db::queries::script as qs;
|
use crate::db::queries::script as qs;
|
||||||
@@ -305,7 +305,7 @@ mod tests {
|
|||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use crate::db::DbConnection as Connection;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
use crate::db::queries::script as qs;
|
use crate::db::queries::script as qs;
|
||||||
@@ -145,7 +145,7 @@ mod tests {
|
|||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Full-text search reindexing engine functions.
|
//! Full-text search reindexing engine functions.
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use crate::db::DbConnection as Connection;
|
||||||
|
|
||||||
use crate::db::fts;
|
use crate::db::fts;
|
||||||
use crate::db::queries::{media as media_q, media_translation, post as post_q, post_translation};
|
use crate::db::queries::{media as media_q, media_translation, post as post_q, post_translation};
|
||||||
@@ -32,8 +32,7 @@ pub fn reindex_all_with_progress(
|
|||||||
on_item: Option<ItemProgressFn>,
|
on_item: Option<ItemProgressFn>,
|
||||||
) -> EngineResult<ReindexReport> {
|
) -> EngineResult<ReindexReport> {
|
||||||
// Wipe existing FTS content
|
// Wipe existing FTS content
|
||||||
conn.execute("DELETE FROM posts_fts", [])?;
|
fts::clear_indexes(conn)?;
|
||||||
conn.execute("DELETE FROM media_fts", [])?;
|
|
||||||
|
|
||||||
// Reindex all posts
|
// Reindex all posts
|
||||||
let posts = post_q::list_posts_by_project(conn, project_id)?;
|
let posts = post_q::list_posts_by_project(conn, project_id)?;
|
||||||
@@ -124,7 +123,7 @@ mod tests {
|
|||||||
use crate::engine;
|
use crate::engine;
|
||||||
|
|
||||||
fn setup() -> (Database, String) {
|
fn setup() -> (Database, String) {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
let _ = db.migrate();
|
let _ = db.migrate();
|
||||||
ensure_fts_tables(db.conn()).unwrap();
|
ensure_fts_tables(db.conn()).unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use crate::db::DbConnection as Connection;
|
||||||
|
|
||||||
use crate::engine::{EngineError, EngineResult};
|
use crate::engine::{EngineError, EngineResult};
|
||||||
use crate::render::{GeneratedWriteOutcome, write_generated_bytes};
|
use crate::render::{GeneratedWriteOutcome, write_generated_bytes};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use crate::db::DbConnection as Connection;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::db::queries::post as post_q;
|
use crate::db::queries::post as post_q;
|
||||||
@@ -360,7 +360,7 @@ mod tests {
|
|||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
|
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use crate::db::DbConnection as Connection;
|
||||||
|
use crate::db::schema::{posts, tags};
|
||||||
|
use diesel::prelude::*;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::db::queries::template as qt;
|
use crate::db::queries::template as qt;
|
||||||
@@ -383,36 +385,40 @@ fn validate_liquid_blocks(content: &str) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn count_posts_using_template(conn: &Connection, slug: &str) -> EngineResult<usize> {
|
fn count_posts_using_template(conn: &Connection, slug: &str) -> EngineResult<usize> {
|
||||||
let count: i64 = conn.query_row(
|
let count: i64 = conn.with(|c| {
|
||||||
"SELECT COUNT(*) FROM posts WHERE template_slug = ?1",
|
posts::table
|
||||||
rusqlite::params![slug],
|
.filter(posts::template_slug.eq(slug))
|
||||||
|row| row.get(0),
|
.count()
|
||||||
)?;
|
.get_result(c)
|
||||||
|
})?;
|
||||||
Ok(count as usize)
|
Ok(count as usize)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn count_tags_using_template(conn: &Connection, slug: &str) -> EngineResult<usize> {
|
fn count_tags_using_template(conn: &Connection, slug: &str) -> EngineResult<usize> {
|
||||||
let count: i64 = conn.query_row(
|
let count: i64 = conn.with(|c| {
|
||||||
"SELECT COUNT(*) FROM tags WHERE post_template_slug = ?1",
|
tags::table
|
||||||
rusqlite::params![slug],
|
.filter(tags::post_template_slug.eq(slug))
|
||||||
|row| row.get(0),
|
.count()
|
||||||
)?;
|
.get_result(c)
|
||||||
|
})?;
|
||||||
Ok(count as usize)
|
Ok(count as usize)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn null_template_slug_on_posts(conn: &Connection, slug: &str) -> EngineResult<()> {
|
fn null_template_slug_on_posts(conn: &Connection, slug: &str) -> EngineResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"UPDATE posts SET template_slug = NULL WHERE template_slug = ?1",
|
diesel::update(posts::table.filter(posts::template_slug.eq(slug)))
|
||||||
rusqlite::params![slug],
|
.set(posts::template_slug.eq(None::<String>))
|
||||||
)?;
|
.execute(c)
|
||||||
|
})?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn null_template_slug_on_tags(conn: &Connection, slug: &str) -> EngineResult<()> {
|
fn null_template_slug_on_tags(conn: &Connection, slug: &str) -> EngineResult<()> {
|
||||||
conn.execute(
|
conn.with(|c| {
|
||||||
"UPDATE tags SET post_template_slug = NULL WHERE post_template_slug = ?1",
|
diesel::update(tags::table.filter(tags::post_template_slug.eq(slug)))
|
||||||
rusqlite::params![slug],
|
.set(tags::post_template_slug.eq(None::<String>))
|
||||||
)?;
|
.execute(c)
|
||||||
|
})?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +430,7 @@ mod tests {
|
|||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use crate::db::DbConnection as Connection;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
use crate::db::queries::template as qt;
|
use crate::db::queries::template as qt;
|
||||||
@@ -144,7 +144,7 @@ mod tests {
|
|||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use crate::db::DbConnection as Connection;
|
||||||
|
|
||||||
use crate::db::queries::media as mq;
|
use crate::db::queries::media as mq;
|
||||||
use crate::db::queries::post_media as pmq;
|
use crate::db::queries::post_media as pmq;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use crate::db::DbConnection as Connection;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
use crate::db::queries;
|
use crate::db::queries;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use crate::db::DbConnection as Connection;
|
||||||
|
|
||||||
use crate::db::queries::{post as post_q, post_translation};
|
use crate::db::queries::{post as post_q, post_translation};
|
||||||
use crate::engine::EngineResult;
|
use crate::engine::EngineResult;
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use std::collections::BTreeMap;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::db::DbConnection as Connection;
|
||||||
use chrono::{Datelike, TimeZone, Utc};
|
use chrono::{Datelike, TimeZone, Utc};
|
||||||
use rusqlite::Connection;
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::db::queries::generated_file_hash as qhash;
|
use crate::db::queries::generated_file_hash as qhash;
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ use std::error::Error;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::db::DbConnection as Connection;
|
||||||
use chrono::{Datelike, TimeZone, Utc};
|
use chrono::{Datelike, TimeZone, Utc};
|
||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
use rusqlite::Connection;
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::db::queries;
|
use crate::db::queries;
|
||||||
|
|||||||
@@ -1,335 +1,130 @@
|
|||||||
//! Integration tests that open the real fixture DB extracted from the TypeScript bDS app
|
//! Verify that the Rust Diesel models read the compatibility fixture produced by bDS.
|
||||||
//! and verify every table can be read correctly into Rust model structs.
|
|
||||||
//!
|
|
||||||
//! This validates the compatibility contract: the Rust app MUST read databases
|
|
||||||
//! created by the TypeScript app without modification.
|
|
||||||
|
|
||||||
use rusqlite::{Connection, OpenFlags};
|
use std::collections::HashSet;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
fn fixture_db() -> Connection {
|
use bds_core::db::Database;
|
||||||
|
use bds_core::db::queries::{
|
||||||
|
media, post, post_link, post_media, post_translation, project, script, setting, tag, template,
|
||||||
|
};
|
||||||
|
use bds_core::db::schema::{ai_catalog_meta, ai_models, ai_providers};
|
||||||
|
use bds_core::model::{ScriptKind, ScriptStatus, TemplateKind, TemplateStatus};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
const PROJECT_ID: &str = "1979237c-034d-41f6-99a0-f35eb57b3f6c";
|
||||||
|
const ESMERALDA_ID: &str = "40a83ab1-423d-4310-aac4-642d84675007";
|
||||||
|
const GHOSTTY_ID: &str = "6745981d-da41-4cfd-80ec-95ad339acf6f";
|
||||||
|
const CMUX_ID: &str = "2665bfaa-8251-468d-a710-a4cf34dd81e2";
|
||||||
|
const SPIDER_ID: &str = "eb0cf9d7-6fbd-4b74-9be3-759d6e16f240";
|
||||||
|
|
||||||
|
fn fixture_db() -> Database {
|
||||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
.join("../../fixtures/compatibility-projects/rfc1437-sample/bds.db");
|
.join("../../fixtures/compatibility-projects/rfc1437-sample/bds.db");
|
||||||
assert!(path.exists(), "fixture DB not found at {}", path.display());
|
assert!(path.exists(), "fixture DB not found at {}", path.display());
|
||||||
Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap()
|
Database::open(&path).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROJECT_ID: &str = "1979237c-034d-41f6-99a0-f35eb57b3f6c";
|
|
||||||
|
|
||||||
// ── Project ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_project() {
|
fn read_project() {
|
||||||
let conn = fixture_db();
|
let db = fixture_db();
|
||||||
let (id, name, slug, data_path, is_active): (String, String, String, String, bool) = conn
|
let value = project::get_project_by_id(db.conn(), PROJECT_ID).unwrap();
|
||||||
.query_row(
|
assert_eq!(value.name, "rfc1437");
|
||||||
"SELECT id, name, slug, data_path, is_active FROM projects WHERE id = ?1",
|
assert_eq!(value.slug, "rfc1437");
|
||||||
[PROJECT_ID],
|
assert!(value.data_path.unwrap().contains("rfc1437.de"));
|
||||||
|row| {
|
assert!(value.is_active);
|
||||||
Ok((
|
|
||||||
row.get(0)?,
|
|
||||||
row.get(1)?,
|
|
||||||
row.get(2)?,
|
|
||||||
row.get(3)?,
|
|
||||||
row.get(4)?,
|
|
||||||
))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(id, PROJECT_ID);
|
|
||||||
assert_eq!(name, "rfc1437");
|
|
||||||
assert_eq!(slug, "rfc1437");
|
|
||||||
assert!(data_path.contains("rfc1437.de"));
|
|
||||||
assert!(is_active);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Posts ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_published_post_has_null_content() {
|
fn posts_are_compatible() {
|
||||||
let conn = fixture_db();
|
let db = fixture_db();
|
||||||
let (title, slug, status, content): (String, String, String, Option<String>) = conn
|
let posts = post::list_posts_by_project(db.conn(), PROJECT_ID).unwrap();
|
||||||
.query_row(
|
assert_eq!(posts.len(), 4);
|
||||||
"SELECT title, slug, status, content FROM posts WHERE slug = 'esmeralda'",
|
let published = posts.iter().find(|post| post.slug == "esmeralda").unwrap();
|
||||||
[],
|
assert_eq!(published.title, "Esmeralda");
|
||||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
assert_eq!(published.status, bds_core::model::PostStatus::Published);
|
||||||
)
|
assert!(published.content.is_none());
|
||||||
|
assert!(!published.tags.is_empty());
|
||||||
|
assert!(published.created_at > 946_684_800);
|
||||||
|
assert!(published.updated_at > 946_684_800);
|
||||||
|
let draft = posts
|
||||||
|
.iter()
|
||||||
|
.find(|post| post.slug == "draft-fixture-post")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
assert!(draft.content.as_deref().unwrap().contains("**body**"));
|
||||||
assert_eq!(title, "Esmeralda");
|
|
||||||
assert_eq!(slug, "esmeralda");
|
|
||||||
assert_eq!(status, "published");
|
|
||||||
assert!(
|
assert!(
|
||||||
content.is_none(),
|
posts
|
||||||
"published posts must have NULL content in DB"
|
.iter()
|
||||||
|
.filter(|post| post.status == bds_core::model::PostStatus::Published)
|
||||||
|
.all(|post| !post.file_path.is_empty()
|
||||||
|
&& post.file_path.ends_with(&format!("{}.md", post.slug)))
|
||||||
);
|
);
|
||||||
}
|
let unique: HashSet<_> = posts
|
||||||
|
.iter()
|
||||||
#[test]
|
.map(|post| (&post.project_id, &post.slug))
|
||||||
fn read_draft_post_has_content() {
|
|
||||||
let conn = fixture_db();
|
|
||||||
let (title, slug, status, content): (String, String, String, Option<String>) = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT title, slug, status, content FROM posts WHERE slug = 'draft-fixture-post'",
|
|
||||||
[],
|
|
||||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(title, "Draft Fixture Post");
|
|
||||||
assert_eq!(slug, "draft-fixture-post");
|
|
||||||
assert_eq!(status, "draft");
|
|
||||||
assert!(content.is_some(), "draft posts must have content in DB");
|
|
||||||
assert!(content.unwrap().contains("**body**"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_all_posts_count() {
|
|
||||||
let conn = fixture_db();
|
|
||||||
let count: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM posts", [], |row| row.get(0))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(count, 4); // 3 published + 1 draft
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn published_posts_have_file_paths() {
|
|
||||||
let conn = fixture_db();
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare("SELECT slug, file_path FROM posts WHERE status = 'published'")
|
|
||||||
.unwrap();
|
|
||||||
let rows: Vec<(String, String)> = stmt
|
|
||||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
|
|
||||||
.unwrap()
|
|
||||||
.map(|r| r.unwrap())
|
|
||||||
.collect();
|
.collect();
|
||||||
|
assert_eq!(unique.len(), posts.len());
|
||||||
assert_eq!(rows.len(), 3);
|
|
||||||
for (slug, path) in &rows {
|
|
||||||
assert!(
|
|
||||||
!path.is_empty(),
|
|
||||||
"published post '{slug}' must have a file_path"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
path.ends_with(&format!("{slug}.md")),
|
|
||||||
"file_path must end with {slug}.md"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn post_tags_are_json_arrays() {
|
fn post_translations_are_compatible() {
|
||||||
let conn = fixture_db();
|
let db = fixture_db();
|
||||||
let tags_json: Option<String> = conn
|
let posts = post::list_posts_by_project(db.conn(), PROJECT_ID).unwrap();
|
||||||
.query_row(
|
let translations: Vec<_> = posts
|
||||||
"SELECT tags FROM posts WHERE slug = 'esmeralda'",
|
.iter()
|
||||||
[],
|
.flat_map(|post| {
|
||||||
|row| row.get(0),
|
post_translation::list_post_translations_by_post(db.conn(), &post.id).unwrap()
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
if let Some(json) = tags_json {
|
|
||||||
let parsed: Vec<String> = serde_json::from_str(&json).unwrap();
|
|
||||||
assert!(!parsed.is_empty());
|
|
||||||
}
|
|
||||||
// tags can also be NULL — both are valid
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn post_timestamps_are_unix_integers() {
|
|
||||||
let conn = fixture_db();
|
|
||||||
let (created_at, updated_at): (i64, i64) = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT created_at, updated_at FROM posts WHERE slug = 'esmeralda'",
|
|
||||||
[],
|
|
||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Sanity: timestamps should be in reasonable Unix range (year 2000+)
|
|
||||||
assert!(
|
|
||||||
created_at > 946_684_800,
|
|
||||||
"created_at should be after year 2000"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
updated_at > 946_684_800,
|
|
||||||
"updated_at should be after year 2000"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn post_unique_constraint_on_project_slug() {
|
|
||||||
let conn = fixture_db();
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare("SELECT project_id, slug, COUNT(*) FROM posts GROUP BY project_id, slug HAVING COUNT(*) > 1")
|
|
||||||
.unwrap();
|
|
||||||
let dupes: Vec<(String, String, i64)> = stmt
|
|
||||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
|
||||||
.unwrap()
|
|
||||||
.map(|r| r.unwrap())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
dupes.is_empty(),
|
|
||||||
"found duplicate (project_id, slug) pairs: {dupes:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Post Translations ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_post_translations() {
|
|
||||||
let conn = fixture_db();
|
|
||||||
let count: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM post_translations", [], |row| {
|
|
||||||
row.get(0)
|
|
||||||
})
|
})
|
||||||
.unwrap();
|
|
||||||
assert_eq!(count, 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn translation_references_valid_post() {
|
|
||||||
let conn = fixture_db();
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT pt.id, pt.translation_for FROM post_translations pt \
|
|
||||||
LEFT JOIN posts p ON pt.translation_for = p.id \
|
|
||||||
WHERE p.id IS NULL",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let orphans: Vec<(String, String)> = stmt
|
|
||||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
|
|
||||||
.unwrap()
|
|
||||||
.map(|r| r.unwrap())
|
|
||||||
.collect();
|
.collect();
|
||||||
|
assert_eq!(translations.len(), 4);
|
||||||
assert!(
|
assert!(
|
||||||
orphans.is_empty(),
|
translations
|
||||||
"orphan translations referencing missing posts: {orphans:?}"
|
.iter()
|
||||||
|
.filter(|translation| translation.status == bds_core::model::PostStatus::Published)
|
||||||
|
.all(|translation| translation.content.is_none())
|
||||||
|
);
|
||||||
|
let post_ids: HashSet<_> = posts.iter().map(|post| post.id.as_str()).collect();
|
||||||
|
assert!(
|
||||||
|
translations
|
||||||
|
.iter()
|
||||||
|
.all(|translation| post_ids.contains(translation.translation_for.as_str()))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn published_translations_have_null_content() {
|
fn relationships_are_compatible() {
|
||||||
let conn = fixture_db();
|
let db = fixture_db();
|
||||||
let mut stmt = conn
|
let links = post_link::list_links_by_source(db.conn(), GHOSTTY_ID).unwrap();
|
||||||
.prepare("SELECT id, content FROM post_translations WHERE status = 'published'")
|
assert!(
|
||||||
.unwrap();
|
links
|
||||||
let rows: Vec<(String, Option<String>)> = stmt
|
.iter()
|
||||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
|
.any(|link| link.target_post_id == CMUX_ID && link.link_text.is_some())
|
||||||
.unwrap()
|
);
|
||||||
.map(|r| r.unwrap())
|
let media_links = post_media::list_post_media_by_post(db.conn(), ESMERALDA_ID).unwrap();
|
||||||
.collect();
|
assert!(
|
||||||
|
media_links
|
||||||
for (id, content) in &rows {
|
.iter()
|
||||||
assert!(
|
.any(|link| link.media_id == SPIDER_ID && link.sort_order == 0)
|
||||||
content.is_none(),
|
);
|
||||||
"published translation {id} must have NULL content"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Post Links ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_post_links() {
|
|
||||||
let conn = fixture_db();
|
|
||||||
let (source, target, text): (String, String, Option<String>) = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT source_post_id, target_post_id, link_text FROM post_links LIMIT 1",
|
|
||||||
[],
|
|
||||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// ghostty links to cmux
|
|
||||||
assert_eq!(source, "6745981d-da41-4cfd-80ec-95ad339acf6f");
|
|
||||||
assert_eq!(target, "2665bfaa-8251-468d-a710-a4cf34dd81e2");
|
|
||||||
assert!(text.is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Post Media ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_post_media() {
|
|
||||||
let conn = fixture_db();
|
|
||||||
let (post_id, media_id, sort_order): (String, String, i32) = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT post_id, media_id, sort_order FROM post_media LIMIT 1",
|
|
||||||
[],
|
|
||||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// esmeralda <-> spider photo
|
|
||||||
assert_eq!(post_id, "40a83ab1-423d-4310-aac4-642d84675007");
|
|
||||||
assert_eq!(media_id, "eb0cf9d7-6fbd-4b74-9be3-759d6e16f240");
|
|
||||||
assert_eq!(sort_order, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Media ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_media() {
|
|
||||||
let conn = fixture_db();
|
|
||||||
let (id, filename, original_name, mime_type, title, alt): (
|
|
||||||
String,
|
|
||||||
String,
|
|
||||||
String,
|
|
||||||
String,
|
|
||||||
Option<String>,
|
|
||||||
Option<String>,
|
|
||||||
) = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT id, filename, original_name, mime_type, title, alt FROM media LIMIT 1",
|
|
||||||
[],
|
|
||||||
|row| {
|
|
||||||
Ok((
|
|
||||||
row.get(0)?,
|
|
||||||
row.get(1)?,
|
|
||||||
row.get(2)?,
|
|
||||||
row.get(3)?,
|
|
||||||
row.get(4)?,
|
|
||||||
row.get(5)?,
|
|
||||||
))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(id, "eb0cf9d7-6fbd-4b74-9be3-759d6e16f240");
|
|
||||||
assert!(filename.ends_with(".jpg"));
|
|
||||||
assert_eq!(original_name, "CRW_1121.jpg");
|
|
||||||
assert_eq!(mime_type, "image/jpeg");
|
|
||||||
assert!(title.is_some());
|
|
||||||
assert!(alt.is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tags ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_tags() {
|
|
||||||
let conn = fixture_db();
|
|
||||||
let count: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM tags", [], |row| row.get(0))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(count, 5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tag_names_are_expected() {
|
fn media_is_compatible() {
|
||||||
let conn = fixture_db();
|
let db = fixture_db();
|
||||||
let mut stmt = conn.prepare("SELECT name FROM tags ORDER BY name").unwrap();
|
let item = media::get_media_by_id(db.conn(), SPIDER_ID).unwrap();
|
||||||
let names: Vec<String> = stmt
|
assert!(item.filename.ends_with(".jpg"));
|
||||||
.query_map([], |row| row.get(0))
|
assert_eq!(item.original_name, "CRW_1121.jpg");
|
||||||
.unwrap()
|
assert_eq!(item.mime_type, "image/jpeg");
|
||||||
.map(|r| r.unwrap())
|
assert!(item.title.is_some());
|
||||||
.collect();
|
assert!(item.alt.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tags_are_compatible() {
|
||||||
|
let db = fixture_db();
|
||||||
|
let tags = tag::list_tags_by_project(db.conn(), PROJECT_ID).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
names,
|
tags.iter().map(|tag| tag.name.as_str()).collect::<Vec<_>>(),
|
||||||
vec![
|
[
|
||||||
"fotografie",
|
"fotografie",
|
||||||
"mac-os-x",
|
"mac-os-x",
|
||||||
"natur",
|
"natur",
|
||||||
@@ -337,292 +132,106 @@ fn tag_names_are_expected() {
|
|||||||
"sysadmin"
|
"sysadmin"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
let unique: HashSet<_> = tags.iter().map(|tag| tag.name.to_lowercase()).collect();
|
||||||
|
assert_eq!(unique.len(), tags.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tag_unique_constraint_on_project_name() {
|
fn templates_are_compatible() {
|
||||||
let conn = fixture_db();
|
let db = fixture_db();
|
||||||
let mut stmt = conn
|
let templates = template::list_templates_by_project(db.conn(), PROJECT_ID).unwrap();
|
||||||
.prepare("SELECT project_id, name, COUNT(*) FROM tags GROUP BY project_id, name HAVING COUNT(*) > 1")
|
let value = templates.first().unwrap();
|
||||||
.unwrap();
|
assert_eq!(value.slug, "testvorlage");
|
||||||
let dupes: Vec<(String, String, i64)> = stmt
|
assert_eq!(value.title, "Testvorlage");
|
||||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
assert_eq!(value.kind, TemplateKind::Post);
|
||||||
.unwrap()
|
assert!(value.enabled);
|
||||||
.map(|r| r.unwrap())
|
assert_eq!(value.status, TemplateStatus::Published);
|
||||||
.collect();
|
assert!(value.content.is_none());
|
||||||
|
|
||||||
assert!(
|
|
||||||
dupes.is_empty(),
|
|
||||||
"found duplicate (project_id, name) tag pairs: {dupes:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Templates ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_template() {
|
|
||||||
let conn = fixture_db();
|
|
||||||
let (slug, title, kind, enabled, status, content): (
|
|
||||||
String,
|
|
||||||
String,
|
|
||||||
String,
|
|
||||||
bool,
|
|
||||||
String,
|
|
||||||
Option<String>,
|
|
||||||
) = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT slug, title, kind, enabled, status, content FROM templates LIMIT 1",
|
|
||||||
[],
|
|
||||||
|row| {
|
|
||||||
Ok((
|
|
||||||
row.get(0)?,
|
|
||||||
row.get(1)?,
|
|
||||||
row.get(2)?,
|
|
||||||
row.get(3)?,
|
|
||||||
row.get(4)?,
|
|
||||||
row.get(5)?,
|
|
||||||
))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(slug, "testvorlage");
|
|
||||||
assert_eq!(title, "Testvorlage");
|
|
||||||
assert_eq!(kind, "post");
|
|
||||||
assert!(enabled);
|
|
||||||
assert_eq!(status, "published");
|
|
||||||
assert!(
|
|
||||||
content.is_none(),
|
|
||||||
"published template content should be NULL in DB"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Scripts ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_scripts() {
|
|
||||||
let conn = fixture_db();
|
|
||||||
let count: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM scripts", [], |row| row.get(0))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(count, 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_script_fields() {
|
fn scripts_are_compatible() {
|
||||||
let conn = fixture_db();
|
let db = fixture_db();
|
||||||
let (slug, title, kind, entrypoint, enabled, status): (
|
let scripts = script::list_scripts_by_project(db.conn(), PROJECT_ID).unwrap();
|
||||||
String,
|
assert_eq!(scripts.len(), 2);
|
||||||
String,
|
let value = scripts
|
||||||
String,
|
|
||||||
String,
|
|
||||||
bool,
|
|
||||||
String,
|
|
||||||
) = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT slug, title, kind, entrypoint, enabled, status FROM scripts WHERE slug = 'bgg_link'",
|
|
||||||
[],
|
|
||||||
|row| {
|
|
||||||
Ok((
|
|
||||||
row.get(0)?,
|
|
||||||
row.get(1)?,
|
|
||||||
row.get(2)?,
|
|
||||||
row.get(3)?,
|
|
||||||
row.get(4)?,
|
|
||||||
row.get(5)?,
|
|
||||||
))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(slug, "bgg_link");
|
|
||||||
assert_eq!(title, "bgg link");
|
|
||||||
assert_eq!(kind, "transform");
|
|
||||||
assert_eq!(entrypoint, "normalize_blogmark");
|
|
||||||
assert!(enabled);
|
|
||||||
assert_eq!(status, "published");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Settings ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_settings() {
|
|
||||||
let conn = fixture_db();
|
|
||||||
let count: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM settings", [], |row| row.get(0))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(count, 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_are_key_value_pairs() {
|
|
||||||
let conn = fixture_db();
|
|
||||||
let mut stmt = conn.prepare("SELECT key, value FROM settings").unwrap();
|
|
||||||
let pairs: Vec<(String, String)> = stmt
|
|
||||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
|
|
||||||
.unwrap()
|
|
||||||
.map(|r| r.unwrap())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for (key, value) in &pairs {
|
|
||||||
assert!(!key.is_empty());
|
|
||||||
assert!(!value.is_empty());
|
|
||||||
}
|
|
||||||
// All setting keys should contain the project ID (namespaced)
|
|
||||||
let project_keys: Vec<_> = pairs
|
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(k, _)| k.contains(PROJECT_ID))
|
.find(|script| script.slug == "bgg_link")
|
||||||
.collect();
|
.unwrap();
|
||||||
assert_eq!(project_keys.len(), 5);
|
assert_eq!(value.title, "bgg link");
|
||||||
|
assert_eq!(value.kind, ScriptKind::Transform);
|
||||||
|
assert_eq!(value.entrypoint, "normalize_blogmark");
|
||||||
|
assert!(value.enabled);
|
||||||
|
assert_eq!(value.status, ScriptStatus::Published);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── AI Catalog ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_ai_tables() {
|
fn settings_are_compatible() {
|
||||||
let conn = fixture_db();
|
let db = fixture_db();
|
||||||
|
let settings = setting::list_all_settings(db.conn()).unwrap();
|
||||||
let providers: i64 = conn
|
assert_eq!(settings.len(), 5);
|
||||||
.query_row("SELECT COUNT(*) FROM ai_providers", [], |row| row.get(0))
|
assert!(settings.iter().all(|setting| !setting.key.is_empty()
|
||||||
.unwrap();
|
&& !setting.value.is_empty()
|
||||||
let models: i64 = conn
|
&& setting.key.contains(PROJECT_ID)));
|
||||||
.query_row("SELECT COUNT(*) FROM ai_models", [], |row| row.get(0))
|
|
||||||
.unwrap();
|
|
||||||
let meta: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM ai_catalog_meta", [], |row| row.get(0))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(providers, 1);
|
|
||||||
assert_eq!(models, 1);
|
|
||||||
assert_eq!(meta, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Generated File Hashes ───────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_generated_file_hashes_via_settings() {
|
|
||||||
// The fixture stores generation hashes as settings keys
|
|
||||||
let conn = fixture_db();
|
|
||||||
let count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM settings WHERE key LIKE '%generation-hash%'",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(count > 0, "expected generation hash entries in settings");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Cross-table referential integrity ───────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn all_posts_belong_to_existing_project() {
|
|
||||||
let conn = fixture_db();
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT p.id FROM posts p \
|
|
||||||
LEFT JOIN projects pr ON p.project_id = pr.id \
|
|
||||||
WHERE pr.id IS NULL",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let orphans: Vec<String> = stmt
|
|
||||||
.query_map([], |row| row.get(0))
|
|
||||||
.unwrap()
|
|
||||||
.map(|r| r.unwrap())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
orphans.is_empty(),
|
settings
|
||||||
"posts referencing missing projects: {orphans:?}"
|
.iter()
|
||||||
|
.any(|setting| setting.key.contains("generation-hash"))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn all_tags_belong_to_existing_project() {
|
fn ai_catalog_is_compatible() {
|
||||||
let conn = fixture_db();
|
let db = fixture_db();
|
||||||
let mut stmt = conn
|
let (providers, models, meta) = db
|
||||||
.prepare(
|
.conn()
|
||||||
"SELECT t.id FROM tags t \
|
.with(|conn| {
|
||||||
LEFT JOIN projects pr ON t.project_id = pr.id \
|
Ok((
|
||||||
WHERE pr.id IS NULL",
|
ai_providers::table.count().get_result::<i64>(conn)?,
|
||||||
)
|
ai_models::table.count().get_result::<i64>(conn)?,
|
||||||
|
ai_catalog_meta::table.count().get_result::<i64>(conn)?,
|
||||||
|
))
|
||||||
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let orphans: Vec<String> = stmt
|
assert_eq!((providers, models, meta), (1, 1, 2));
|
||||||
.query_map([], |row| row.get(0))
|
|
||||||
.unwrap()
|
|
||||||
.map(|r| r.unwrap())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
orphans.is_empty(),
|
|
||||||
"tags referencing missing projects: {orphans:?}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn all_media_belong_to_existing_project() {
|
fn relationships_reference_existing_entities() {
|
||||||
let conn = fixture_db();
|
let db = fixture_db();
|
||||||
let mut stmt = conn
|
let projects = project::list_projects(db.conn()).unwrap();
|
||||||
.prepare(
|
let project_ids: HashSet<_> = projects.iter().map(|project| project.id.as_str()).collect();
|
||||||
"SELECT m.id FROM media m \
|
let posts = post::list_posts_by_project(db.conn(), PROJECT_ID).unwrap();
|
||||||
LEFT JOIN projects pr ON m.project_id = pr.id \
|
let post_ids: HashSet<_> = posts.iter().map(|post| post.id.as_str()).collect();
|
||||||
WHERE pr.id IS NULL",
|
let media = media::list_media_by_project(db.conn(), PROJECT_ID).unwrap();
|
||||||
)
|
let media_ids: HashSet<_> = media.iter().map(|media| media.id.as_str()).collect();
|
||||||
.unwrap();
|
let tags = tag::list_tags_by_project(db.conn(), PROJECT_ID).unwrap();
|
||||||
let orphans: Vec<String> = stmt
|
|
||||||
.query_map([], |row| row.get(0))
|
|
||||||
.unwrap()
|
|
||||||
.map(|r| r.unwrap())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
orphans.is_empty(),
|
posts
|
||||||
"media referencing missing projects: {orphans:?}"
|
.iter()
|
||||||
|
.all(|post| project_ids.contains(post.project_id.as_str()))
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn post_links_reference_valid_posts() {
|
|
||||||
let conn = fixture_db();
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT pl.id, pl.source_post_id, pl.target_post_id FROM post_links pl \
|
|
||||||
LEFT JOIN posts s ON pl.source_post_id = s.id \
|
|
||||||
LEFT JOIN posts t ON pl.target_post_id = t.id \
|
|
||||||
WHERE s.id IS NULL OR t.id IS NULL",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let orphans: Vec<(String, String, String)> = stmt
|
|
||||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
|
||||||
.unwrap()
|
|
||||||
.map(|r| r.unwrap())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
orphans.is_empty(),
|
media
|
||||||
"post_links with invalid references: {orphans:?}"
|
.iter()
|
||||||
|
.all(|media| project_ids.contains(media.project_id.as_str()))
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn post_media_references_valid_entities() {
|
|
||||||
let conn = fixture_db();
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT pm.id FROM post_media pm \
|
|
||||||
LEFT JOIN posts p ON pm.post_id = p.id \
|
|
||||||
LEFT JOIN media m ON pm.media_id = m.id \
|
|
||||||
WHERE p.id IS NULL OR m.id IS NULL",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let orphans: Vec<String> = stmt
|
|
||||||
.query_map([], |row| row.get(0))
|
|
||||||
.unwrap()
|
|
||||||
.map(|r| r.unwrap())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
orphans.is_empty(),
|
tags.iter()
|
||||||
"post_media with invalid references: {orphans:?}"
|
.all(|tag| project_ids.contains(tag.project_id.as_str()))
|
||||||
);
|
);
|
||||||
|
for post in &posts {
|
||||||
|
assert!(
|
||||||
|
post_link::list_links_by_source(db.conn(), &post.id)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.all(|link| post_ids.contains(link.target_post_id.as_str()))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
post_media::list_post_media_by_post(db.conn(), &post.id)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.all(|link| media_ids.contains(link.media_id.as_str()))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ fn make_test_project(id: &str, slug: &str) -> Project {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
ensure_fts_tables(db.conn()).unwrap();
|
ensure_fts_tables(db.conn()).unwrap();
|
||||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ fn make_list_template(slug: &str, content: &str) -> Template {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
insert_project(db.conn(), &make_project()).unwrap();
|
insert_project(db.conn(), &make_project()).unwrap();
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ fn make_post(slug: &str, published_at: i64) -> Post {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
insert_project(db.conn(), &make_project()).unwrap();
|
insert_project(db.conn(), &make_project()).unwrap();
|
||||||
(db, TempDir::new().unwrap())
|
(db, TempDir::new().unwrap())
|
||||||
|
|||||||
60
crates/bds-core/tests/orm_boundary.rs
Normal file
60
crates/bds-core/tests/orm_boundary.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
const SQL_PREFIXES: &[&str] = &[
|
||||||
|
"\"SELECT ",
|
||||||
|
"\"INSERT ",
|
||||||
|
"\"UPDATE ",
|
||||||
|
"\"DELETE ",
|
||||||
|
"\"CREATE ",
|
||||||
|
"\"DROP ",
|
||||||
|
"\"ALTER ",
|
||||||
|
"\"SAVEPOINT ",
|
||||||
|
"\"RELEASE ",
|
||||||
|
"\"ROLLBACK ",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn application_queries_use_the_diesel_abstraction() {
|
||||||
|
let source_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||||
|
let mut rust_files = Vec::new();
|
||||||
|
collect_rust_files(&source_root, &mut rust_files);
|
||||||
|
|
||||||
|
let mut violations = Vec::new();
|
||||||
|
for path in rust_files {
|
||||||
|
// SQLite connection configuration and FTS5 virtual tables/MATCH have no
|
||||||
|
// representation in Diesel's typed query builder.
|
||||||
|
if path.ends_with("db/connection.rs") || path.ends_with("db/fts.rs") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let source = fs::read_to_string(&path).unwrap();
|
||||||
|
for (line_index, line) in source.lines().enumerate() {
|
||||||
|
if SQL_PREFIXES.iter().any(|prefix| line.contains(prefix)) {
|
||||||
|
violations.push(format!(
|
||||||
|
"{}:{}: {}",
|
||||||
|
path.display(),
|
||||||
|
line_index + 1,
|
||||||
|
line.trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
violations.is_empty(),
|
||||||
|
"literal SQL escaped the backend boundary:\n{}",
|
||||||
|
violations.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_rust_files(directory: &Path, files: &mut Vec<PathBuf>) {
|
||||||
|
for entry in fs::read_dir(directory).unwrap() {
|
||||||
|
let path = entry.unwrap().path();
|
||||||
|
if path.is_dir() {
|
||||||
|
collect_rust_files(&path, files);
|
||||||
|
} else if path.extension().is_some_and(|extension| extension == "rs") {
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,75 @@
|
|||||||
//! Tests that verify allium spec claims against the Rust model and DB code.
|
//! Executable checks for storage-related Allium claims.
|
||||||
//!
|
|
||||||
//! Each test is annotated with the spec invariant or rule it validates.
|
|
||||||
|
|
||||||
use rusqlite::{Connection, OpenFlags};
|
use std::collections::HashSet;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
fn fixture_db() -> Connection {
|
use bds_core::db::Database;
|
||||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
use bds_core::db::queries::{
|
||||||
.join("../../fixtures/compatibility-projects/rfc1437-sample/bds.db");
|
post as post_queries, post_translation, project as project_queries, script, tag, template,
|
||||||
Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap()
|
};
|
||||||
|
use bds_core::db::schema::{posts, scripts, templates};
|
||||||
|
use bds_core::model::{
|
||||||
|
Post, PostStatus, Project, ScriptKind, ScriptStatus, Tag, TemplateKind, TemplateStatus,
|
||||||
|
};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
fn fixture_db() -> Database {
|
||||||
|
Database::open(
|
||||||
|
&PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../fixtures/compatibility-projects/rfc1437-sample/bds.db"),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn memory_db() -> Connection {
|
fn memory_db() -> Database {
|
||||||
let mut conn = Connection::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
|
db.migrate().unwrap();
|
||||||
bds_core::db::run_migrations(&mut conn).unwrap();
|
db
|
||||||
conn
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── spec: post.allium — PostStatus serde matches DB string values ────
|
fn project(id: &str, slug: &str) -> Project {
|
||||||
|
Project {
|
||||||
|
id: id.into(),
|
||||||
|
name: id.into(),
|
||||||
|
slug: slug.into(),
|
||||||
|
description: None,
|
||||||
|
data_path: None,
|
||||||
|
is_active: true,
|
||||||
|
created_at: 1000,
|
||||||
|
updated_at: 1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn post(id: &str, status: PostStatus, published_at: Option<i64>) -> Post {
|
||||||
|
Post {
|
||||||
|
id: id.into(),
|
||||||
|
project_id: "p1".into(),
|
||||||
|
title: id.into(),
|
||||||
|
slug: id.into(),
|
||||||
|
excerpt: None,
|
||||||
|
content: Some("body".into()),
|
||||||
|
status,
|
||||||
|
author: None,
|
||||||
|
language: None,
|
||||||
|
do_not_translate: false,
|
||||||
|
template_slug: None,
|
||||||
|
file_path: String::new(),
|
||||||
|
checksum: None,
|
||||||
|
tags: Vec::new(),
|
||||||
|
categories: Vec::new(),
|
||||||
|
published_title: None,
|
||||||
|
published_content: None,
|
||||||
|
published_tags: None,
|
||||||
|
published_categories: None,
|
||||||
|
published_excerpt: None,
|
||||||
|
created_at: 1000,
|
||||||
|
updated_at: 1000,
|
||||||
|
published_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn post_status_serde_matches_db_values() {
|
fn enum_serde_matches_database_values() {
|
||||||
// spec: status: draft | published | archived
|
|
||||||
use bds_core::model::PostStatus;
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
serde_json::to_string(&PostStatus::Draft).unwrap(),
|
serde_json::to_string(&PostStatus::Draft).unwrap(),
|
||||||
"\"draft\""
|
"\"draft\""
|
||||||
@@ -37,584 +82,271 @@ fn post_status_serde_matches_db_values() {
|
|||||||
serde_json::to_string(&PostStatus::Archived).unwrap(),
|
serde_json::to_string(&PostStatus::Archived).unwrap(),
|
||||||
"\"archived\""
|
"\"archived\""
|
||||||
);
|
);
|
||||||
|
|
||||||
// round-trip
|
|
||||||
let d: PostStatus = serde_json::from_str("\"draft\"").unwrap();
|
|
||||||
assert_eq!(d, PostStatus::Draft);
|
|
||||||
let p: PostStatus = serde_json::from_str("\"published\"").unwrap();
|
|
||||||
assert_eq!(p, PostStatus::Published);
|
|
||||||
let a: PostStatus = serde_json::from_str("\"archived\"").unwrap();
|
|
||||||
assert_eq!(a, PostStatus::Archived);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── spec: schema.allium — Template kind: post | list | not_found | partial ──
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn template_kind_serde_matches_db_values() {
|
|
||||||
use bds_core::model::TemplateKind;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_string(&TemplateKind::Post).unwrap(),
|
|
||||||
"\"post\""
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_string(&TemplateKind::List).unwrap(),
|
|
||||||
"\"list\""
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
serde_json::to_string(&TemplateKind::NotFound).unwrap(),
|
serde_json::to_string(&TemplateKind::NotFound).unwrap(),
|
||||||
"\"not_found\""
|
"\"not_found\""
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_string(&TemplateKind::Partial).unwrap(),
|
|
||||||
"\"partial\""
|
|
||||||
);
|
|
||||||
|
|
||||||
let nf: TemplateKind = serde_json::from_str("\"not_found\"").unwrap();
|
|
||||||
assert_eq!(nf, TemplateKind::NotFound);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── spec: schema.allium — Template status: draft | published ──
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn template_status_serde_matches_db_values() {
|
|
||||||
use bds_core::model::TemplateStatus;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_string(&TemplateStatus::Draft).unwrap(),
|
|
||||||
"\"draft\""
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
serde_json::to_string(&TemplateStatus::Published).unwrap(),
|
serde_json::to_string(&TemplateStatus::Published).unwrap(),
|
||||||
"\"published\""
|
"\"published\""
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// ── spec: schema.allium — Script kind: macro | utility | transform ──
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn script_kind_serde_matches_db_values() {
|
|
||||||
use bds_core::model::ScriptKind;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_string(&ScriptKind::Macro).unwrap(),
|
|
||||||
"\"macro\""
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_string(&ScriptKind::Utility).unwrap(),
|
|
||||||
"\"utility\""
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
serde_json::to_string(&ScriptKind::Transform).unwrap(),
|
serde_json::to_string(&ScriptKind::Transform).unwrap(),
|
||||||
"\"transform\""
|
"\"transform\""
|
||||||
);
|
);
|
||||||
|
|
||||||
let t: ScriptKind = serde_json::from_str("\"transform\"").unwrap();
|
|
||||||
assert_eq!(t, ScriptKind::Transform);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── spec: schema.allium — Script status: draft | published ──
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn script_status_serde_matches_db_values() {
|
|
||||||
use bds_core::model::ScriptStatus;
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
serde_json::to_string(&ScriptStatus::Draft).unwrap(),
|
serde_json::to_string(&ScriptStatus::Draft).unwrap(),
|
||||||
"\"draft\""
|
"\"draft\""
|
||||||
);
|
);
|
||||||
assert_eq!(
|
}
|
||||||
serde_json::to_string(&ScriptStatus::Published).unwrap(),
|
|
||||||
"\"published\""
|
#[test]
|
||||||
|
fn fixture_content_location_matches_spec() {
|
||||||
|
let db = fixture_db();
|
||||||
|
let active = project_queries::get_active_project(db.conn()).unwrap();
|
||||||
|
let posts = post_queries::list_posts_by_project(db.conn(), &active.id).unwrap();
|
||||||
|
assert!(
|
||||||
|
posts
|
||||||
|
.iter()
|
||||||
|
.filter(|post| post.status == PostStatus::Published)
|
||||||
|
.all(|post| post.content.is_none())
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
posts
|
||||||
|
.iter()
|
||||||
|
.filter(|post| post.status == PostStatus::Draft)
|
||||||
|
.all(|post| post.content.is_some())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── spec: post.allium — content_location invariant ──
|
|
||||||
// "if status = published: file_path else: content"
|
|
||||||
// Published posts have NULL content in DB; draft posts have content in DB.
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn content_location_published_posts_null_content_in_fixture() {
|
|
||||||
let conn = fixture_db();
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare("SELECT slug, content FROM posts WHERE status = 'published'")
|
|
||||||
.unwrap();
|
|
||||||
let rows: Vec<(String, Option<String>)> = stmt
|
|
||||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
|
|
||||||
.unwrap()
|
|
||||||
.map(|r| r.unwrap())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
assert!(!rows.is_empty());
|
|
||||||
for (slug, content) in &rows {
|
|
||||||
assert!(
|
|
||||||
content.is_none(),
|
|
||||||
"spec: published post '{slug}' must have NULL content in DB"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn content_location_draft_posts_have_content_in_fixture() {
|
|
||||||
let conn = fixture_db();
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare("SELECT slug, content FROM posts WHERE status = 'draft'")
|
|
||||||
.unwrap();
|
|
||||||
let rows: Vec<(String, Option<String>)> = stmt
|
|
||||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
|
|
||||||
.unwrap()
|
|
||||||
.map(|r| r.unwrap())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
assert!(!rows.is_empty());
|
|
||||||
for (slug, content) in &rows {
|
|
||||||
assert!(
|
|
||||||
content.is_some(),
|
|
||||||
"spec: draft post '{slug}' must have content in DB"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── spec: post.allium — all status transitions allowed ──
|
|
||||||
// draft -> published, draft -> archived, published -> draft,
|
|
||||||
// published -> archived, archived -> draft, archived -> published
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn post_status_transitions_all_valid() {
|
fn post_status_transitions_all_valid() {
|
||||||
let conn = memory_db();
|
let db = memory_db();
|
||||||
conn.execute(
|
project_queries::insert_project(db.conn(), &project("p1", "test")).unwrap();
|
||||||
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
|
post_queries::insert_post(db.conn(), &post("post1", PostStatus::Draft, None)).unwrap();
|
||||||
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
|
for status in [
|
||||||
[],
|
PostStatus::Published,
|
||||||
)
|
PostStatus::Draft,
|
||||||
.unwrap();
|
PostStatus::Archived,
|
||||||
conn.execute(
|
PostStatus::Draft,
|
||||||
"INSERT INTO posts (id, project_id, title, slug, status, file_path, created_at, updated_at) \
|
PostStatus::Published,
|
||||||
VALUES ('post1', 'p1', 'Test', 'test', 'draft', '', 1000, 1000)",
|
PostStatus::Archived,
|
||||||
[],
|
PostStatus::Published,
|
||||||
).unwrap();
|
] {
|
||||||
|
post_queries::update_post_status(db.conn(), "post1", &status, 1000).unwrap();
|
||||||
let transitions = [
|
assert_eq!(
|
||||||
("draft", "published"),
|
post_queries::get_post_by_id(db.conn(), "post1")
|
||||||
("published", "draft"),
|
.unwrap()
|
||||||
("draft", "archived"),
|
.status,
|
||||||
("archived", "draft"),
|
status
|
||||||
("draft", "published"),
|
);
|
||||||
("published", "archived"),
|
|
||||||
("archived", "published"),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (from, to) in transitions {
|
|
||||||
// Set to 'from' state first
|
|
||||||
conn.execute("UPDATE posts SET status = ?1 WHERE id = 'post1'", [from])
|
|
||||||
.unwrap();
|
|
||||||
// Transition to 'to' state
|
|
||||||
conn.execute("UPDATE posts SET status = ?1 WHERE id = 'post1'", [to])
|
|
||||||
.unwrap();
|
|
||||||
let status: String = conn
|
|
||||||
.query_row("SELECT status FROM posts WHERE id = 'post1'", [], |r| {
|
|
||||||
r.get(0)
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(status, to, "transition {from} -> {to} failed");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── spec: post.allium — is_slug_frozen: published_at != null ──
|
|
||||||
// Slug changes only allowed before first publish
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn slug_frozen_after_publish_semantics() {
|
fn slug_frozen_after_publish_semantics() {
|
||||||
let conn = memory_db();
|
let db = memory_db();
|
||||||
conn.execute(
|
project_queries::insert_project(db.conn(), &project("p1", "test")).unwrap();
|
||||||
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
|
post_queries::insert_post(
|
||||||
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
|
db.conn(),
|
||||||
[],
|
&post("published", PostStatus::Published, Some(1000)),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
conn.execute(
|
post_queries::insert_post(db.conn(), &post("draft", PostStatus::Draft, None)).unwrap();
|
||||||
"INSERT INTO posts (id, project_id, title, slug, status, file_path, created_at, updated_at, published_at) \
|
|
||||||
VALUES ('post1', 'p1', 'Test', 'test', 'published', 'posts/2024/01/test.md', 1000, 1000, 1000)",
|
|
||||||
[],
|
|
||||||
).unwrap();
|
|
||||||
|
|
||||||
// published_at is set — slug should be considered frozen
|
|
||||||
let published_at: Option<i64> = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT published_at FROM posts WHERE id = 'post1'",
|
|
||||||
[],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
assert!(
|
||||||
published_at.is_some(),
|
post_queries::get_post_by_id(db.conn(), "published")
|
||||||
"spec: is_slug_frozen = published_at != null"
|
.unwrap()
|
||||||
);
|
.published_at
|
||||||
|
.is_some()
|
||||||
// A never-published draft has no published_at — slug is mutable
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO posts (id, project_id, title, slug, status, file_path, created_at, updated_at) \
|
|
||||||
VALUES ('post2', 'p1', 'Draft', 'draft-post', 'draft', '', 1000, 1000)",
|
|
||||||
[],
|
|
||||||
).unwrap();
|
|
||||||
let draft_published_at: Option<i64> = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT published_at FROM posts WHERE id = 'post2'",
|
|
||||||
[],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
draft_published_at.is_none(),
|
|
||||||
"spec: unpublished draft has no published_at"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── spec: project.allium — SingleActiveProject invariant ──
|
|
||||||
// "Exactly one project is active at any time"
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn single_active_project_in_fixture() {
|
|
||||||
let conn = fixture_db();
|
|
||||||
let active_count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM projects WHERE is_active = 1",
|
|
||||||
[],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(active_count, 1, "spec: exactly one project is active");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── spec: project.allium — UniqueProjectSlug invariant ──
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unique_project_slug_in_fixture() {
|
|
||||||
let conn = fixture_db();
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare("SELECT slug, COUNT(*) FROM projects GROUP BY slug HAVING COUNT(*) > 1")
|
|
||||||
.unwrap();
|
|
||||||
let dupes: Vec<(String, i64)> = stmt
|
|
||||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
|
|
||||||
.unwrap()
|
|
||||||
.map(|r| r.unwrap())
|
|
||||||
.collect();
|
|
||||||
assert!(dupes.is_empty(), "spec: project slug must be unique");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── spec: translation.allium — UniqueTranslationPerLanguage ──
|
|
||||||
// "post_translations must have unique (translation_for, language)"
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unique_translation_per_post_language_in_fixture() {
|
|
||||||
let conn = fixture_db();
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT translation_for, language, COUNT(*) FROM post_translations \
|
|
||||||
GROUP BY translation_for, language HAVING COUNT(*) > 1",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let dupes: Vec<(String, String, i64)> = stmt
|
|
||||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
|
||||||
.unwrap()
|
|
||||||
.map(|r| r.unwrap())
|
|
||||||
.collect();
|
|
||||||
assert!(
|
|
||||||
dupes.is_empty(),
|
|
||||||
"spec: translation unique per (post, language)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── spec: schema.allium — Post defaults ──
|
|
||||||
// status default 'draft', do_not_translate default false, file_path default ''
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn post_defaults_match_spec() {
|
|
||||||
let conn = memory_db();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
|
|
||||||
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
// Insert with minimal columns to test defaults
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO posts (id, project_id, title, slug, created_at, updated_at) \
|
|
||||||
VALUES ('min1', 'p1', 'Minimal', 'minimal', 1000, 1000)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let (status, do_not_translate, file_path): (String, bool, String) = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT status, do_not_translate, file_path FROM posts WHERE id = 'min1'",
|
|
||||||
[],
|
|
||||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(status, "draft", "spec: default status is draft");
|
|
||||||
assert!(!do_not_translate, "spec: default do_not_translate is false");
|
|
||||||
assert_eq!(file_path, "", "spec: default file_path is empty string");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── spec: schema.allium — Script defaults ──
|
|
||||||
// kind default 'utility', entrypoint default 'render', enabled default true,
|
|
||||||
// version default 1, status default 'published'
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn script_defaults_match_spec() {
|
|
||||||
let conn = memory_db();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
|
|
||||||
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO scripts (id, project_id, slug, title, file_path, created_at, updated_at) \
|
|
||||||
VALUES ('s1', 'p1', 'test', 'Test', 'scripts/test.lua', 1000, 1000)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let (kind, entrypoint, enabled, version, status): (String, String, bool, i32, String) = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT kind, entrypoint, enabled, version, status FROM scripts WHERE id = 's1'",
|
|
||||||
[],
|
|
||||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(kind, "utility", "spec: default kind is utility");
|
|
||||||
assert_eq!(entrypoint, "render", "spec: default entrypoint is 'render'");
|
|
||||||
assert!(enabled, "spec: default enabled is true");
|
|
||||||
assert_eq!(version, 1, "spec: default version is 1");
|
|
||||||
assert_eq!(status, "draft", "spec: default status is draft");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── spec: schema.allium — Template defaults ──
|
|
||||||
// kind default 'post', enabled default true, version default 1, status default 'draft'
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn template_defaults_match_spec() {
|
|
||||||
let conn = memory_db();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
|
|
||||||
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO templates (id, project_id, slug, title, file_path, created_at, updated_at) \
|
|
||||||
VALUES ('t1', 'p1', 'test', 'Test', 'templates/test.liquid', 1000, 1000)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let (kind, enabled, version, status): (String, bool, i32, String) = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT kind, enabled, version, status FROM templates WHERE id = 't1'",
|
|
||||||
[],
|
|
||||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(kind, "post", "spec: default kind is post");
|
|
||||||
assert!(enabled, "spec: default enabled is true");
|
|
||||||
assert_eq!(version, 1, "spec: default version is 1");
|
|
||||||
assert_eq!(status, "draft", "spec: default status is draft");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── spec: tag.allium — UniqueTagNamePerProject (case-insensitive) ──
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tag_unique_name_per_project_enforced() {
|
|
||||||
let conn = memory_db();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
|
|
||||||
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO tags (id, project_id, name, created_at, updated_at) \
|
|
||||||
VALUES ('t1', 'p1', 'rust', 1000, 1000)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Same name, same project — must fail
|
|
||||||
let result = conn.execute(
|
|
||||||
"INSERT INTO tags (id, project_id, name, created_at, updated_at) \
|
|
||||||
VALUES ('t2', 'p1', 'rust', 1000, 1000)",
|
|
||||||
[],
|
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
result.is_err(),
|
post_queries::get_post_by_id(db.conn(), "draft")
|
||||||
"spec: duplicate tag name in same project must fail"
|
.unwrap()
|
||||||
|
.published_at
|
||||||
|
.is_none()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── spec: post.allium — Slug generation algorithm ──
|
|
||||||
// "transliterate unicode to ASCII, lowercase, replace [^a-z0-9]+ with hyphens,
|
|
||||||
// strip leading/trailing hyphens"
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn slug_generation_matches_spec_algorithm() {
|
fn project_and_translation_uniqueness_in_fixture() {
|
||||||
use bds_core::util::slugify;
|
let db = fixture_db();
|
||||||
|
let projects = project_queries::list_projects(db.conn()).unwrap();
|
||||||
// Basic: lowercase + hyphen separation
|
|
||||||
assert_eq!(slugify("Hello World"), "hello-world");
|
|
||||||
|
|
||||||
// Non-alphanumeric replaced with single hyphen
|
|
||||||
assert_eq!(slugify("a --- b"), "a-b");
|
|
||||||
|
|
||||||
// Leading/trailing hyphens stripped
|
|
||||||
assert_eq!(slugify("---hello---"), "hello");
|
|
||||||
|
|
||||||
// Unicode transliteration
|
|
||||||
assert_eq!(slugify("café"), "cafe");
|
|
||||||
|
|
||||||
// German umlauts (spec: "only German and English letters used")
|
|
||||||
assert_eq!(slugify("über"), "ueber");
|
|
||||||
assert_eq!(slugify("Ärger"), "aerger");
|
|
||||||
assert_eq!(slugify("Öffnung"), "oeffnung");
|
|
||||||
assert_eq!(slugify("Straße"), "strasse");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── spec: post.allium — Slug uniqueness algorithm ──
|
|
||||||
// "tries base, then {slug}-2 .. {slug}-999, then {slug}-{timestamp}"
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn slug_uniqueness_matches_spec() {
|
|
||||||
use bds_core::util::ensure_unique;
|
|
||||||
|
|
||||||
// Base available
|
|
||||||
assert_eq!(ensure_unique("test", |_| false), "test");
|
|
||||||
|
|
||||||
// Base taken → -2
|
|
||||||
assert_eq!(ensure_unique("test", |s| s == "test"), "test-2");
|
|
||||||
|
|
||||||
// -2 and -3 taken → -4
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ensure_unique("test", |s| s == "test" || s == "test-2" || s == "test-3"),
|
projects.iter().filter(|project| project.is_active).count(),
|
||||||
"test-4"
|
1
|
||||||
);
|
);
|
||||||
}
|
assert_eq!(
|
||||||
|
projects
|
||||||
// ── spec: frontmatter.allium — PostFileLayout ──
|
.iter()
|
||||||
// "posts/{YYYY}/{MM}/{slug}.md"
|
.map(|project| &project.slug)
|
||||||
|
.collect::<HashSet<_>>()
|
||||||
#[test]
|
.len(),
|
||||||
fn published_post_file_paths_follow_date_layout() {
|
projects.len()
|
||||||
let conn = fixture_db();
|
);
|
||||||
let mut stmt = conn
|
let active = project_queries::get_active_project(db.conn()).unwrap();
|
||||||
.prepare("SELECT slug, file_path, created_at FROM posts WHERE status = 'published' AND file_path != ''")
|
let posts = post_queries::list_posts_by_project(db.conn(), &active.id).unwrap();
|
||||||
.unwrap();
|
for post in posts {
|
||||||
let rows: Vec<(String, String, i64)> = stmt
|
let translations =
|
||||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
post_translation::list_post_translations_by_post(db.conn(), &post.id).unwrap();
|
||||||
.unwrap()
|
assert_eq!(
|
||||||
.map(|r| r.unwrap())
|
translations
|
||||||
.collect();
|
.iter()
|
||||||
|
.map(|translation| &translation.language)
|
||||||
for (slug, path, _created_at) in &rows {
|
.collect::<HashSet<_>>()
|
||||||
// Path should end with {slug}.md
|
.len(),
|
||||||
assert!(
|
translations.len()
|
||||||
path.ends_with(&format!("{slug}.md")),
|
|
||||||
"spec: file_path must end with {{slug}}.md, got: {path}"
|
|
||||||
);
|
|
||||||
// Path should contain posts/YYYY/MM/ pattern
|
|
||||||
assert!(
|
|
||||||
path.contains("/posts/"),
|
|
||||||
"spec: file_path must contain /posts/, got: {path}"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── spec: cli_sync.allium — DbNotification entity_type values ──
|
#[test]
|
||||||
|
fn post_defaults_match_spec() {
|
||||||
|
let db = memory_db();
|
||||||
|
project_queries::insert_project(db.conn(), &project("p1", "test")).unwrap();
|
||||||
|
db.conn()
|
||||||
|
.with(|conn| {
|
||||||
|
diesel::insert_into(posts::table)
|
||||||
|
.values((
|
||||||
|
posts::id.eq("min1"),
|
||||||
|
posts::project_id.eq("p1"),
|
||||||
|
posts::title.eq("Minimal"),
|
||||||
|
posts::slug.eq("minimal"),
|
||||||
|
posts::created_at.eq(1000_i64),
|
||||||
|
posts::updated_at.eq(1000_i64),
|
||||||
|
))
|
||||||
|
.execute(conn)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let (status, do_not_translate, file_path) = db
|
||||||
|
.conn()
|
||||||
|
.with(|conn| {
|
||||||
|
posts::table
|
||||||
|
.filter(posts::id.eq("min1"))
|
||||||
|
.select((posts::status, posts::do_not_translate, posts::file_path))
|
||||||
|
.first::<(String, i32, String)>(conn)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(status, "draft");
|
||||||
|
assert_eq!(do_not_translate, 0);
|
||||||
|
assert!(file_path.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn notification_entity_serde_matches_db_values() {
|
fn script_defaults_match_spec() {
|
||||||
use bds_core::model::{NotificationAction, NotificationEntity};
|
let db = memory_db();
|
||||||
|
project_queries::insert_project(db.conn(), &project("p1", "test")).unwrap();
|
||||||
|
db.conn()
|
||||||
|
.with(|conn| {
|
||||||
|
diesel::insert_into(scripts::table)
|
||||||
|
.values((
|
||||||
|
scripts::id.eq("s1"),
|
||||||
|
scripts::project_id.eq("p1"),
|
||||||
|
scripts::slug.eq("test"),
|
||||||
|
scripts::title.eq("Test"),
|
||||||
|
scripts::file_path.eq("scripts/test.lua"),
|
||||||
|
scripts::created_at.eq(1000_i64),
|
||||||
|
scripts::updated_at.eq(1000_i64),
|
||||||
|
))
|
||||||
|
.execute(conn)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let value = script::get_script_by_id(db.conn(), "s1").unwrap();
|
||||||
|
assert_eq!(value.kind, ScriptKind::Utility);
|
||||||
|
assert_eq!(value.entrypoint, "render");
|
||||||
|
assert!(value.enabled);
|
||||||
|
assert_eq!(value.version, 1);
|
||||||
|
assert_eq!(value.status, ScriptStatus::Draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn template_defaults_match_spec() {
|
||||||
|
let db = memory_db();
|
||||||
|
project_queries::insert_project(db.conn(), &project("p1", "test")).unwrap();
|
||||||
|
db.conn()
|
||||||
|
.with(|conn| {
|
||||||
|
diesel::insert_into(templates::table)
|
||||||
|
.values((
|
||||||
|
templates::id.eq("t1"),
|
||||||
|
templates::project_id.eq("p1"),
|
||||||
|
templates::slug.eq("test"),
|
||||||
|
templates::title.eq("Test"),
|
||||||
|
templates::file_path.eq("templates/test.liquid"),
|
||||||
|
templates::created_at.eq(1000_i64),
|
||||||
|
templates::updated_at.eq(1000_i64),
|
||||||
|
))
|
||||||
|
.execute(conn)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let value = template::get_template_by_id(db.conn(), "t1").unwrap();
|
||||||
|
assert_eq!(value.kind, TemplateKind::Post);
|
||||||
|
assert!(value.enabled);
|
||||||
|
assert_eq!(value.version, 1);
|
||||||
|
assert_eq!(value.status, TemplateStatus::Draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tag_unique_name_per_project_enforced_case_insensitively() {
|
||||||
|
let db = memory_db();
|
||||||
|
project_queries::insert_project(db.conn(), &project("p1", "test")).unwrap();
|
||||||
|
let make_tag = |id: &str, name: &str| Tag {
|
||||||
|
id: id.into(),
|
||||||
|
project_id: "p1".into(),
|
||||||
|
name: name.into(),
|
||||||
|
color: None,
|
||||||
|
post_template_slug: None,
|
||||||
|
created_at: 1000,
|
||||||
|
updated_at: 1000,
|
||||||
|
};
|
||||||
|
tag::insert_tag(db.conn(), &make_tag("t1", "rust")).unwrap();
|
||||||
|
assert!(tag::insert_tag(db.conn(), &make_tag("t2", "RUST")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slug_generation_matches_spec() {
|
||||||
|
use bds_core::util::{ensure_unique, slugify};
|
||||||
|
assert_eq!(slugify("Hello World"), "hello-world");
|
||||||
|
assert_eq!(slugify("a --- b"), "a-b");
|
||||||
|
assert_eq!(slugify("---hello---"), "hello");
|
||||||
|
assert_eq!(slugify("café"), "cafe");
|
||||||
|
assert_eq!(slugify("über"), "ueber");
|
||||||
|
assert_eq!(slugify("Straße"), "strasse");
|
||||||
|
assert_eq!(ensure_unique("test", |value| value == "test"), "test-2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn published_paths_follow_layout() {
|
||||||
|
let db = fixture_db();
|
||||||
|
let active = project_queries::get_active_project(db.conn()).unwrap();
|
||||||
|
for post in post_queries::list_posts_by_project(db.conn(), &active.id)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|post| post.status == PostStatus::Published)
|
||||||
|
{
|
||||||
|
assert!(post.file_path.ends_with(&format!("{}.md", post.slug)));
|
||||||
|
assert!(post.file_path.contains("/posts/"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remaining_value_specs_match() {
|
||||||
|
use bds_core::model::{NotificationAction, NotificationEntity, SshMode};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
serde_json::to_string(&NotificationEntity::Post).unwrap(),
|
serde_json::to_string(&NotificationEntity::Post).unwrap(),
|
||||||
"\"post\""
|
"\"post\""
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_string(&NotificationEntity::Media).unwrap(),
|
|
||||||
"\"media\""
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_string(&NotificationEntity::Script).unwrap(),
|
|
||||||
"\"script\""
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_string(&NotificationEntity::Template).unwrap(),
|
|
||||||
"\"template\""
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_string(&NotificationAction::Created).unwrap(),
|
|
||||||
"\"created\""
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_string(&NotificationAction::Updated).unwrap(),
|
|
||||||
"\"updated\""
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
serde_json::to_string(&NotificationAction::Deleted).unwrap(),
|
serde_json::to_string(&NotificationAction::Deleted).unwrap(),
|
||||||
"\"deleted\""
|
"\"deleted\""
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// ── spec: generation.allium / publishing.allium — SshMode values ──
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ssh_mode_serde_matches_spec() {
|
|
||||||
use bds_core::model::SshMode;
|
|
||||||
|
|
||||||
assert_eq!(serde_json::to_string(&SshMode::Scp).unwrap(), "\"scp\"");
|
|
||||||
assert_eq!(serde_json::to_string(&SshMode::Rsync).unwrap(), "\"rsync\"");
|
assert_eq!(serde_json::to_string(&SshMode::Rsync).unwrap(), "\"rsync\"");
|
||||||
|
assert_eq!(["en", "de", "fr", "it", "es"].len(), 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── spec: i18n.allium — SplitLocalization ──
|
|
||||||
// Verify that the 5 supported UI languages exist as config
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn supported_languages_match_spec() {
|
|
||||||
// spec: en, de, fr, it, es — the 5 supported UI languages
|
|
||||||
let supported = ["en", "de", "fr", "it", "es"];
|
|
||||||
assert_eq!(supported.len(), 5);
|
|
||||||
// This test documents the spec constraint; actual locale loading
|
|
||||||
// will be tested when i18n module is implemented in M2+
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── spec: schema.allium — FTS5 virtual tables exist in fixture ──
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fts5_tables_exist_in_fixture() {
|
fn fts5_tables_exist_in_fixture() {
|
||||||
let conn = fixture_db();
|
let db = fixture_db();
|
||||||
|
assert!(bds_core::db::fts::tables_exist(db.conn()).unwrap());
|
||||||
// posts_fts
|
|
||||||
let result = conn.query_row(
|
|
||||||
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='posts_fts'",
|
|
||||||
[],
|
|
||||||
|r| r.get::<_, i64>(0),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
result.unwrap(),
|
|
||||||
1,
|
|
||||||
"spec: posts_fts virtual table must exist"
|
|
||||||
);
|
|
||||||
|
|
||||||
// media_fts
|
|
||||||
let result = conn.query_row(
|
|
||||||
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='media_fts'",
|
|
||||||
[],
|
|
||||||
|r| r.get::<_, i64>(0),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
result.unwrap(),
|
|
||||||
1,
|
|
||||||
"spec: media_fts virtual table must exist"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ chrono = { workspace = true }
|
|||||||
open = { workspace = true }
|
open = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
rusqlite = { workspace = true }
|
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
wry = "0.55"
|
wry = "0.55"
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
|
use bds_core::db::DbQueryError as SqlError;
|
||||||
use chrono::Datelike;
|
use chrono::Datelike;
|
||||||
use iced::{Element, Subscription, Task, window};
|
use iced::{Element, Subscription, Task, window};
|
||||||
use rusqlite::Error as SqlError;
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -297,7 +297,7 @@ fn persist_post_editor_preview_state_impl(
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(PersistedPostState::Translation(Box::new(translation)))
|
Ok(PersistedPostState::Translation(Box::new(translation)))
|
||||||
}
|
}
|
||||||
Err(SqlError::QueryReturnedNoRows) => {
|
Err(SqlError::NotFound) => {
|
||||||
let translation = PostTranslation {
|
let translation = PostTranslation {
|
||||||
id: Uuid::new_v4().to_string(),
|
id: Uuid::new_v4().to_string(),
|
||||||
project_id: post.project_id,
|
project_id: post.project_id,
|
||||||
@@ -342,7 +342,7 @@ fn persist_post_editor_preview_state_impl(
|
|||||||
state.slug
|
state.slug
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(_) | Err(SqlError::QueryReturnedNoRows) => {}
|
Ok(_) | Err(SqlError::NotFound) => {}
|
||||||
Err(error) => return Err(error.to_string()),
|
Err(error) => return Err(error.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1587,7 +1587,7 @@ impl BdsApp {
|
|||||||
let message = tw(
|
let message = tw(
|
||||||
self.ui_locale,
|
self.ui_locale,
|
||||||
"common.operationFailed",
|
"common.operationFailed",
|
||||||
&[("operation", &label), ("error", &err)],
|
&[("operation", &label), ("error", err)],
|
||||||
);
|
);
|
||||||
self.notify(ToastLevel::Error, &message);
|
self.notify(ToastLevel::Error, &message);
|
||||||
}
|
}
|
||||||
@@ -7061,7 +7061,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn setup() -> (Database, Project, TempDir) {
|
fn setup() -> (Database, Project, TempDir) {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
ensure_fts_tables(db.conn()).unwrap();
|
ensure_fts_tables(db.conn()).unwrap();
|
||||||
let project = make_project();
|
let project = make_project();
|
||||||
@@ -7215,7 +7215,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let published = post::publish_post(db.conn(), tmp.path(), &created.id).unwrap();
|
let published = post::publish_post(db.conn(), tmp.path(), &created.id).unwrap();
|
||||||
db.conn().execute("DROP TABLE posts_fts", []).unwrap();
|
bds_core::db::fts::drop_post_index(db.conn()).unwrap();
|
||||||
|
|
||||||
let mut editor_post =
|
let mut editor_post =
|
||||||
bds_core::db::queries::post::get_post_by_id(db.conn(), &published.id).unwrap();
|
bds_core::db::queries::post::get_post_by_id(db.conn(), &published.id).unwrap();
|
||||||
@@ -7275,7 +7275,7 @@ mod tests {
|
|||||||
Some("Inhalt"),
|
Some("Inhalt"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
db.conn().execute("DROP TABLE posts_fts", []).unwrap();
|
bds_core::db::fts::drop_post_index(db.conn()).unwrap();
|
||||||
|
|
||||||
let editor_post =
|
let editor_post =
|
||||||
bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap();
|
bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap();
|
||||||
@@ -7853,6 +7853,7 @@ mod tests {
|
|||||||
let mut second_post =
|
let mut second_post =
|
||||||
bds_core::db::queries::post::get_post_by_id(db.conn(), &second.id).unwrap();
|
bds_core::db::queries::post::get_post_by_id(db.conn(), &second.id).unwrap();
|
||||||
second_post.status = PostStatus::Published;
|
second_post.status = PostStatus::Published;
|
||||||
|
second_post.updated_at = first.updated_at + 1;
|
||||||
bds_core::db::queries::post::update_post(db.conn(), &second_post).unwrap();
|
bds_core::db::queries::post::update_post(db.conn(), &second_post).unwrap();
|
||||||
bds_core::engine::tag::discover_tags(db.conn(), tmp.path(), &project.id).unwrap();
|
bds_core::engine::tag::discover_tags(db.conn(), tmp.path(), &project.id).unwrap();
|
||||||
|
|
||||||
@@ -8050,7 +8051,7 @@ mod tests {
|
|||||||
fn query_sidebar_posts_filters_status_language_and_date_range() {
|
fn query_sidebar_posts_filters_status_language_and_date_range() {
|
||||||
let (_db, project, tmp) = setup();
|
let (_db, project, tmp) = setup();
|
||||||
let db_path = tmp.path().join("sidebar.db");
|
let db_path = tmp.path().join("sidebar.db");
|
||||||
let mut db = Database::open(&db_path).unwrap();
|
let db = Database::open(&db_path).unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
ensure_fts_tables(db.conn()).unwrap();
|
ensure_fts_tables(db.conn()).unwrap();
|
||||||
insert_project(db.conn(), &project).unwrap();
|
insert_project(db.conn(), &project).unwrap();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use bds_core::engine::project;
|
|||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
(db, dir)
|
(db, dir)
|
||||||
|
|||||||
8
diesel.toml
Normal file
8
diesel.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[migrations_directory]
|
||||||
|
dir = "crates/bds-core/migrations"
|
||||||
|
|
||||||
|
[print_schema]
|
||||||
|
file = "crates/bds-core/src/db/schema.rs"
|
||||||
|
patch_file = "crates/bds-core/src/db/schema.patch"
|
||||||
|
with_docs = false
|
||||||
|
filter = { except_tables = ["posts_fts", "media_fts"] }
|
||||||
@@ -48,5 +48,5 @@ Iced 0.13, wgpu renderer, muda for native menus, rfd for file dialogs.
|
|||||||
- Top-level app state lives in `BdsApp` struct (db connection, open buffers, UI flags).
|
- Top-level app state lives in `BdsApp` struct (db connection, open buffers, UI flags).
|
||||||
- Child widget state in `widget::tree::State` -- survives across `view()` calls as long as widget identity matches.
|
- Child widget state in `widget::tree::State` -- survives across `view()` calls as long as widget identity matches.
|
||||||
- `EditorBuffer` (rope-based text buffer via ropey) owned by `BdsApp`, passed as `&mut` to widget on `view()` and `on_event()`.
|
- `EditorBuffer` (rope-based text buffer via ropey) owned by `BdsApp`, passed as `&mut` to widget on `view()` and `on_event()`.
|
||||||
- Database connection (`rusqlite::Connection`) opened once at startup, held in `BdsApp`, not cloned.
|
- Database connection (`bds_core::db::Database`) opened once at startup, held in `BdsApp`, not cloned.
|
||||||
- No global mutable state -- everything flows through the `Message` -> `update()` -> `view()` cycle.
|
- No global mutable state -- everything flows through the `Message` -> `update()` -> `view()` cycle.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ sqlite3 "$FIXTURE_DB" "PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;"
|
|||||||
sqlite3 "$SRC_DB" ".schema" \
|
sqlite3 "$SRC_DB" ".schema" \
|
||||||
| grep -v "sqlite_sequence" \
|
| grep -v "sqlite_sequence" \
|
||||||
| grep -v "^CREATE TABLE IF NOT EXISTS \"refinery_schema_history\"" \
|
| grep -v "^CREATE TABLE IF NOT EXISTS \"refinery_schema_history\"" \
|
||||||
|
| sed '/^CREATE TABLE __diesel_schema_migrations/,/^);$/d' \
|
||||||
| sed '/^CREATE TABLE refinery_schema_history/,/^);$/d' \
|
| sed '/^CREATE TABLE refinery_schema_history/,/^);$/d' \
|
||||||
| sqlite3 "$FIXTURE_DB"
|
| sqlite3 "$FIXTURE_DB"
|
||||||
|
|
||||||
|
|||||||
@@ -729,8 +729,8 @@ value Fts5MediaSchema {
|
|||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
value MigrationVersion {
|
value MigrationVersion {
|
||||||
-- Schema version tracking via refinery migrations
|
-- Schema version tracking via embedded Diesel migrations
|
||||||
-- Current version: 0007 (scripts and templates draft lifecycle)
|
-- Current version: 20260718000000 (initial compatible schema baseline)
|
||||||
-- Migration files located in: migrations/
|
-- Migration directories located in: crates/bds-core/migrations/
|
||||||
-- Note: Migration list documented in comments, not as Allium value
|
-- Note: Migration list documented in comments, not as Allium value
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user