Replace literal SQL with Diesel abstractions

This commit is contained in:
2026-07-18 17:00:32 +02:00
parent a727c9073d
commit ca5eb4e1ac
69 changed files with 3508 additions and 4285 deletions

View File

@@ -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
View File

@@ -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",
] ]

View File

@@ -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"] }

View File

@@ -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`

View File

@@ -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"

View File

@@ -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)

View File

@@ -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
View File

@@ -0,0 +1,3 @@
fn main() {
println!("cargo:rerun-if-changed=migrations");
}

View File

View 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;

View File

@@ -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,

View File

@@ -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);

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View File

@@ -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();

View File

@@ -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

View File

@@ -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
} }

View File

@@ -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

View File

@@ -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
} }

View File

@@ -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
} }

View File

@@ -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

View File

@@ -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
} }

View File

@@ -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

View File

@@ -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

View 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,

View 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,
);

View File

@@ -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
} }

View File

@@ -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();

View File

@@ -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,
} }

View File

@@ -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)

View File

@@ -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;

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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(),

View File

@@ -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)

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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};

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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()))
);
}
} }

View File

@@ -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();

View File

@@ -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();

View File

@@ -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())

View 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);
}
}
}

View File

@@ -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"
);
} }

View File

@@ -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"

View File

@@ -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();

View File

@@ -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
View 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"] }

View File

@@ -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.

View File

@@ -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"

View File

@@ -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
} }