diff --git a/CLEANUP.md b/CLEANUP.md deleted file mode 100644 index e86d5b7..0000000 --- a/CLEANUP.md +++ /dev/null @@ -1,276 +0,0 @@ -# Cleanup Backlog - -Findings from a repo-wide audit (2026-07-18) for over-engineering, dead code, and -idiomatic-Rust issues beyond what clippy/rustfmt catch. Ordered by priority — -the first item is a real bug, the rest are cleanup. Each item is self-contained. - -Ground rules (from AGENTS.md): red/green TDD, run build + tests after each item, -remove unused code instead of keeping it, verify behaviour against the allium -specs in `specs/` and the Elixir baseline in `../bDS2` when in doubt. - -## 1. BUG: temp-file collision in atomic_write - -`crates/bds-core/src/util/atomic_write.rs` uses `path.with_extension("tmp")`, -which *replaces* the last extension. Sibling files that differ only in their -final extension map to the same temp path: - -- `esmeralda.en.md` → `esmeralda.en.tmp` -- `esmeralda.en.meta` → `esmeralda.en.tmp` - -Concurrent writes (e.g. publishing writes post file + sidecars in parallel) can -interleave on the shared temp file and corrupt or cross-write content. It also -clobbers any real file named `*.tmp`. - -**Fix:** append a unique suffix to the full filename instead of replacing the -extension, e.g. `format!("{}.{}.tmp", file_name, std::process::id())` or a -counter/uuid. Add a regression test writing `a.en.md` and `a.en.meta` -concurrently (or at least assert the two temp paths differ). - -## 2. Parallel record-struct layer in the DB glue (~450 lines) - -`crates/bds-core/src/db/from_row.rs` defines a `*Record` twin struct for all 12 -tables plus hand-written `From`/`TryFrom` conversions in both directions. Most -fields are copied 1:1; only a handful need conversion (status/kind enums, -`i32` bools, JSON string-lists). - -**Fix:** implement diesel `ToSql`/`FromSql` (or `serialize`/`deserialize` -wrappers) for `PostStatus`, `TemplateKind`, `TemplateStatus`, `ScriptKind`, -`ScriptStatus`, `NotificationEntity`, `NotificationAction`, plus a newtype for -JSON-serialized `Vec` (tags/categories) and bool-as-i32, then derive -`Queryable`/`Selectable`/`Insertable`/`AsChangeset` on the domain models in -`crates/bds-core/src/model/` directly. Delete the record structs and the -`convert`/`convert_all` helpers. Keep behaviour identical: tags serialize as -JSON arrays, unknown enum strings must still error, `treat_none_as_null` must -be preserved. - -## 3. Split the god file: bds-ui/src/app.rs (9,090 lines) - -`update()` alone spans ~2,250 lines (line 982–3238). The fix pattern already -exists in the same file: `handle_tags_msg` and `handle_settings_msg` delegate a -message sub-enum to a dedicated handler. - -**Fix:** apply the same pattern to the remaining domains — post editor, -media editor, template/script editors, preview/embedded webview, AI actions, -translation flows, sidebar/filtering. Move each handler group into its own -module (e.g. `crates/bds-ui/src/app/posts.rs`), keeping `Message` routing thin. -Pure refactor: no behaviour change, existing tests must stay green. - -## 4. Dead task machinery in engine/task.rs (~130 lines) - -Zero production call sites in the workspace for: - -- `ProgressThrottle` (whole struct + its 2 tests) — its logic is already - duplicated inside `TaskManager::report_progress` -- `TaskProgress` struct -- `try_start` -- `submit_grouped` and the `group_id`/`group_name` fields it fills (carried - through `TaskEntry` and `TaskSnapshot`, never read by the UI) -- `label()`, `message()` accessors -- `next_queued` (test-only) - -**Fix:** delete them and their tests. - -**Related wiring gap (do NOT delete, investigate):** `evict_expired()` and -`is_cancelled()` are also never called from production code. That means -finished tasks are never evicted at runtime and cooperative cancellation never -actually interrupts running work. Either wire them up in `bds-ui/src/app.rs` -(evict on the task-snapshot refresh tick; check `is_cancelled` inside -long-running engine loops) or confirm against the allium spec what the intended -behaviour is. - -## 5. Dead public functions (~250 lines) - -Zero call sites anywhere in the workspace (verified by sweep): - -- `crates/bds-core/src/engine/validate_translations.rs`: - `validate_translations` — only `validate_translations_with_progress` is used -- `crates/bds-core/src/engine/meta.rs`: `update_blog_languages`, - `update_project_metadata` -- `crates/bds-core/src/db/queries/generated_file_hash.rs`: - `delete_generated_file_hash`, `list_generated_file_hashes_by_project` -- `crates/bds-core/src/db/queries/post.rs`: `list_posts_by_project_limited` -- `crates/bds-core/src/db/queries/media.rs`: `list_media_by_project_limited` -- `crates/bds-core/src/db/queries/post_link.rs`: `list_post_backlinks`, - `list_post_outlinks` -- `crates/bds-core/src/db/from_row.rs`: `notification_entity_to_str`, - `notification_action_to_str` - -**Fix:** delete, along with any tests that exist only to exercise them. - -**Special case:** `crates/bds-core/src/db/fts.rs::search_media` has only -test callers. AGENTS.md requires functionality to be tied to UI — check the -allium spec: if media search is specced, wire it into the UI; if not, delete. - -## 6. Delete unused EngineContext - -`crates/bds-core/src/engine/context.rs` — a "shared context passed to engine -operations" that zero engine operations take (every engine fn takes -`conn`/`project_id`/`data_dir` individually). Its only test asserts that struct -fields hold assigned values. - -**Fix:** delete the file, its `pub use` in `crates/bds-core/src/engine/mod.rs`. - -## 7. Derive EngineError with thiserror - -`crates/bds-core/src/engine/error.rs` hand-writes `Display`, `Error::source`, -and four `From` impls (~85 lines). `DatabaseError` in -`crates/bds-core/src/db/connection.rs` already uses -`#[derive(thiserror::Error)]` — the crate is inconsistent. - -**Fix:** convert `EngineError` to a thiserror derive (`#[error(...)]`, -`#[from]`, `#[source]`). Keep the `From` mapping and the -reqwest/serde_json/serde_yaml → `Parse` conversions (thiserror `#[from]` can't -map two sources into one variant with `.to_string()`, so those three stay as -small manual impls or become dedicated variants). - -## 8. Single source of truth for status/kind enum strings - -`PostStatus` ↔ string mapping exists three times: - -1. serde `#[serde(rename_all = "lowercase")]` on the enum - (`crates/bds-core/src/model/post.rs`) -2. `post_status_to_str` / `post_status` in `crates/bds-core/src/db/from_row.rs` -3. the `serde_json::to_string(&status).trim_matches('"')` hack in - `crates/bds-core/src/util/frontmatter.rs` (`PostFrontmatter::from_post`, - `TranslationFrontmatter::from_translation`) - -**Fix:** give each enum (`PostStatus`, `TemplateKind`, `TemplateStatus`, -`ScriptKind`, `ScriptStatus`) an `as_str()` + `FromStr` impl next to its -definition, use it from the DB layer and frontmatter, delete the duplicates and -the serde_json hack. (Combines well with item 2.) - -## 9. Derive frontmatter deserialization (~120 lines) - -`crates/bds-core/src/util/frontmatter.rs`: the four `from_yaml` impls each -hand-roll near-identical `get_str`/`get_string_list` closures over -`serde_yaml::Value`. - -**Fix:** replace with `#[derive(Deserialize)]` + -`#[serde(rename_all = "camelCase")]` structs and one shared -`deserialize_with` helper for ISO-8601 → unix-ms timestamps. Preserve current -lenient behaviour (numbers/bools coerced to strings where fields expect -strings; missing optional fields default). - -**Do NOT touch the serialization side** (`to_yaml`): it is deliberately -hand-rolled for byte-identical golden output against the gray-matter format — -the `golden_output_*` tests assert exact equality with fixture files. - -## 10. Minor shrinks - -- `crates/bds-core/src/util/checksum.rs`: replace the inline `hex` module with - `bytes.iter().map(|b| format!("{b:02x}")).collect::()`. (Correctly - avoids adding a `hex` crate dep — keep it that way.) -- `crates/bds-core/src/i18n/mod.rs`: `RENDER_KEYS` hardcodes 34 keys that must - be kept in sync with the `.ftl` catalogs by hand. Enumerate the parsed - `FluentResource` entries of the English render catalog instead, so new keys - can't be silently missed. -- `crates/bds-core/src/i18n/mod.rs`: `locale_index` can be `locale as usize` - with explicit discriminants on `UiLocale`. - ---- - -# Test Suite Cleanup - -Findings from a full test-suite review (2026-07-18). The suite is largely -healthy: engine/query tests run against real in-memory SQLite, AI tests run the -real HTTP client against a local TCP test server, golden-output tests pin -byte-compatibility with the bDS2 baseline fixtures. No mock-object testing -anti-pattern exists. The problems are hot-air tests (assertions the compiler -already guarantees or that test a literal against itself) and duplication. - -## T1. Delete bds-ui/tests/app_smoke.rs entirely - -Every test in the file is compile-time noise: - -- `message_variants_constructable`, `new_message_variants_constructable` - (~80 lines): construct `Message` enum variants and discard them — the - compiler already guarantees this -- `message_clone_works`: tests `#[derive(Clone)]` -- `bds_app_type_is_public`: an inner function that is never called; purely a - type assertion with no runtime assertion - -The file's own header admits `BdsApp::new()` cannot be tested here. The real -smoke coverage lives in `app.rs`'s inline tests (which drive `update()` against -a real in-memory DB via `new_for_tests`). Delete the file. - -## T2. Hot air in bds-ui/tests/m2_validation.rs - -- `toast_level_variants`, `toast_preserves_message`: assert that a constructor - stores its arguments. Delete. -- `toast_ids_are_monotonically_increasing`, `fresh_toast_is_not_expired`: - duplicate the inline tests in `crates/bds-ui/src/state/toast.rs` - (`toast_ids_are_unique`, `fresh_toast_not_expired`). Keep one location - (suggest inline), delete the other. -- `menu_actions_that_need_project` / `menu_actions_that_need_tab` / - `menu_actions_gated_by_offline`: their own comment admits they don't test the - gating logic — they only re-verify i18n keys (already covered for ALL actions - in `menu_routing.rs::all_menu_actions_translate_in_all_locales`) and then - `assert_eq!(array.len(), N)` on the literal array declared five lines above. - Delete. **Real gap:** the actual enable/disable rules in - `BdsApp::sync_menu_state` (app.rs) are untested — write a test that drives - the real method (or extract its decision logic into a testable function). -- `accelerator_actions_match_spec`: asserts a locally declared array has 15 - elements and that i18n keys are non-empty; never touches the real - accelerator registration in `platform/menu.rs`. Delete, or rewrite to - inspect the actual menu construction. - -## T3. Hot air in bds-core/tests/spec_claims.rs - -- `remaining_value_specs_match` contains - `assert_eq!(["en", "de", "fr", "it", "es"].len(), 5)` — a literal tested - against itself. Drop that line (the serde asserts around it are fine). -- `slug_frozen_after_publish_semantics`: the name promises the slug-freeze - invariant; the body only asserts `published_at` is Some/None after insert - (already covered by roundtrip tests elsewhere). Either write the real test — - attempt a slug change on a published post through the engine API and assert - it's rejected — or delete. - -## T4. Vacuous inline tests in bds-core/src/i18n/mod.rs - -- `ui_locale_is_independent_type`: asserts `"de" != "fr"`. Delete. -- `translate_falls_back_to_english`: identical call and assertion as - `translate_menu_labels`, and it does NOT test fallback — the key it uses has - a German translation. Replace with a key that exists only in en.ftl (that - actually exercises `add_resource_overriding` fallback), or delete. -- `detect_os_locale_does_not_panic`: marginal smoke; keep or drop, zero cost. - -## T5. Tests that only exist to cover dead code - -Deleted together with their subjects (see items 4–6 above): - -- `engine/task.rs`: `progress_throttle_initial_reports`, - `progress_throttle_suppresses_rapid` (test the dead `ProgressThrottle`) -- `engine/context.rs`: `context_holds_references` (asserts struct fields hold - assigned values) -- `engine/error.rs`: `display_variants`, `from_io_error` become - derive-testing once EngineError moves to thiserror; delete with item 7 - -## T6. Change-detector test in bds-ui/tests/menu_routing.rs - -`menu_action_count_matches_spec` asserts `MenuAction::ALL.len() == 28`. It must -be hand-bumped on every menu change and only ever catches forgetting to update -itself; the useful properties (every action has a key, keys unique, all locales -translate) are covered by the other three tests in the file. Delete. - -## Explicitly fine (do not "clean up") - -- `tests/orm_boundary.rs`, `tests/i18n_completeness.rs`, - `tests/packaging_assets.rs`: lints-as-tests. They don't test runtime - behaviour but mechanically enforce AGENTS.md rules (no raw SQL outside the - backend boundary, no untranslated keys, packaging config integrity). Keep. -- `fixture_readability.rs::fixture_database_files_are_not_modified_by_tests`: - meta-test guarding fixture integrity for the whole compat suite. Keep. -- `m1_validation.rs`: the per-field diff-detection matrix looks repetitive but - each test pins one metadata-diff field required by the spec. Keep. -- AI engine tests spawn a real local TCP server — fake at the network - boundary, real production client code exercised. This is the right pattern. -- `util/timestamp.rs::now_is_recent` looks trivial but guards - milliseconds-vs-seconds confusion. Keep. - ---- - -## Explicitly out of scope - -- `crates/bds-cli` being a stub: intentional, part of upcoming extensions. -- The hand-rolled YAML *serialization* in frontmatter.rs and sidecar.rs: - required for byte-compatibility with the baseline output. diff --git a/Cargo.lock b/Cargo.lock index 69fe678..323b9a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -802,6 +802,7 @@ dependencies = [ "diesel", "diesel_migrations", "fluent-bundle", + "fluent-syntax", "image 0.25.10", "keyring", "libsqlite3-sys", diff --git a/Cargo.toml b/Cargo.toml index 4748460..0e6317d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ image = "0.25" rust-stemmers = "1" sys-locale = "0.3" fluent-bundle = "0.16" +fluent-syntax = "0.12" dirs = "5" open = "5" pulldown-cmark = "0.13" diff --git a/crates/bds-core/Cargo.toml b/crates/bds-core/Cargo.toml index 0641619..cf4ba34 100644 --- a/crates/bds-core/Cargo.toml +++ b/crates/bds-core/Cargo.toml @@ -18,6 +18,7 @@ image = { workspace = true } rust-stemmers = { workspace = true } sys-locale = { workspace = true } fluent-bundle = { workspace = true } +fluent-syntax = { workspace = true } pulldown-cmark = { workspace = true } liquid = { workspace = true } liquid-core = { workspace = true } diff --git a/crates/bds-core/src/db/from_row.rs b/crates/bds-core/src/db/from_row.rs deleted file mode 100644 index c55f32c..0000000 --- a/crates/bds-core/src/db/from_row.rs +++ /dev/null @@ -1,759 +0,0 @@ -use diesel::prelude::*; -use diesel::sqlite::Sqlite; - -use crate::db::schema; -use crate::model::{ - DbNotification, GeneratedFileHash, Media, MediaTranslation, NotificationAction, - NotificationEntity, Post, PostLink, PostMedia, PostStatus, PostTranslation, Project, Script, - ScriptKind, ScriptStatus, Setting, Tag, Template, TemplateKind, TemplateStatus, -}; - -type ConversionError = Box; - -fn invalid_value(kind: &str, value: &str) -> ConversionError { - format!("invalid {kind}: {value}").into() -} - -fn json_strings(value: &str) -> Result, ConversionError> { - Ok(serde_json::from_str(value)?) -} - -pub fn post_status_to_str(value: &PostStatus) -> &'static str { - match value { - PostStatus::Draft => "draft", - PostStatus::Published => "published", - PostStatus::Archived => "archived", - } -} - -fn post_status(value: &str) -> Result { - match value { - "draft" => Ok(PostStatus::Draft), - "published" => Ok(PostStatus::Published), - "archived" => Ok(PostStatus::Archived), - _ => Err(invalid_value("PostStatus", value)), - } -} - -pub fn template_kind_to_str(value: &TemplateKind) -> &'static str { - match value { - TemplateKind::Post => "post", - TemplateKind::List => "list", - TemplateKind::NotFound => "not_found", - TemplateKind::Partial => "partial", - } -} - -fn template_kind(value: &str) -> Result { - match value { - "post" => Ok(TemplateKind::Post), - "list" => Ok(TemplateKind::List), - "not_found" => Ok(TemplateKind::NotFound), - "partial" => Ok(TemplateKind::Partial), - _ => Err(invalid_value("TemplateKind", value)), - } -} - -pub fn template_status_to_str(value: &TemplateStatus) -> &'static str { - match value { - TemplateStatus::Draft => "draft", - TemplateStatus::Published => "published", - } -} - -fn template_status(value: &str) -> Result { - match value { - "draft" => Ok(TemplateStatus::Draft), - "published" => Ok(TemplateStatus::Published), - _ => Err(invalid_value("TemplateStatus", value)), - } -} - -pub fn script_kind_to_str(value: &ScriptKind) -> &'static str { - match value { - ScriptKind::Macro => "macro", - ScriptKind::Utility => "utility", - ScriptKind::Transform => "transform", - } -} - -fn script_kind(value: &str) -> Result { - match value { - "macro" => Ok(ScriptKind::Macro), - "utility" => Ok(ScriptKind::Utility), - "transform" => Ok(ScriptKind::Transform), - _ => Err(invalid_value("ScriptKind", value)), - } -} - -pub fn script_status_to_str(value: &ScriptStatus) -> &'static str { - match value { - ScriptStatus::Draft => "draft", - ScriptStatus::Published => "published", - } -} - -fn script_status(value: &str) -> Result { - match value { - "draft" => Ok(ScriptStatus::Draft), - "published" => Ok(ScriptStatus::Published), - _ => Err(invalid_value("ScriptStatus", value)), - } -} - -pub fn notification_entity_to_str(value: &NotificationEntity) -> &'static str { - match value { - NotificationEntity::Post => "post", - NotificationEntity::Media => "media", - NotificationEntity::Script => "script", - NotificationEntity::Template => "template", - } -} - -fn notification_entity(value: &str) -> Result { - match value { - "post" => Ok(NotificationEntity::Post), - "media" => Ok(NotificationEntity::Media), - "script" => Ok(NotificationEntity::Script), - "template" => Ok(NotificationEntity::Template), - _ => Err(invalid_value("NotificationEntity", value)), - } -} - -pub fn notification_action_to_str(value: &NotificationAction) -> &'static str { - match value { - NotificationAction::Created => "created", - NotificationAction::Updated => "updated", - NotificationAction::Deleted => "deleted", - } -} - -fn notification_action(value: &str) -> Result { - match value { - "created" => Ok(NotificationAction::Created), - "updated" => Ok(NotificationAction::Updated), - "deleted" => Ok(NotificationAction::Deleted), - _ => Err(invalid_value("NotificationAction", value)), - } -} - -#[derive(Queryable, Selectable, Insertable, AsChangeset)] -#[diesel(table_name = schema::projects, check_for_backend(Sqlite))] -#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)] -pub struct ProjectRecord { - pub id: String, - pub name: String, - pub slug: String, - pub description: Option, - pub data_path: Option, - pub is_active: i32, - pub created_at: i64, - pub updated_at: i64, -} - -impl From<&Project> for ProjectRecord { - fn from(value: &Project) -> Self { - Self { - id: value.id.clone(), - name: value.name.clone(), - slug: value.slug.clone(), - description: value.description.clone(), - data_path: value.data_path.clone(), - is_active: value.is_active as i32, - created_at: value.created_at, - updated_at: value.updated_at, - } - } -} - -impl From for Project { - fn from(value: ProjectRecord) -> Self { - Self { - id: value.id, - name: value.name, - slug: value.slug, - description: value.description, - data_path: value.data_path, - is_active: value.is_active != 0, - created_at: value.created_at, - updated_at: value.updated_at, - } - } -} - -#[derive(Queryable, Selectable, Insertable, AsChangeset)] -#[diesel(table_name = schema::posts, check_for_backend(Sqlite))] -#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)] -pub struct PostRecord { - pub id: String, - pub project_id: String, - pub title: String, - pub slug: String, - pub excerpt: Option, - pub content: Option, - pub status: String, - pub author: Option, - pub created_at: i64, - pub updated_at: i64, - pub published_at: Option, - pub file_path: String, - pub checksum: Option, - pub tags: String, - pub categories: String, - pub template_slug: Option, - pub language: Option, - pub do_not_translate: i32, - pub published_title: Option, - pub published_content: Option, - pub published_tags: Option, - pub published_categories: Option, - pub published_excerpt: Option, -} - -impl From<&Post> for PostRecord { - fn from(value: &Post) -> Self { - Self { - id: value.id.clone(), - project_id: value.project_id.clone(), - title: value.title.clone(), - slug: value.slug.clone(), - excerpt: value.excerpt.clone(), - content: value.content.clone(), - status: post_status_to_str(&value.status).into(), - author: value.author.clone(), - created_at: value.created_at, - updated_at: value.updated_at, - published_at: value.published_at, - file_path: value.file_path.clone(), - checksum: value.checksum.clone(), - tags: serde_json::to_string(&value.tags).unwrap_or_else(|_| "[]".into()), - categories: serde_json::to_string(&value.categories).unwrap_or_else(|_| "[]".into()), - template_slug: value.template_slug.clone(), - language: value.language.clone(), - do_not_translate: value.do_not_translate as i32, - published_title: value.published_title.clone(), - published_content: value.published_content.clone(), - published_tags: value.published_tags.clone(), - published_categories: value.published_categories.clone(), - published_excerpt: value.published_excerpt.clone(), - } - } -} - -impl TryFrom for Post { - type Error = ConversionError; - fn try_from(value: PostRecord) -> Result { - Ok(Self { - id: value.id, - project_id: value.project_id, - title: value.title, - slug: value.slug, - excerpt: value.excerpt, - content: value.content, - status: post_status(&value.status)?, - author: value.author, - language: value.language, - do_not_translate: value.do_not_translate != 0, - template_slug: value.template_slug, - file_path: value.file_path, - checksum: value.checksum, - tags: json_strings(&value.tags)?, - categories: json_strings(&value.categories)?, - published_title: value.published_title, - published_content: value.published_content, - published_tags: value.published_tags, - published_categories: value.published_categories, - published_excerpt: value.published_excerpt, - created_at: value.created_at, - updated_at: value.updated_at, - published_at: value.published_at, - }) - } -} - -#[derive(Queryable, Selectable, Insertable, AsChangeset)] -#[diesel(table_name = schema::post_translations, check_for_backend(Sqlite))] -#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)] -pub struct PostTranslationRecord { - pub id: String, - pub project_id: String, - pub translation_for: String, - pub language: String, - pub title: String, - pub excerpt: Option, - pub content: Option, - pub status: String, - pub created_at: i64, - pub updated_at: i64, - pub published_at: Option, - pub file_path: String, - pub checksum: Option, -} - -impl From<&PostTranslation> for PostTranslationRecord { - fn from(v: &PostTranslation) -> Self { - Self { - id: v.id.clone(), - project_id: v.project_id.clone(), - translation_for: v.translation_for.clone(), - language: v.language.clone(), - title: v.title.clone(), - excerpt: v.excerpt.clone(), - content: v.content.clone(), - status: post_status_to_str(&v.status).into(), - created_at: v.created_at, - updated_at: v.updated_at, - published_at: v.published_at, - file_path: v.file_path.clone(), - checksum: v.checksum.clone(), - } - } -} -impl TryFrom for PostTranslation { - type Error = ConversionError; - fn try_from(v: PostTranslationRecord) -> Result { - Ok(Self { - id: v.id, - project_id: v.project_id, - translation_for: v.translation_for, - language: v.language, - title: v.title, - excerpt: v.excerpt, - content: v.content, - status: post_status(&v.status)?, - file_path: v.file_path, - checksum: v.checksum, - created_at: v.created_at, - updated_at: v.updated_at, - published_at: v.published_at, - }) - } -} - -#[derive(Queryable, Selectable, Insertable)] -#[diesel(table_name = schema::post_links, check_for_backend(Sqlite))] -pub struct PostLinkRecord { - pub id: String, - pub source_post_id: String, - pub target_post_id: String, - pub link_text: Option, - pub created_at: i64, -} -impl From<&PostLink> for PostLinkRecord { - fn from(v: &PostLink) -> Self { - Self { - id: v.id.clone(), - source_post_id: v.source_post_id.clone(), - target_post_id: v.target_post_id.clone(), - link_text: v.link_text.clone(), - created_at: v.created_at, - } - } -} -impl From for PostLink { - fn from(v: PostLinkRecord) -> Self { - Self { - id: v.id, - source_post_id: v.source_post_id, - target_post_id: v.target_post_id, - link_text: v.link_text, - created_at: v.created_at, - } - } -} - -#[derive(Queryable, Selectable, Insertable)] -#[diesel(table_name = schema::post_media, check_for_backend(Sqlite))] -pub struct PostMediaRecord { - pub id: String, - pub project_id: String, - pub post_id: String, - pub media_id: String, - pub sort_order: i32, - pub created_at: i64, -} -impl From<&PostMedia> for PostMediaRecord { - fn from(v: &PostMedia) -> Self { - Self { - id: v.id.clone(), - project_id: v.project_id.clone(), - post_id: v.post_id.clone(), - media_id: v.media_id.clone(), - sort_order: v.sort_order, - created_at: v.created_at, - } - } -} -impl From for PostMedia { - fn from(v: PostMediaRecord) -> Self { - Self { - id: v.id, - project_id: v.project_id, - post_id: v.post_id, - media_id: v.media_id, - sort_order: v.sort_order, - created_at: v.created_at, - } - } -} - -#[derive(Queryable, Selectable, Insertable, AsChangeset)] -#[diesel(table_name = schema::media, check_for_backend(Sqlite))] -#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)] -pub struct MediaRecord { - pub id: String, - pub project_id: String, - pub filename: String, - pub original_name: String, - pub mime_type: String, - pub size: i64, - pub width: Option, - pub height: Option, - pub title: Option, - pub alt: Option, - pub caption: Option, - pub author: Option, - pub file_path: String, - pub sidecar_path: String, - pub created_at: i64, - pub updated_at: i64, - pub checksum: Option, - pub tags: String, - pub language: Option, -} -impl From<&Media> for MediaRecord { - fn from(v: &Media) -> Self { - Self { - id: v.id.clone(), - project_id: v.project_id.clone(), - filename: v.filename.clone(), - original_name: v.original_name.clone(), - mime_type: v.mime_type.clone(), - size: v.size, - width: v.width, - height: v.height, - title: v.title.clone(), - alt: v.alt.clone(), - caption: v.caption.clone(), - author: v.author.clone(), - file_path: v.file_path.clone(), - sidecar_path: v.sidecar_path.clone(), - created_at: v.created_at, - updated_at: v.updated_at, - checksum: v.checksum.clone(), - tags: serde_json::to_string(&v.tags).unwrap_or_else(|_| "[]".into()), - language: v.language.clone(), - } - } -} -impl TryFrom for Media { - type Error = ConversionError; - fn try_from(v: MediaRecord) -> Result { - Ok(Self { - id: v.id, - project_id: v.project_id, - filename: v.filename, - original_name: v.original_name, - mime_type: v.mime_type, - size: v.size, - width: v.width, - height: v.height, - title: v.title, - alt: v.alt, - caption: v.caption, - author: v.author, - language: v.language, - file_path: v.file_path, - sidecar_path: v.sidecar_path, - checksum: v.checksum, - tags: json_strings(&v.tags)?, - created_at: v.created_at, - updated_at: v.updated_at, - }) - } -} - -#[derive(Queryable, Selectable, Insertable, AsChangeset)] -#[diesel(table_name = schema::media_translations, check_for_backend(Sqlite))] -#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)] -pub struct MediaTranslationRecord { - pub id: String, - pub project_id: String, - pub translation_for: String, - pub language: String, - pub title: Option, - pub alt: Option, - pub caption: Option, - pub created_at: i64, - pub updated_at: i64, -} -impl From<&MediaTranslation> for MediaTranslationRecord { - fn from(v: &MediaTranslation) -> Self { - Self { - id: v.id.clone(), - project_id: v.project_id.clone(), - translation_for: v.translation_for.clone(), - language: v.language.clone(), - title: v.title.clone(), - alt: v.alt.clone(), - caption: v.caption.clone(), - created_at: v.created_at, - updated_at: v.updated_at, - } - } -} -impl From for MediaTranslation { - fn from(v: MediaTranslationRecord) -> Self { - Self { - id: v.id, - project_id: v.project_id, - translation_for: v.translation_for, - language: v.language, - title: v.title, - alt: v.alt, - caption: v.caption, - created_at: v.created_at, - updated_at: v.updated_at, - } - } -} - -#[derive(Queryable, Selectable, Insertable, AsChangeset)] -#[diesel(table_name = schema::tags, check_for_backend(Sqlite))] -#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)] -pub struct TagRecord { - pub id: String, - pub project_id: String, - pub name: String, - pub color: Option, - pub post_template_slug: Option, - pub created_at: i64, - pub updated_at: i64, -} -impl From<&Tag> for TagRecord { - fn from(v: &Tag) -> Self { - Self { - id: v.id.clone(), - project_id: v.project_id.clone(), - name: v.name.clone(), - color: v.color.clone(), - post_template_slug: v.post_template_slug.clone(), - created_at: v.created_at, - updated_at: v.updated_at, - } - } -} -impl From for Tag { - fn from(v: TagRecord) -> Self { - Self { - id: v.id, - project_id: v.project_id, - name: v.name, - color: v.color, - post_template_slug: v.post_template_slug, - created_at: v.created_at, - updated_at: v.updated_at, - } - } -} - -#[derive(Queryable, Selectable, Insertable, AsChangeset)] -#[diesel(table_name = schema::templates, check_for_backend(Sqlite))] -#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)] -pub struct TemplateRecord { - pub id: String, - pub project_id: String, - pub slug: String, - pub title: String, - pub kind: String, - pub enabled: i32, - pub version: i32, - pub file_path: String, - pub status: String, - pub content: Option, - pub created_at: i64, - pub updated_at: i64, -} -impl From<&Template> for TemplateRecord { - fn from(v: &Template) -> Self { - Self { - id: v.id.clone(), - project_id: v.project_id.clone(), - slug: v.slug.clone(), - title: v.title.clone(), - kind: template_kind_to_str(&v.kind).into(), - enabled: v.enabled as i32, - version: v.version, - file_path: v.file_path.clone(), - status: template_status_to_str(&v.status).into(), - content: v.content.clone(), - created_at: v.created_at, - updated_at: v.updated_at, - } - } -} -impl TryFrom for Template { - type Error = ConversionError; - fn try_from(v: TemplateRecord) -> Result { - Ok(Self { - id: v.id, - project_id: v.project_id, - slug: v.slug, - title: v.title, - kind: template_kind(&v.kind)?, - enabled: v.enabled != 0, - version: v.version, - file_path: v.file_path, - status: template_status(&v.status)?, - content: v.content, - created_at: v.created_at, - updated_at: v.updated_at, - }) - } -} - -#[derive(Queryable, Selectable, Insertable, AsChangeset)] -#[diesel(table_name = schema::scripts, check_for_backend(Sqlite))] -#[diesel(treat_none_as_default_value = false, treat_none_as_null = true)] -pub struct ScriptRecord { - pub id: String, - pub project_id: String, - pub slug: String, - pub title: String, - pub kind: String, - pub entrypoint: String, - pub enabled: i32, - pub version: i32, - pub file_path: String, - pub status: String, - pub content: Option, - pub created_at: i64, - pub updated_at: i64, -} -impl From<&Script> for ScriptRecord { - fn from(v: &Script) -> Self { - Self { - id: v.id.clone(), - project_id: v.project_id.clone(), - slug: v.slug.clone(), - title: v.title.clone(), - kind: script_kind_to_str(&v.kind).into(), - entrypoint: v.entrypoint.clone(), - enabled: v.enabled as i32, - version: v.version, - file_path: v.file_path.clone(), - status: script_status_to_str(&v.status).into(), - content: v.content.clone(), - created_at: v.created_at, - updated_at: v.updated_at, - } - } -} -impl TryFrom for Script { - type Error = ConversionError; - fn try_from(v: ScriptRecord) -> Result { - Ok(Self { - id: v.id, - project_id: v.project_id, - slug: v.slug, - title: v.title, - kind: script_kind(&v.kind)?, - entrypoint: v.entrypoint, - enabled: v.enabled != 0, - version: v.version, - file_path: v.file_path, - status: script_status(&v.status)?, - content: v.content, - created_at: v.created_at, - updated_at: v.updated_at, - }) - } -} - -#[derive(Queryable, Selectable, Insertable, AsChangeset)] -#[diesel(table_name = schema::settings, check_for_backend(Sqlite))] -pub struct SettingRecord { - pub key: String, - pub value: String, - pub updated_at: i64, -} -impl From for Setting { - fn from(v: SettingRecord) -> Self { - Self { - key: v.key, - value: v.value, - updated_at: v.updated_at, - } - } -} - -#[derive(Queryable, Selectable, Insertable, AsChangeset)] -#[diesel(table_name = schema::generated_file_hashes, check_for_backend(Sqlite))] -pub struct GeneratedFileHashRecord { - pub project_id: String, - pub relative_path: String, - pub content_hash: String, - pub updated_at: i64, -} -impl From<&GeneratedFileHash> for GeneratedFileHashRecord { - fn from(v: &GeneratedFileHash) -> Self { - Self { - project_id: v.project_id.clone(), - relative_path: v.relative_path.clone(), - content_hash: v.content_hash.clone(), - updated_at: v.updated_at, - } - } -} -impl From for GeneratedFileHash { - fn from(v: GeneratedFileHashRecord) -> Self { - Self { - project_id: v.project_id, - relative_path: v.relative_path, - content_hash: v.content_hash, - updated_at: v.updated_at, - } - } -} - -#[derive(Queryable, Selectable)] -#[diesel(table_name = schema::db_notifications, check_for_backend(Sqlite))] -pub struct DbNotificationRecord { - pub id: i32, - pub entity_type: String, - pub entity_id: String, - pub action: String, - pub from_cli: i32, - pub seen_at: Option, - pub created_at: i64, -} -impl TryFrom for DbNotification { - type Error = ConversionError; - fn try_from(v: DbNotificationRecord) -> Result { - Ok(Self { - id: i64::from(v.id), - entity_type: notification_entity(&v.entity_type)?, - entity_id: v.entity_id, - action: notification_action(&v.action)?, - from_cli: v.from_cli != 0, - seen_at: v.seen_at, - created_at: v.created_at, - }) - } -} - -pub(crate) fn convert(value: T) -> diesel::QueryResult -where - U: TryFrom, -{ - value - .try_into() - .map_err(diesel::result::Error::DeserializationError) -} - -pub(crate) fn convert_all(values: Vec) -> diesel::QueryResult> -where - U: TryFrom, -{ - values.into_iter().map(convert).collect() -} diff --git a/crates/bds-core/src/db/fts.rs b/crates/bds-core/src/db/fts.rs index dc34667..da487dc 100644 --- a/crates/bds-core/src/db/fts.rs +++ b/crates/bds-core/src/db/fts.rs @@ -5,7 +5,7 @@ use diesel::sqlite::Sqlite; use rust_stemmers::{Algorithm, Stemmer}; use crate::db::DbConnection as Connection; -use crate::db::schema::{post_translations, posts}; +use crate::db::schema::{media, post_translations, posts}; use crate::util::calendar_range_unix_ms; diesel::define_sql_function!(fn instr(haystack: Text, needle: Text) -> diesel::sql_types::Integer); @@ -457,11 +457,92 @@ pub fn search_media(conn: &Connection, query: &str, language: &str) -> QueryResu }) } +#[derive(Default)] +pub struct MediaSearchFilters<'a> { + pub project_id: Option<&'a str>, + pub tags: Option<&'a [String]>, + pub year: Option, + pub month: Option, + pub limit: Option, + pub offset: Option, +} + +#[derive(Debug)] +pub struct MediaSearchResults { + pub media_ids: Vec, + pub total: usize, + pub offset: usize, + pub limit: usize, +} + +pub fn search_media_filtered( + conn: &Connection, + query: &str, + language: &str, + filters: &MediaSearchFilters<'_>, +) -> QueryResult { + let ranked_ids = search_media(conn, query, language)?; + let offset = filters.offset.unwrap_or(0); + if ranked_ids.is_empty() { + return Ok(MediaSearchResults { + media_ids: Vec::new(), + total: 0, + offset, + limit: filters.limit.unwrap_or(0), + }); + } + + let matching_ids = conn.with(|connection| { + let mut filtered = media::table + .filter(media::id.eq_any(&ranked_ids)) + .into_boxed(); + if let Some(project_id) = filters.project_id { + filtered = filtered.filter(media::project_id.eq(project_id)); + } + 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()) + })?; + filtered = filtered.filter(media::created_at.ge(start).and(media::created_at.lt(end))); + } + if let Some(tags) = filters.tags { + for tag in tags { + filtered = filtered.filter( + instr( + lower(media::tags), + serde_json::to_string(&tag.to_lowercase()).unwrap(), + ) + .gt(0), + ); + } + } + filtered.select(media::id).load::(connection) + })?; + let matching_ids = matching_ids + .into_iter() + .collect::>(); + let mut media_ids = ranked_ids + .into_iter() + .filter(|id| matching_ids.contains(id)) + .collect::>(); + let total = media_ids.len(); + let limit = filters.limit.unwrap_or(total); + media_ids = media_ids.into_iter().skip(offset).take(limit).collect(); + Ok(MediaSearchResults { + media_ids, + total, + offset, + limit, + }) +} + #[cfg(test)] mod tests { use super::*; use crate::db::Database; + 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::model::PostStatus; fn setup() -> Database { @@ -652,6 +733,50 @@ mod tests { assert_eq!(results, vec!["media-1"]); } + #[test] + fn filtered_media_search_honors_project_tags_and_pagination() { + let db = setup(); + insert_project(db.conn(), &make_test_project("p1", "one")).unwrap(); + insert_project(db.conn(), &make_test_project("p2", "two")).unwrap(); + for (id, project, tags) in [ + ("m1", "p1", vec!["nature".to_string()]), + ("m2", "p1", vec!["city".to_string()]), + ("m3", "p2", vec!["nature".to_string()]), + ] { + let mut media = make_test_media(id, project); + media.tags = tags.clone(); + insert_media(db.conn(), &media).unwrap(); + index_media( + db.conn(), + id, + Some("Shared sunset"), + None, + None, + &media.original_name, + &tags, + &[], + "en", + ) + .unwrap(); + } + + let tags = vec!["nature".to_string()]; + let results = search_media_filtered( + db.conn(), + "sunset", + "en", + &MediaSearchFilters { + project_id: Some("p1"), + tags: Some(&tags), + limit: Some(1), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(results.total, 1); + assert_eq!(results.media_ids, ["m1"]); + } + #[test] fn search_no_results() { let db = setup(); diff --git a/crates/bds-core/src/db/mod.rs b/crates/bds-core/src/db/mod.rs index 40865c0..c2f0435 100644 --- a/crates/bds-core/src/db/mod.rs +++ b/crates/bds-core/src/db/mod.rs @@ -1,9 +1,10 @@ mod connection; -pub mod from_row; pub mod fts; mod migrations; pub mod queries; pub mod schema; +#[doc(hidden)] +pub mod types; pub use connection::{Database, DatabaseError, DbConnection}; pub use diesel::result::Error as DbQueryError; diff --git a/crates/bds-core/src/db/queries/generated_file_hash.rs b/crates/bds-core/src/db/queries/generated_file_hash.rs index 73aaaca..040f38e 100644 --- a/crates/bds-core/src/db/queries/generated_file_hash.rs +++ b/crates/bds-core/src/db/queries/generated_file_hash.rs @@ -1,7 +1,6 @@ use diesel::prelude::*; use crate::db::DbConnection; -use crate::db::from_row::GeneratedFileHashRecord; use crate::db::schema::generated_file_hashes; use crate::model::GeneratedFileHash; @@ -14,9 +13,8 @@ pub fn get_generated_file_hash( generated_file_hashes::table .filter(generated_file_hashes::project_id.eq(project_id)) .filter(generated_file_hashes::relative_path.eq(relative_path)) - .select(GeneratedFileHashRecord::as_select()) + .select(GeneratedFileHash::as_select()) .first(c) - .map(Into::into) }) } @@ -26,7 +24,7 @@ pub fn upsert_generated_file_hash( ) -> QueryResult<()> { conn.with(|c| { diesel::insert_into(generated_file_hashes::table) - .values(GeneratedFileHashRecord::from(hash)) + .values(hash.clone()) .on_conflict(( generated_file_hashes::project_id, generated_file_hashes::relative_path, @@ -41,36 +39,6 @@ pub fn upsert_generated_file_hash( }) } -pub fn delete_generated_file_hash( - conn: &DbConnection, - project_id: &str, - relative_path: &str, -) -> QueryResult<()> { - conn.with(|c| { - diesel::delete( - generated_file_hashes::table - .filter(generated_file_hashes::project_id.eq(project_id)) - .filter(generated_file_hashes::relative_path.eq(relative_path)), - ) - .execute(c) - .map(|_| ()) - }) -} - -pub fn list_generated_file_hashes_by_project( - conn: &DbConnection, - project_id: &str, -) -> QueryResult> { - conn.with(|c| { - generated_file_hashes::table - .filter(generated_file_hashes::project_id.eq(project_id)) - .order(generated_file_hashes::relative_path) - .select(GeneratedFileHashRecord::as_select()) - .load(c) - .map(|rows: Vec| rows.into_iter().map(Into::into).collect()) - }) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/bds-core/src/db/queries/media.rs b/crates/bds-core/src/db/queries/media.rs index 19553fb..73a03e8 100644 --- a/crates/bds-core/src/db/queries/media.rs +++ b/crates/bds-core/src/db/queries/media.rs @@ -3,7 +3,6 @@ use diesel::prelude::*; use diesel::sql_types::Text; use crate::db::DbConnection; -use crate::db::from_row::{MediaRecord, convert, convert_all}; use crate::db::schema::media; use crate::model::Media; use crate::util::calendar_range_unix_ms; @@ -13,7 +12,7 @@ diesel::define_sql_function!(fn instr(haystack: Text, needle: Text) -> Integer); pub fn insert_media(conn: &DbConnection, m: &Media) -> QueryResult<()> { conn.with(|c| { diesel::insert_into(media::table) - .values(MediaRecord::from(m)) + .values(m.clone()) .execute(c) .map(|_| ()) }) @@ -23,9 +22,8 @@ pub fn get_media_by_id(conn: &DbConnection, id: &str) -> QueryResult { conn.with(|c| { media::table .filter(media::id.eq(id)) - .select(MediaRecord::as_select()) + .select(Media::as_select()) .first(c) - .and_then(convert) }) } @@ -34,27 +32,8 @@ pub fn list_media_by_project(conn: &DbConnection, project_id: &str) -> QueryResu media::table .filter(media::project_id.eq(project_id)) .order(media::created_at.desc()) - .select(MediaRecord::as_select()) + .select(Media::as_select()) .load(c) - .and_then(convert_all) - }) -} - -pub fn list_media_by_project_limited( - conn: &DbConnection, - project_id: &str, - limit: i64, - offset: i64, -) -> QueryResult> { - conn.with(|c| { - media::table - .filter(media::project_id.eq(project_id)) - .order(media::created_at.desc()) - .limit(limit) - .offset(offset) - .select(MediaRecord::as_select()) - .load(c) - .and_then(convert_all) }) } @@ -70,7 +49,7 @@ pub fn count_media_by_project(conn: &DbConnection, project_id: &str) -> QueryRes pub fn update_media(conn: &DbConnection, m: &Media) -> QueryResult<()> { conn.with(|c| { diesel::update(media::table.filter(media::id.eq(&m.id))) - .set(MediaRecord::from(m)) + .set(m.clone()) .execute(c) .map(|_| ()) }) @@ -140,9 +119,8 @@ pub fn list_media_filtered( .order(media::created_at.desc()) .limit(limit) .offset(offset) - .select(MediaRecord::as_select()) + .select(Media::as_select()) .load(c) - .and_then(convert_all) }) } diff --git a/crates/bds-core/src/db/queries/media_translation.rs b/crates/bds-core/src/db/queries/media_translation.rs index da075a8..2b6838a 100644 --- a/crates/bds-core/src/db/queries/media_translation.rs +++ b/crates/bds-core/src/db/queries/media_translation.rs @@ -1,14 +1,13 @@ use diesel::prelude::*; use crate::db::DbConnection; -use crate::db::from_row::MediaTranslationRecord; use crate::db::schema::media_translations; use crate::model::MediaTranslation; pub fn insert_media_translation(conn: &DbConnection, t: &MediaTranslation) -> QueryResult<()> { conn.with(|c| { diesel::insert_into(media_translations::table) - .values(MediaTranslationRecord::from(t)) + .values(t.clone()) .execute(c) .map(|_| ()) }) @@ -23,9 +22,8 @@ pub fn get_media_translation_by_media_and_language( media_translations::table .filter(media_translations::translation_for.eq(translation_for)) .filter(media_translations::language.eq(language)) - .select(MediaTranslationRecord::as_select()) + .select(MediaTranslation::as_select()) .first(c) - .map(Into::into) }) } @@ -37,16 +35,15 @@ pub fn list_media_translations_by_media( media_translations::table .filter(media_translations::translation_for.eq(translation_for)) .order(media_translations::language) - .select(MediaTranslationRecord::as_select()) + .select(MediaTranslation::as_select()) .load(c) - .map(|rows: Vec| rows.into_iter().map(Into::into).collect()) }) } pub fn upsert_media_translation(conn: &DbConnection, t: &MediaTranslation) -> QueryResult<()> { conn.with(|c| { diesel::insert_into(media_translations::table) - .values(MediaTranslationRecord::from(t)) + .values(t.clone()) .on_conflict(( media_translations::translation_for, media_translations::language, diff --git a/crates/bds-core/src/db/queries/post.rs b/crates/bds-core/src/db/queries/post.rs index f3ddaa4..cac53a7 100644 --- a/crates/bds-core/src/db/queries/post.rs +++ b/crates/bds-core/src/db/queries/post.rs @@ -4,7 +4,6 @@ use diesel::sql_types::Text; use diesel::sqlite::Sqlite; 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::util::calendar_range_unix_ms; @@ -15,7 +14,7 @@ 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)) + .values(post.clone()) .execute(c) .map(|_| ()) }) @@ -25,9 +24,8 @@ pub fn get_post_by_id(conn: &DbConnection, id: &str) -> QueryResult { conn.with(|c| { posts::table .filter(posts::id.eq(id)) - .select(PostRecord::as_select()) + .select(Post::as_select()) .first(c) - .and_then(convert) }) } @@ -40,9 +38,8 @@ pub fn get_post_by_project_and_slug( posts::table .filter(posts::project_id.eq(project_id)) .filter(posts::slug.eq(slug)) - .select(PostRecord::as_select()) + .select(Post::as_select()) .first(c) - .and_then(convert) }) } @@ -51,34 +48,15 @@ pub fn list_posts_by_project(conn: &DbConnection, project_id: &str) -> QueryResu posts::table .filter(posts::project_id.eq(project_id)) .order(posts::created_at.desc()) - .select(PostRecord::as_select()) + .select(Post::as_select()) .load(c) - .and_then(convert_all) - }) -} - -pub fn list_posts_by_project_limited( - conn: &DbConnection, - project_id: &str, - limit: i64, - offset: i64, -) -> QueryResult> { - conn.with(|c| { - posts::table - .filter(posts::project_id.eq(project_id)) - .order(posts::created_at.desc()) - .limit(limit) - .offset(offset) - .select(PostRecord::as_select()) - .load(c) - .and_then(convert_all) }) } pub fn update_post(conn: &DbConnection, post: &Post) -> QueryResult<()> { conn.with(|c| { diesel::update(posts::table.filter(posts::id.eq(&post.id))) - .set(PostRecord::from(post)) + .set(post.clone()) .execute(c) .map(|_| ()) }) @@ -93,7 +71,7 @@ pub fn update_post_status( conn.with(|c| { diesel::update(posts::table.filter(posts::id.eq(id))) .set(( - posts::status.eq(post_status_to_str(status)), + posts::status.eq(status.as_str()), posts::updated_at.eq(updated_at), )) .execute(c) @@ -307,17 +285,17 @@ pub fn list_posts_filtered( .order(posts::created_at.desc()) .limit(limit) .offset(offset) - .select(PostRecord::as_select()) + .select(Post::as_select()) .load(c)? } else if content_filters { - let mut records: Vec = post_query(project_id, filters, false, false) + let mut records: Vec = post_query(project_id, filters, false, false) .filter(posts::status.eq("draft")) - .select(PostRecord::as_select()) + .select(Post::as_select()) .load(c)?; records.extend( post_query(project_id, filters, true, true) - .select(PostRecord::as_select()) - .load::(c)?, + .select(Post::as_select()) + .load::(c)?, ); records.sort_unstable_by_key(|record| std::cmp::Reverse(record.created_at)); records @@ -330,10 +308,10 @@ pub fn list_posts_filtered( .order(posts::created_at.desc()) .limit(limit) .offset(offset) - .select(PostRecord::as_select()) + .select(Post::as_select()) .load(c)? }; - convert_all(records) + Ok(records) }) } @@ -544,6 +522,29 @@ mod tests { assert_eq!(fetched.updated_at, 5000); } + #[test] + fn malformed_persisted_types_fail_deserialization() { + let db = setup(); + insert_post(db.conn(), &make_post("x1", "hello")).unwrap(); + db.conn() + .with(|connection| { + diesel::update(posts::table.filter(posts::id.eq("x1"))) + .set(posts::status.eq("unknown")) + .execute(connection) + }) + .unwrap(); + assert!(get_post_by_id(db.conn(), "x1").is_err()); + + db.conn() + .with(|connection| { + diesel::update(posts::table.filter(posts::id.eq("x1"))) + .set((posts::status.eq("draft"), posts::tags.eq("not-json"))) + .execute(connection) + }) + .unwrap(); + assert!(get_post_by_id(db.conn(), "x1").is_err()); + } + #[test] fn clear_content_sets_null() { let db = setup(); diff --git a/crates/bds-core/src/db/queries/post_link.rs b/crates/bds-core/src/db/queries/post_link.rs index 8539ad4..567bdad 100644 --- a/crates/bds-core/src/db/queries/post_link.rs +++ b/crates/bds-core/src/db/queries/post_link.rs @@ -1,14 +1,13 @@ use diesel::prelude::*; use crate::db::DbConnection; -use crate::db::from_row::PostLinkRecord; use crate::db::schema::post_links; use crate::model::PostLink; pub fn insert_post_link(conn: &DbConnection, link: &PostLink) -> QueryResult<()> { conn.with(|c| { diesel::insert_into(post_links::table) - .values(PostLinkRecord::from(link)) + .values(link.clone()) .execute(c) .map(|_| ()) }) @@ -38,9 +37,8 @@ pub fn list_links_by_source( post_links::table .filter(post_links::source_post_id.eq(source_post_id)) .order(post_links::created_at) - .select(PostLinkRecord::as_select()) + .select(PostLink::as_select()) .load(c) - .map(|rows: Vec| rows.into_iter().map(Into::into).collect()) }) } @@ -52,9 +50,8 @@ pub fn list_links_by_target( post_links::table .filter(post_links::target_post_id.eq(target_post_id)) .order(post_links::created_at) - .select(PostLinkRecord::as_select()) + .select(PostLink::as_select()) .load(c) - .map(|rows: Vec| rows.into_iter().map(Into::into).collect()) }) } diff --git a/crates/bds-core/src/db/queries/post_media.rs b/crates/bds-core/src/db/queries/post_media.rs index 73e9af8..27ddb81 100644 --- a/crates/bds-core/src/db/queries/post_media.rs +++ b/crates/bds-core/src/db/queries/post_media.rs @@ -1,14 +1,13 @@ use diesel::prelude::*; use crate::db::DbConnection; -use crate::db::from_row::PostMediaRecord; use crate::db::schema::post_media; use crate::model::PostMedia; pub fn link_media(conn: &DbConnection, pm: &PostMedia) -> QueryResult<()> { conn.with(|c| { diesel::insert_into(post_media::table) - .values(PostMediaRecord::from(pm)) + .values(pm.clone()) .execute(c) .map(|_| ()) }) @@ -39,9 +38,8 @@ pub fn list_post_media_by_post(conn: &DbConnection, post_id: &str) -> QueryResul post_media::table .filter(post_media::post_id.eq(post_id)) .order(post_media::sort_order) - .select(PostMediaRecord::as_select()) + .select(PostMedia::as_select()) .load(c) - .map(|rows: Vec| rows.into_iter().map(Into::into).collect()) }) } @@ -53,9 +51,8 @@ pub fn list_post_media_by_media( post_media::table .filter(post_media::media_id.eq(media_id)) .order(post_media::created_at) - .select(PostMediaRecord::as_select()) + .select(PostMedia::as_select()) .load(c) - .map(|rows: Vec| rows.into_iter().map(Into::into).collect()) }) } diff --git a/crates/bds-core/src/db/queries/post_translation.rs b/crates/bds-core/src/db/queries/post_translation.rs index 54a45e5..4349938 100644 --- a/crates/bds-core/src/db/queries/post_translation.rs +++ b/crates/bds-core/src/db/queries/post_translation.rs @@ -1,7 +1,6 @@ use diesel::prelude::*; use crate::db::DbConnection; -use crate::db::from_row::{PostTranslationRecord, convert, convert_all}; use crate::db::schema::post_translations; use crate::model::PostTranslation; @@ -13,7 +12,7 @@ pub fn insert_post_translation(conn: &DbConnection, t: &PostTranslation) -> Quer } conn.with(|c| { diesel::insert_into(post_translations::table) - .values(PostTranslationRecord::from(t)) + .values(t.clone()) .execute(c) .map(|_| ()) }) @@ -23,9 +22,8 @@ pub fn get_post_translation_by_id(conn: &DbConnection, id: &str) -> QueryResult< conn.with(|c| { post_translations::table .filter(post_translations::id.eq(id)) - .select(PostTranslationRecord::as_select()) + .select(PostTranslation::as_select()) .first(c) - .and_then(convert) }) } @@ -38,9 +36,8 @@ pub fn get_post_translation_by_post_and_language( post_translations::table .filter(post_translations::translation_for.eq(translation_for)) .filter(post_translations::language.eq(language)) - .select(PostTranslationRecord::as_select()) + .select(PostTranslation::as_select()) .first(c) - .and_then(convert) }) } @@ -52,9 +49,8 @@ pub fn list_post_translations_by_post( post_translations::table .filter(post_translations::translation_for.eq(translation_for)) .order(post_translations::language) - .select(PostTranslationRecord::as_select()) + .select(PostTranslation::as_select()) .load(c) - .and_then(convert_all) }) } @@ -66,7 +62,7 @@ pub fn update_post_translation(conn: &DbConnection, t: &PostTranslation) -> Quer } conn.with(|c| { diesel::update(post_translations::table.filter(post_translations::id.eq(&t.id))) - .set(PostTranslationRecord::from(t)) + .set(t.clone()) .execute(c) .map(|_| ()) }) diff --git a/crates/bds-core/src/db/queries/project.rs b/crates/bds-core/src/db/queries/project.rs index 0e9a458..8b10655 100644 --- a/crates/bds-core/src/db/queries/project.rs +++ b/crates/bds-core/src/db/queries/project.rs @@ -1,14 +1,13 @@ use diesel::prelude::*; use crate::db::DbConnection; -use crate::db::from_row::ProjectRecord; use crate::db::schema::projects; use crate::model::Project; pub fn insert_project(conn: &DbConnection, project: &Project) -> QueryResult<()> { conn.with(|c| { diesel::insert_into(projects::table) - .values(ProjectRecord::from(project)) + .values(project.clone()) .execute(c) .map(|_| ()) }) @@ -18,9 +17,8 @@ pub fn get_project_by_id(conn: &DbConnection, id: &str) -> QueryResult conn.with(|c| { projects::table .filter(projects::id.eq(id)) - .select(ProjectRecord::as_select()) + .select(Project::as_select()) .first(c) - .map(Into::into) }) } @@ -28,9 +26,8 @@ pub fn get_project_by_slug(conn: &DbConnection, slug: &str) -> QueryResult QueryResult { conn.with(|c| { projects::table .filter(projects::is_active.eq(1)) - .select(ProjectRecord::as_select()) + .select(Project::as_select()) .first(c) - .map(Into::into) }) } @@ -62,16 +58,15 @@ pub fn list_projects(conn: &DbConnection) -> QueryResult> { conn.with(|c| { projects::table .order(projects::name) - .select(ProjectRecord::as_select()) + .select(Project::as_select()) .load(c) - .map(|rows: Vec| rows.into_iter().map(Into::into).collect()) }) } pub fn update_project(conn: &DbConnection, project: &Project) -> QueryResult<()> { conn.with(|c| { diesel::update(projects::table.filter(projects::id.eq(&project.id))) - .set(ProjectRecord::from(project)) + .set(project.clone()) .execute(c) .map(|_| ()) }) diff --git a/crates/bds-core/src/db/queries/script.rs b/crates/bds-core/src/db/queries/script.rs index 4de467d..f3b784c 100644 --- a/crates/bds-core/src/db/queries/script.rs +++ b/crates/bds-core/src/db/queries/script.rs @@ -1,14 +1,13 @@ use diesel::prelude::*; use crate::db::DbConnection; -use crate::db::from_row::{ScriptRecord, convert, convert_all}; use crate::db::schema::scripts; use crate::model::Script; pub fn insert_script(conn: &DbConnection, s: &Script) -> QueryResult<()> { conn.with(|c| { diesel::insert_into(scripts::table) - .values(ScriptRecord::from(s)) + .values(s.clone()) .execute(c) .map(|_| ()) }) @@ -18,9 +17,8 @@ pub fn get_script_by_id(conn: &DbConnection, id: &str) -> QueryResult