feat: implementation of M1
This commit is contained in:
619
crates/bds-core/src/db/from_row.rs
Normal file
619
crates/bds-core/src/db/from_row.rs
Normal file
@@ -0,0 +1,619 @@
|
||||
use rusqlite::Row;
|
||||
|
||||
use crate::model::{
|
||||
DbNotification, GeneratedFileHash, Media, MediaTranslation, NotificationAction,
|
||||
NotificationEntity, Post, PostLink, PostMedia, PostStatus, PostTranslation, Project, Script,
|
||||
ScriptKind, ScriptStatus, Setting, Tag, Template, TemplateKind, TemplateStatus,
|
||||
};
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
fn conversion_err(msg: String) -> rusqlite::Error {
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
0,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, msg)),
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_post_status(s: &str) -> rusqlite::Result<PostStatus> {
|
||||
match s {
|
||||
"draft" => Ok(PostStatus::Draft),
|
||||
"published" => Ok(PostStatus::Published),
|
||||
"archived" => Ok(PostStatus::Archived),
|
||||
_ => Err(conversion_err(format!("invalid PostStatus: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_template_kind(s: &str) -> rusqlite::Result<TemplateKind> {
|
||||
match s {
|
||||
"post" => Ok(TemplateKind::Post),
|
||||
"list" => Ok(TemplateKind::List),
|
||||
"not_found" => Ok(TemplateKind::NotFound),
|
||||
"partial" => Ok(TemplateKind::Partial),
|
||||
_ => Err(conversion_err(format!("invalid TemplateKind: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_template_status(s: &str) -> rusqlite::Result<TemplateStatus> {
|
||||
match s {
|
||||
"draft" => Ok(TemplateStatus::Draft),
|
||||
"published" => Ok(TemplateStatus::Published),
|
||||
_ => Err(conversion_err(format!("invalid TemplateStatus: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_script_kind(s: &str) -> rusqlite::Result<ScriptKind> {
|
||||
match s {
|
||||
"macro" => Ok(ScriptKind::Macro),
|
||||
"utility" => Ok(ScriptKind::Utility),
|
||||
"transform" => Ok(ScriptKind::Transform),
|
||||
_ => Err(conversion_err(format!("invalid ScriptKind: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_script_status(s: &str) -> rusqlite::Result<ScriptStatus> {
|
||||
match s {
|
||||
"draft" => Ok(ScriptStatus::Draft),
|
||||
"published" => Ok(ScriptStatus::Published),
|
||||
_ => Err(conversion_err(format!("invalid ScriptStatus: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_notification_entity(s: &str) -> rusqlite::Result<NotificationEntity> {
|
||||
match s {
|
||||
"post" => Ok(NotificationEntity::Post),
|
||||
"media" => Ok(NotificationEntity::Media),
|
||||
"script" => Ok(NotificationEntity::Script),
|
||||
"template" => Ok(NotificationEntity::Template),
|
||||
_ => Err(conversion_err(format!("invalid NotificationEntity: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_notification_action(s: &str) -> rusqlite::Result<NotificationAction> {
|
||||
match s {
|
||||
"created" => Ok(NotificationAction::Created),
|
||||
"updated" => Ok(NotificationAction::Updated),
|
||||
"deleted" => Ok(NotificationAction::Deleted),
|
||||
_ => Err(conversion_err(format!("invalid NotificationAction: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_to_vec_string(s: &str) -> rusqlite::Result<Vec<String>> {
|
||||
serde_json::from_str(s).map_err(|e| conversion_err(format!("JSON parse error: {e}")))
|
||||
}
|
||||
|
||||
fn bool_from_i64(v: i64) -> bool {
|
||||
v != 0
|
||||
}
|
||||
|
||||
// ── enum → DB string (for INSERT / UPDATE) ───────────────────────────
|
||||
|
||||
pub fn post_status_to_str(s: &PostStatus) -> &'static str {
|
||||
match s {
|
||||
PostStatus::Draft => "draft",
|
||||
PostStatus::Published => "published",
|
||||
PostStatus::Archived => "archived",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn template_kind_to_str(k: &TemplateKind) -> &'static str {
|
||||
match k {
|
||||
TemplateKind::Post => "post",
|
||||
TemplateKind::List => "list",
|
||||
TemplateKind::NotFound => "not_found",
|
||||
TemplateKind::Partial => "partial",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn template_status_to_str(s: &TemplateStatus) -> &'static str {
|
||||
match s {
|
||||
TemplateStatus::Draft => "draft",
|
||||
TemplateStatus::Published => "published",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn script_kind_to_str(k: &ScriptKind) -> &'static str {
|
||||
match k {
|
||||
ScriptKind::Macro => "macro",
|
||||
ScriptKind::Utility => "utility",
|
||||
ScriptKind::Transform => "transform",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn script_status_to_str(s: &ScriptStatus) -> &'static str {
|
||||
match s {
|
||||
ScriptStatus::Draft => "draft",
|
||||
ScriptStatus::Published => "published",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn notification_entity_to_str(e: &NotificationEntity) -> &'static str {
|
||||
match e {
|
||||
NotificationEntity::Post => "post",
|
||||
NotificationEntity::Media => "media",
|
||||
NotificationEntity::Script => "script",
|
||||
NotificationEntity::Template => "template",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn notification_action_to_str(a: &NotificationAction) -> &'static str {
|
||||
match a {
|
||||
NotificationAction::Created => "created",
|
||||
NotificationAction::Updated => "updated",
|
||||
NotificationAction::Deleted => "deleted",
|
||||
}
|
||||
}
|
||||
|
||||
// ── column lists (keep in sync with from_row functions) ──────────────
|
||||
|
||||
pub const PROJECT_COLUMNS: &str =
|
||||
"id, name, slug, description, data_path, is_active, created_at, updated_at";
|
||||
|
||||
pub const POST_COLUMNS: &str = "\
|
||||
id, project_id, title, slug, excerpt, content, status, author, \
|
||||
language, do_not_translate, template_slug, file_path, checksum, \
|
||||
tags, categories, \
|
||||
published_title, published_content, published_tags, \
|
||||
published_categories, published_excerpt, \
|
||||
created_at, updated_at, published_at";
|
||||
|
||||
pub const POST_TRANSLATION_COLUMNS: &str = "\
|
||||
id, project_id, translation_for, language, title, excerpt, content, \
|
||||
status, file_path, checksum, created_at, updated_at, published_at";
|
||||
|
||||
pub const POST_LINK_COLUMNS: &str =
|
||||
"id, source_post_id, target_post_id, link_text, created_at";
|
||||
|
||||
pub const POST_MEDIA_COLUMNS: &str =
|
||||
"id, project_id, post_id, media_id, sort_order, created_at";
|
||||
|
||||
pub const MEDIA_COLUMNS: &str = "\
|
||||
id, project_id, filename, original_name, mime_type, size, \
|
||||
width, height, title, alt, caption, author, language, \
|
||||
file_path, sidecar_path, checksum, tags, created_at, updated_at";
|
||||
|
||||
pub const MEDIA_TRANSLATION_COLUMNS: &str =
|
||||
"id, project_id, translation_for, language, title, alt, caption, created_at, updated_at";
|
||||
|
||||
pub const TAG_COLUMNS: &str =
|
||||
"id, project_id, name, color, post_template_slug, created_at, updated_at";
|
||||
|
||||
pub const TEMPLATE_COLUMNS: &str = "\
|
||||
id, project_id, slug, title, kind, enabled, version, \
|
||||
file_path, status, content, created_at, updated_at";
|
||||
|
||||
pub const SCRIPT_COLUMNS: &str = "\
|
||||
id, project_id, slug, title, kind, entrypoint, enabled, version, \
|
||||
file_path, status, content, created_at, updated_at";
|
||||
|
||||
pub const SETTING_COLUMNS: &str = "key, value, updated_at";
|
||||
|
||||
pub const GENERATED_FILE_HASH_COLUMNS: &str =
|
||||
"project_id, relative_path, content_hash, updated_at";
|
||||
|
||||
pub const DB_NOTIFICATION_COLUMNS: &str =
|
||||
"id, entity_type, entity_id, action, from_cli, seen_at, created_at";
|
||||
|
||||
// ── from_row functions ───────────────────────────────────────────────
|
||||
|
||||
pub fn project_from_row(row: &Row) -> rusqlite::Result<Project> {
|
||||
Ok(Project {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
slug: row.get(2)?,
|
||||
description: row.get(3)?,
|
||||
data_path: row.get(4)?,
|
||||
is_active: bool_from_i64(row.get(5)?),
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn post_from_row(row: &Row) -> rusqlite::Result<Post> {
|
||||
let status_str: String = row.get(6)?;
|
||||
let tags_json: String = row.get(13)?;
|
||||
let categories_json: String = row.get(14)?;
|
||||
Ok(Post {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
slug: row.get(3)?,
|
||||
excerpt: row.get(4)?,
|
||||
content: row.get(5)?,
|
||||
status: parse_post_status(&status_str)?,
|
||||
author: row.get(7)?,
|
||||
language: row.get(8)?,
|
||||
do_not_translate: bool_from_i64(row.get(9)?),
|
||||
template_slug: row.get(10)?,
|
||||
file_path: row.get(11)?,
|
||||
checksum: row.get(12)?,
|
||||
tags: json_to_vec_string(&tags_json)?,
|
||||
categories: json_to_vec_string(&categories_json)?,
|
||||
published_title: row.get(15)?,
|
||||
published_content: row.get(16)?,
|
||||
published_tags: row.get(17)?,
|
||||
published_categories: row.get(18)?,
|
||||
published_excerpt: row.get(19)?,
|
||||
created_at: row.get(20)?,
|
||||
updated_at: row.get(21)?,
|
||||
published_at: row.get(22)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn post_translation_from_row(row: &Row) -> rusqlite::Result<PostTranslation> {
|
||||
let status_str: String = row.get(7)?;
|
||||
Ok(PostTranslation {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
translation_for: row.get(2)?,
|
||||
language: row.get(3)?,
|
||||
title: row.get(4)?,
|
||||
excerpt: row.get(5)?,
|
||||
content: row.get(6)?,
|
||||
status: parse_post_status(&status_str)?,
|
||||
file_path: row.get(8)?,
|
||||
checksum: row.get(9)?,
|
||||
created_at: row.get(10)?,
|
||||
updated_at: row.get(11)?,
|
||||
published_at: row.get(12)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn post_link_from_row(row: &Row) -> rusqlite::Result<PostLink> {
|
||||
Ok(PostLink {
|
||||
id: row.get(0)?,
|
||||
source_post_id: row.get(1)?,
|
||||
target_post_id: row.get(2)?,
|
||||
link_text: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn post_media_from_row(row: &Row) -> rusqlite::Result<PostMedia> {
|
||||
Ok(PostMedia {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
post_id: row.get(2)?,
|
||||
media_id: row.get(3)?,
|
||||
sort_order: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn media_from_row(row: &Row) -> rusqlite::Result<Media> {
|
||||
let tags_json: String = row.get(16)?;
|
||||
Ok(Media {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
filename: row.get(2)?,
|
||||
original_name: row.get(3)?,
|
||||
mime_type: row.get(4)?,
|
||||
size: row.get(5)?,
|
||||
width: row.get(6)?,
|
||||
height: row.get(7)?,
|
||||
title: row.get(8)?,
|
||||
alt: row.get(9)?,
|
||||
caption: row.get(10)?,
|
||||
author: row.get(11)?,
|
||||
language: row.get(12)?,
|
||||
file_path: row.get(13)?,
|
||||
sidecar_path: row.get(14)?,
|
||||
checksum: row.get(15)?,
|
||||
tags: json_to_vec_string(&tags_json)?,
|
||||
created_at: row.get(17)?,
|
||||
updated_at: row.get(18)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn media_translation_from_row(row: &Row) -> rusqlite::Result<MediaTranslation> {
|
||||
Ok(MediaTranslation {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
translation_for: row.get(2)?,
|
||||
language: row.get(3)?,
|
||||
title: row.get(4)?,
|
||||
alt: row.get(5)?,
|
||||
caption: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
updated_at: row.get(8)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tag_from_row(row: &Row) -> rusqlite::Result<Tag> {
|
||||
Ok(Tag {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
color: row.get(3)?,
|
||||
post_template_slug: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn template_from_row(row: &Row) -> rusqlite::Result<Template> {
|
||||
let kind_str: String = row.get(4)?;
|
||||
let status_str: String = row.get(8)?;
|
||||
Ok(Template {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
slug: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
kind: parse_template_kind(&kind_str)?,
|
||||
enabled: bool_from_i64(row.get(5)?),
|
||||
version: row.get(6)?,
|
||||
file_path: row.get(7)?,
|
||||
status: parse_template_status(&status_str)?,
|
||||
content: row.get(9)?,
|
||||
created_at: row.get(10)?,
|
||||
updated_at: row.get(11)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn script_from_row(row: &Row) -> rusqlite::Result<Script> {
|
||||
let kind_str: String = row.get(4)?;
|
||||
let status_str: String = row.get(9)?;
|
||||
Ok(Script {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
slug: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
kind: parse_script_kind(&kind_str)?,
|
||||
entrypoint: row.get(5)?,
|
||||
enabled: bool_from_i64(row.get(6)?),
|
||||
version: row.get(7)?,
|
||||
file_path: row.get(8)?,
|
||||
status: parse_script_status(&status_str)?,
|
||||
content: row.get(10)?,
|
||||
created_at: row.get(11)?,
|
||||
updated_at: row.get(12)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn setting_from_row(row: &Row) -> rusqlite::Result<Setting> {
|
||||
Ok(Setting {
|
||||
key: row.get(0)?,
|
||||
value: row.get(1)?,
|
||||
updated_at: row.get(2)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn generated_file_hash_from_row(row: &Row) -> rusqlite::Result<GeneratedFileHash> {
|
||||
Ok(GeneratedFileHash {
|
||||
project_id: row.get(0)?,
|
||||
relative_path: row.get(1)?,
|
||||
content_hash: row.get(2)?,
|
||||
updated_at: row.get(3)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn db_notification_from_row(row: &Row) -> rusqlite::Result<DbNotification> {
|
||||
let entity_str: String = row.get(1)?;
|
||||
let action_str: String = row.get(3)?;
|
||||
Ok(DbNotification {
|
||||
id: row.get(0)?,
|
||||
entity_type: parse_notification_entity(&entity_str)?,
|
||||
entity_id: row.get(2)?,
|
||||
action: parse_notification_action(&action_str)?,
|
||||
from_cli: bool_from_i64(row.get(4)?),
|
||||
seen_at: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
// ── enum parsing ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_post_status_valid() {
|
||||
assert_eq!(parse_post_status("draft").unwrap(), PostStatus::Draft);
|
||||
assert_eq!(parse_post_status("published").unwrap(), PostStatus::Published);
|
||||
assert_eq!(parse_post_status("archived").unwrap(), PostStatus::Archived);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_post_status_invalid() {
|
||||
assert!(parse_post_status("nope").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_template_kind_valid() {
|
||||
assert_eq!(parse_template_kind("post").unwrap(), TemplateKind::Post);
|
||||
assert_eq!(parse_template_kind("list").unwrap(), TemplateKind::List);
|
||||
assert_eq!(parse_template_kind("not_found").unwrap(), TemplateKind::NotFound);
|
||||
assert_eq!(parse_template_kind("partial").unwrap(), TemplateKind::Partial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_template_kind_invalid() {
|
||||
assert!(parse_template_kind("unknown").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_template_status_valid() {
|
||||
assert_eq!(parse_template_status("draft").unwrap(), TemplateStatus::Draft);
|
||||
assert_eq!(parse_template_status("published").unwrap(), TemplateStatus::Published);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_script_kind_valid() {
|
||||
assert_eq!(parse_script_kind("macro").unwrap(), ScriptKind::Macro);
|
||||
assert_eq!(parse_script_kind("utility").unwrap(), ScriptKind::Utility);
|
||||
assert_eq!(parse_script_kind("transform").unwrap(), ScriptKind::Transform);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_script_status_valid() {
|
||||
assert_eq!(parse_script_status("draft").unwrap(), ScriptStatus::Draft);
|
||||
assert_eq!(parse_script_status("published").unwrap(), ScriptStatus::Published);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_notification_entity_valid() {
|
||||
assert_eq!(parse_notification_entity("post").unwrap(), NotificationEntity::Post);
|
||||
assert_eq!(parse_notification_entity("media").unwrap(), NotificationEntity::Media);
|
||||
assert_eq!(parse_notification_entity("script").unwrap(), NotificationEntity::Script);
|
||||
assert_eq!(parse_notification_entity("template").unwrap(), NotificationEntity::Template);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_notification_action_valid() {
|
||||
assert_eq!(parse_notification_action("created").unwrap(), NotificationAction::Created);
|
||||
assert_eq!(parse_notification_action("updated").unwrap(), NotificationAction::Updated);
|
||||
assert_eq!(parse_notification_action("deleted").unwrap(), NotificationAction::Deleted);
|
||||
}
|
||||
|
||||
// ── JSON helpers ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn json_vec_string_roundtrip() {
|
||||
let v = json_to_vec_string(r#"["a","b","c"]"#).unwrap();
|
||||
assert_eq!(v, vec!["a", "b", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_vec_string_empty() {
|
||||
let v = json_to_vec_string("[]").unwrap();
|
||||
assert!(v.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_vec_string_invalid() {
|
||||
assert!(json_to_vec_string("not json").is_err());
|
||||
}
|
||||
|
||||
// ── from_row round-trips via real DB ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn project_from_row_roundtrip() {
|
||||
let db = setup();
|
||||
let c = db.conn();
|
||||
c.execute(
|
||||
"INSERT INTO projects (id, name, slug, description, data_path, is_active, created_at, updated_at)
|
||||
VALUES ('p1', 'Blog', 'blog', 'My blog', '/data', 1, 1000, 2000)",
|
||||
[],
|
||||
).unwrap();
|
||||
let p = c.query_row(
|
||||
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE id = 'p1'"),
|
||||
[],
|
||||
project_from_row,
|
||||
).unwrap();
|
||||
assert_eq!(p.id, "p1");
|
||||
assert_eq!(p.name, "Blog");
|
||||
assert_eq!(p.slug, "blog");
|
||||
assert_eq!(p.description.as_deref(), Some("My blog"));
|
||||
assert_eq!(p.data_path.as_deref(), Some("/data"));
|
||||
assert!(p.is_active);
|
||||
assert_eq!(p.created_at, 1000);
|
||||
assert_eq!(p.updated_at, 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_from_row_roundtrip() {
|
||||
let db = setup();
|
||||
let c = db.conn();
|
||||
c.execute(
|
||||
"INSERT INTO projects (id, name, slug, is_active, created_at, updated_at)
|
||||
VALUES ('p1', 'B', 'b', 0, 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, excerpt, content, status, author,
|
||||
language, do_not_translate, template_slug, file_path, checksum,
|
||||
tags, categories,
|
||||
published_title, published_content, published_tags, published_categories, published_excerpt,
|
||||
created_at, updated_at, published_at)
|
||||
VALUES ('x', 'p1', 'Hello', 'hello', 'sum', 'body', 'draft', 'Alice',
|
||||
'en', 1, 'tpl', 'posts/hello.md', 'abc',
|
||||
'[\"rust\"]', '[\"tech\"]',
|
||||
NULL, NULL, NULL, NULL, NULL,
|
||||
1000, 2000, NULL)",
|
||||
[],
|
||||
).unwrap();
|
||||
let p = c.query_row(
|
||||
&format!("SELECT {POST_COLUMNS} FROM posts WHERE id = 'x'"),
|
||||
[],
|
||||
post_from_row,
|
||||
).unwrap();
|
||||
assert_eq!(p.id, "x");
|
||||
assert_eq!(p.project_id, "p1");
|
||||
assert_eq!(p.title, "Hello");
|
||||
assert_eq!(p.slug, "hello");
|
||||
assert_eq!(p.excerpt.as_deref(), Some("sum"));
|
||||
assert_eq!(p.content.as_deref(), Some("body"));
|
||||
assert_eq!(p.status, PostStatus::Draft);
|
||||
assert_eq!(p.author.as_deref(), Some("Alice"));
|
||||
assert_eq!(p.language.as_deref(), Some("en"));
|
||||
assert!(p.do_not_translate);
|
||||
assert_eq!(p.template_slug.as_deref(), Some("tpl"));
|
||||
assert_eq!(p.file_path, "posts/hello.md");
|
||||
assert_eq!(p.checksum.as_deref(), Some("abc"));
|
||||
assert_eq!(p.tags, vec!["rust"]);
|
||||
assert_eq!(p.categories, vec!["tech"]);
|
||||
assert!(p.published_title.is_none());
|
||||
assert_eq!(p.created_at, 1000);
|
||||
assert_eq!(p.updated_at, 2000);
|
||||
assert!(p.published_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_from_row_roundtrip() {
|
||||
let db = setup();
|
||||
let c = db.conn();
|
||||
c.execute(
|
||||
"INSERT INTO projects (id, name, slug, is_active, created_at, updated_at)
|
||||
VALUES ('p1', 'B', 'b', 0, 1000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO templates (id, project_id, slug, title, kind, enabled, version,
|
||||
file_path, status, content, created_at, updated_at)
|
||||
VALUES ('t1', 'p1', 'default', 'Default', 'not_found', 0, 3,
|
||||
'templates/default.liquid', 'draft', 'html', 1000, 2000)",
|
||||
[],
|
||||
).unwrap();
|
||||
let t = c.query_row(
|
||||
&format!("SELECT {TEMPLATE_COLUMNS} FROM templates WHERE id = 't1'"),
|
||||
[],
|
||||
template_from_row,
|
||||
).unwrap();
|
||||
assert_eq!(t.kind, TemplateKind::NotFound);
|
||||
assert!(!t.enabled);
|
||||
assert_eq!(t.version, 3);
|
||||
assert_eq!(t.status, TemplateStatus::Draft);
|
||||
assert_eq!(t.content.as_deref(), Some("html"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_notification_from_row_roundtrip() {
|
||||
let db = setup();
|
||||
let c = db.conn();
|
||||
c.execute(
|
||||
"INSERT INTO db_notifications (entity_type, entity_id, action, from_cli, seen_at, created_at)
|
||||
VALUES ('media', 'm1', 'deleted', 1, 5000, 1000)",
|
||||
[],
|
||||
).unwrap();
|
||||
let n = c.query_row(
|
||||
&format!("SELECT {DB_NOTIFICATION_COLUMNS} FROM db_notifications WHERE entity_id = 'm1'"),
|
||||
[],
|
||||
db_notification_from_row,
|
||||
).unwrap();
|
||||
assert_eq!(n.entity_type, NotificationEntity::Media);
|
||||
assert_eq!(n.entity_id, "m1");
|
||||
assert_eq!(n.action, NotificationAction::Deleted);
|
||||
assert!(n.from_cli);
|
||||
assert_eq!(n.seen_at, Some(5000));
|
||||
assert_eq!(n.created_at, 1000);
|
||||
}
|
||||
}
|
||||
297
crates/bds-core/src/db/fts.rs
Normal file
297
crates/bds-core/src/db/fts.rs
Normal file
@@ -0,0 +1,297 @@
|
||||
use rust_stemmers::{Algorithm, Stemmer};
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// Create FTS5 virtual tables at runtime (not in migrations per spec).
|
||||
pub fn ensure_fts_tables(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(
|
||||
"CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
|
||||
post_id UNINDEXED,
|
||||
content
|
||||
);
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS media_fts USING fts5(
|
||||
media_id UNINDEXED,
|
||||
content
|
||||
);"
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Map ISO 639-1 language code to Snowball stemmer algorithm.
|
||||
fn stemmer_for_language(lang: &str) -> Algorithm {
|
||||
match lang {
|
||||
"ar" => Algorithm::Arabic,
|
||||
"da" => Algorithm::Danish,
|
||||
"de" => Algorithm::German,
|
||||
"el" => Algorithm::Greek,
|
||||
"en" => Algorithm::English,
|
||||
"es" => Algorithm::Spanish,
|
||||
"fi" => Algorithm::Finnish,
|
||||
"fr" => Algorithm::French,
|
||||
"hu" => Algorithm::Hungarian,
|
||||
"hy" | "id" => Algorithm::English, // no dedicated stemmer, fallback
|
||||
"it" => Algorithm::Italian,
|
||||
"nb" | "nn" | "no" => Algorithm::Norwegian,
|
||||
"nl" => Algorithm::Dutch,
|
||||
"pt" => Algorithm::Portuguese,
|
||||
"ro" => Algorithm::Romanian,
|
||||
"ru" => Algorithm::Russian,
|
||||
"sv" => Algorithm::Swedish,
|
||||
"ta" => Algorithm::Tamil,
|
||||
"tr" => Algorithm::Turkish,
|
||||
_ => Algorithm::English,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stem a text string using the Snowball stemmer for the given language.
|
||||
pub fn stem_text(text: &str, language: &str) -> String {
|
||||
let algo = stemmer_for_language(language);
|
||||
let stemmer = Stemmer::create(algo);
|
||||
text.split_whitespace()
|
||||
.map(|word| stemmer.stem(word).into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Index a post in the FTS table. Concatenates title, excerpt, content, tags,
|
||||
/// categories, and all translation text. Pre-stems before inserting.
|
||||
pub fn index_post(
|
||||
conn: &Connection,
|
||||
post_id: &str,
|
||||
title: &str,
|
||||
excerpt: Option<&str>,
|
||||
content: Option<&str>,
|
||||
tags: &[String],
|
||||
categories: &[String],
|
||||
translation_texts: &[String],
|
||||
language: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
// Remove existing entry
|
||||
remove_post_from_index(conn, post_id)?;
|
||||
|
||||
let mut parts: Vec<&str> = vec![title];
|
||||
if let Some(exc) = excerpt {
|
||||
parts.push(exc);
|
||||
}
|
||||
if let Some(cnt) = content {
|
||||
parts.push(cnt);
|
||||
}
|
||||
let tags_str = tags.join(" ");
|
||||
parts.push(&tags_str);
|
||||
let cats_str = categories.join(" ");
|
||||
parts.push(&cats_str);
|
||||
|
||||
let combined = parts.join(" ");
|
||||
let mut full_text = stem_text(&combined, language);
|
||||
|
||||
// Add translation texts (stemmed with their own implied language or same)
|
||||
for t in translation_texts {
|
||||
full_text.push(' ');
|
||||
full_text.push_str(&stem_text(t, language));
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO posts_fts (post_id, content) VALUES (?1, ?2)",
|
||||
rusqlite::params![post_id, full_text],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Index a media item in the FTS table.
|
||||
pub fn index_media(
|
||||
conn: &Connection,
|
||||
media_id: &str,
|
||||
title: Option<&str>,
|
||||
alt: Option<&str>,
|
||||
caption: Option<&str>,
|
||||
original_name: &str,
|
||||
tags: &[String],
|
||||
translation_texts: &[String],
|
||||
language: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
remove_media_from_index(conn, media_id)?;
|
||||
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
if let Some(t) = title {
|
||||
parts.push(t);
|
||||
}
|
||||
if let Some(a) = alt {
|
||||
parts.push(a);
|
||||
}
|
||||
if let Some(c) = caption {
|
||||
parts.push(c);
|
||||
}
|
||||
parts.push(original_name);
|
||||
let tags_str = tags.join(" ");
|
||||
parts.push(&tags_str);
|
||||
|
||||
let combined = parts.join(" ");
|
||||
let mut full_text = stem_text(&combined, language);
|
||||
|
||||
for t in translation_texts {
|
||||
full_text.push(' ');
|
||||
full_text.push_str(&stem_text(t, language));
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO media_fts (media_id, content) VALUES (?1, ?2)",
|
||||
rusqlite::params![media_id, full_text],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a post from the FTS index.
|
||||
pub fn remove_post_from_index(conn: &Connection, post_id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM posts_fts WHERE post_id = ?1",
|
||||
rusqlite::params![post_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a media item from the FTS index.
|
||||
pub fn remove_media_from_index(conn: &Connection, media_id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM media_fts WHERE media_id = ?1",
|
||||
rusqlite::params![media_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Search posts by full-text query. Returns matching post IDs.
|
||||
pub fn search_posts(conn: &Connection, query: &str, language: &str) -> rusqlite::Result<Vec<String>> {
|
||||
let stemmed = stem_text(query, language);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT post_id FROM posts_fts WHERE posts_fts MATCH ?1 ORDER BY rank"
|
||||
)?;
|
||||
let rows = stmt.query_map(rusqlite::params![stemmed], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
/// Search media by full-text query. Returns matching media IDs.
|
||||
pub fn search_media(conn: &Connection, query: &str, language: &str) -> rusqlite::Result<Vec<String>> {
|
||||
let stemmed = stem_text(query, language);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT media_id FROM media_fts WHERE media_fts MATCH ?1 ORDER BY rank"
|
||||
)?;
|
||||
let rows = stmt.query_map(rusqlite::params![stemmed], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate();
|
||||
ensure_fts_tables(db.conn()).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fts_tables_created() {
|
||||
let db = setup();
|
||||
let count: i64 = db.conn()
|
||||
.query_row(
|
||||
"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]
|
||||
fn fts_tables_idempotent() {
|
||||
let db = setup();
|
||||
ensure_fts_tables(db.conn()).unwrap(); // second call should not error
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stem_german() {
|
||||
let stemmed = stem_text("Programmierung Entwicklung", "de");
|
||||
// German stemmer should reduce these words
|
||||
assert!(!stemmed.is_empty());
|
||||
assert_ne!(stemmed, "Programmierung Entwicklung");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stem_english() {
|
||||
let stemmed = stem_text("running jumps", "en");
|
||||
assert!(stemmed.contains("run"));
|
||||
assert!(stemmed.contains("jump"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_and_search_post() {
|
||||
let db = setup();
|
||||
index_post(
|
||||
db.conn(),
|
||||
"post-1",
|
||||
"Esmeralda Spider",
|
||||
Some("A macro photo"),
|
||||
Some("Beautiful spider web"),
|
||||
&["fotografie".into(), "natur".into()],
|
||||
&["picture".into()],
|
||||
&["Esmeralda English".into()],
|
||||
"en",
|
||||
).unwrap();
|
||||
|
||||
let results = search_posts(db.conn(), "spider", "en").unwrap();
|
||||
assert_eq!(results, vec!["post-1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_and_search_media() {
|
||||
let db = setup();
|
||||
index_media(
|
||||
db.conn(),
|
||||
"media-1",
|
||||
Some("Sunset Photo"),
|
||||
Some("Beautiful sunset over mountains"),
|
||||
None,
|
||||
"sunset.jpg",
|
||||
&["nature".into()],
|
||||
&[],
|
||||
"en",
|
||||
).unwrap();
|
||||
|
||||
let results = search_media(db.conn(), "sunset", "en").unwrap();
|
||||
assert_eq!(results, vec!["media-1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_no_results() {
|
||||
let db = setup();
|
||||
let results = search_posts(db.conn(), "nonexistent", "en").unwrap();
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_from_index() {
|
||||
let db = setup();
|
||||
index_post(
|
||||
db.conn(), "p1", "Test", None, None,
|
||||
&[], &[], &[], "en",
|
||||
).unwrap();
|
||||
assert_eq!(search_posts(db.conn(), "test", "en").unwrap().len(), 1);
|
||||
|
||||
remove_post_from_index(db.conn(), "p1").unwrap();
|
||||
assert!(search_posts(db.conn(), "test", "en").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reindex_replaces_old() {
|
||||
let db = setup();
|
||||
index_post(db.conn(), "p1", "Alpha", None, None, &[], &[], &[], "en").unwrap();
|
||||
index_post(db.conn(), "p1", "Beta", None, None, &[], &[], &[], "en").unwrap();
|
||||
|
||||
assert!(search_posts(db.conn(), "alpha", "en").unwrap().is_empty());
|
||||
assert_eq!(search_posts(db.conn(), "beta", "en").unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
mod connection;
|
||||
pub mod from_row;
|
||||
pub mod fts;
|
||||
mod migrations;
|
||||
pub mod queries;
|
||||
|
||||
pub use connection::Database;
|
||||
pub use migrations::run_migrations;
|
||||
|
||||
210
crates/bds-core/src/db/queries/media.rs
Normal file
210
crates/bds-core/src/db/queries/media.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::db::from_row::{media_from_row, MEDIA_COLUMNS};
|
||||
use crate::model::Media;
|
||||
|
||||
fn tags_to_json(tags: &[String]) -> String {
|
||||
serde_json::to_string(tags).unwrap_or_else(|_| "[]".into())
|
||||
}
|
||||
|
||||
pub fn insert_media(conn: &Connection, m: &Media) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO media (
|
||||
id, project_id, filename, original_name, mime_type, size,
|
||||
width, height, title, alt, caption, author, language,
|
||||
file_path, sidecar_path, checksum, tags, created_at, updated_at
|
||||
) VALUES (
|
||||
?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> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {MEDIA_COLUMNS} FROM media WHERE id = ?1"),
|
||||
params![id],
|
||||
media_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
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 update_media(conn: &Connection, m: &Media) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE media SET
|
||||
filename = ?1, original_name = ?2, mime_type = ?3, size = ?4,
|
||||
width = ?5, height = ?6, title = ?7, alt = ?8, caption = ?9,
|
||||
author = ?10, language = ?11, file_path = ?12, sidecar_path = ?13,
|
||||
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<()> {
|
||||
conn.execute("DELETE FROM media WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test helper: create a minimal Media value (available to sibling test modules).
|
||||
#[cfg(test)]
|
||||
pub fn make_test_media(id: &str, project_id: &str) -> Media {
|
||||
Media {
|
||||
id: id.to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
filename: format!("{id}.jpg"),
|
||||
original_name: "photo.jpg".to_string(),
|
||||
mime_type: "image/jpeg".to_string(),
|
||||
size: 50000,
|
||||
width: Some(1920),
|
||||
height: Some(1080),
|
||||
title: None,
|
||||
alt: None,
|
||||
caption: None,
|
||||
author: None,
|
||||
language: None,
|
||||
file_path: format!("/media/{id}.jpg"),
|
||||
sidecar_path: format!("/media/{id}.jpg.meta"),
|
||||
checksum: None,
|
||||
tags: vec![],
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
fn make_media_item(id: &str) -> Media {
|
||||
Media {
|
||||
id: id.to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
filename: "abc.jpg".to_string(),
|
||||
original_name: "photo.jpg".to_string(),
|
||||
mime_type: "image/jpeg".to_string(),
|
||||
size: 50000,
|
||||
width: Some(1920),
|
||||
height: Some(1080),
|
||||
title: Some("Sunset".into()),
|
||||
alt: Some("A sunset".into()),
|
||||
caption: None,
|
||||
author: Some("Bob".into()),
|
||||
language: Some("en".into()),
|
||||
file_path: "/media/abc.jpg".to_string(),
|
||||
sidecar_path: "/media/abc.jpg.meta".to_string(),
|
||||
checksum: Some("hash1".into()),
|
||||
tags: vec!["nature".into()],
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_get_by_id() {
|
||||
let db = setup();
|
||||
let m = make_media_item("m1");
|
||||
insert_media(db.conn(), &m).unwrap();
|
||||
let fetched = get_media_by_id(db.conn(), "m1").unwrap();
|
||||
assert_eq!(fetched.original_name, "photo.jpg");
|
||||
assert_eq!(fetched.width, Some(1920));
|
||||
assert_eq!(fetched.height, Some(1080));
|
||||
assert_eq!(fetched.tags, vec!["nature"]);
|
||||
assert_eq!(fetched.size, 50000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_by_project() {
|
||||
let db = setup();
|
||||
let mut m1 = make_media_item("m1");
|
||||
m1.created_at = 1000;
|
||||
let mut m2 = make_media_item("m2");
|
||||
m2.filename = "def.jpg".into();
|
||||
m2.created_at = 2000;
|
||||
insert_media(db.conn(), &m1).unwrap();
|
||||
insert_media(db.conn(), &m2).unwrap();
|
||||
let list = list_media_by_project(db.conn(), "p1").unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].id, "m2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_media_fields() {
|
||||
let db = setup();
|
||||
let mut m = make_media_item("m1");
|
||||
insert_media(db.conn(), &m).unwrap();
|
||||
m.title = Some("New Title".into());
|
||||
m.tags = vec!["updated".into()];
|
||||
m.updated_at = 9999;
|
||||
update_media(db.conn(), &m).unwrap();
|
||||
let fetched = get_media_by_id(db.conn(), "m1").unwrap();
|
||||
assert_eq!(fetched.title.as_deref(), Some("New Title"));
|
||||
assert_eq!(fetched.tags, vec!["updated"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_removes_media() {
|
||||
let db = setup();
|
||||
insert_media(db.conn(), &make_media_item("m1")).unwrap();
|
||||
delete_media(db.conn(), "m1").unwrap();
|
||||
assert!(get_media_by_id(db.conn(), "m1").is_err());
|
||||
}
|
||||
}
|
||||
171
crates/bds-core/src/db/queries/media_translation.rs
Normal file
171
crates/bds-core/src/db/queries/media_translation.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::db::from_row::{media_translation_from_row, MEDIA_TRANSLATION_COLUMNS};
|
||||
use crate::model::MediaTranslation;
|
||||
|
||||
pub fn insert_media_translation(
|
||||
conn: &Connection,
|
||||
t: &MediaTranslation,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO media_translations (
|
||||
id, project_id, translation_for, language, title, alt, caption,
|
||||
created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
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(
|
||||
conn: &Connection,
|
||||
translation_for: &str,
|
||||
language: &str,
|
||||
) -> rusqlite::Result<MediaTranslation> {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT {MEDIA_TRANSLATION_COLUMNS} FROM media_translations
|
||||
WHERE translation_for = ?1 AND language = ?2"
|
||||
),
|
||||
params![translation_for, language],
|
||||
media_translation_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_media_translations_by_media(
|
||||
conn: &Connection,
|
||||
translation_for: &str,
|
||||
) -> rusqlite::Result<Vec<MediaTranslation>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {MEDIA_TRANSLATION_COLUMNS} FROM media_translations
|
||||
WHERE translation_for = ?1 ORDER BY language"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![translation_for], media_translation_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn upsert_media_translation(
|
||||
conn: &Connection,
|
||||
t: &MediaTranslation,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO media_translations (
|
||||
id, project_id, translation_for, language, title, alt, caption,
|
||||
created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||
ON CONFLICT(translation_for, language) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
alt = excluded.alt,
|
||||
caption = excluded.caption,
|
||||
updated_at = excluded.updated_at",
|
||||
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 delete_media_translation(
|
||||
conn: &Connection,
|
||||
translation_for: &str,
|
||||
language: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM media_translations WHERE translation_for = ?1 AND language = ?2",
|
||||
params![translation_for, language],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::media::{insert_media, make_test_media};
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
insert_media(db.conn(), &make_test_media("m1", "p1")).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
fn make_mt(id: &str, lang: &str) -> MediaTranslation {
|
||||
MediaTranslation {
|
||||
id: id.to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
translation_for: "m1".to_string(),
|
||||
language: lang.to_string(),
|
||||
title: Some(format!("Title {lang}")),
|
||||
alt: Some(format!("Alt {lang}")),
|
||||
caption: None,
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_get() {
|
||||
let db = setup();
|
||||
insert_media_translation(db.conn(), &make_mt("mt1", "de")).unwrap();
|
||||
let t = get_media_translation_by_media_and_language(db.conn(), "m1", "de").unwrap();
|
||||
assert_eq!(t.title.as_deref(), Some("Title de"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_by_media() {
|
||||
let db = setup();
|
||||
insert_media_translation(db.conn(), &make_mt("mt1", "de")).unwrap();
|
||||
insert_media_translation(db.conn(), &make_mt("mt2", "fr")).unwrap();
|
||||
let list = list_media_translations_by_media(db.conn(), "m1").unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].language, "de");
|
||||
assert_eq!(list[1].language, "fr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_inserts_then_updates() {
|
||||
let db = setup();
|
||||
let mut t = make_mt("mt1", "de");
|
||||
upsert_media_translation(db.conn(), &t).unwrap();
|
||||
let fetched = get_media_translation_by_media_and_language(db.conn(), "m1", "de").unwrap();
|
||||
assert_eq!(fetched.title.as_deref(), Some("Title de"));
|
||||
|
||||
t.title = Some("Neuer Titel".into());
|
||||
t.updated_at = 9999;
|
||||
upsert_media_translation(db.conn(), &t).unwrap();
|
||||
let fetched = get_media_translation_by_media_and_language(db.conn(), "m1", "de").unwrap();
|
||||
assert_eq!(fetched.title.as_deref(), Some("Neuer Titel"));
|
||||
assert_eq!(fetched.updated_at, 9999);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_translation() {
|
||||
let db = setup();
|
||||
insert_media_translation(db.conn(), &make_mt("mt1", "de")).unwrap();
|
||||
delete_media_translation(db.conn(), "m1", "de").unwrap();
|
||||
assert!(
|
||||
get_media_translation_by_media_and_language(db.conn(), "m1", "de").is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
11
crates/bds-core/src/db/queries/mod.rs
Normal file
11
crates/bds-core/src/db/queries/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
pub mod project;
|
||||
pub mod post;
|
||||
pub mod post_translation;
|
||||
pub mod media;
|
||||
pub mod media_translation;
|
||||
pub mod tag;
|
||||
pub mod post_link;
|
||||
pub mod post_media;
|
||||
pub mod template;
|
||||
pub mod script;
|
||||
pub mod setting;
|
||||
339
crates/bds-core/src/db/queries/post.rs
Normal file
339
crates/bds-core/src/db/queries/post.rs
Normal file
@@ -0,0 +1,339 @@
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::db::from_row::{post_from_row, post_status_to_str, POST_COLUMNS};
|
||||
use crate::model::{Post, PostStatus};
|
||||
|
||||
fn tags_to_json(tags: &[String]) -> String {
|
||||
serde_json::to_string(tags).unwrap_or_else(|_| "[]".into())
|
||||
}
|
||||
|
||||
pub fn insert_post(conn: &Connection, post: &Post) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO posts (
|
||||
id, project_id, title, slug, excerpt, content, status, author,
|
||||
language, do_not_translate, template_slug, file_path, checksum,
|
||||
tags, categories,
|
||||
published_title, published_content, published_tags,
|
||||
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(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<Post> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {POST_COLUMNS} FROM posts WHERE project_id = ?1 AND slug = ?2"),
|
||||
params![project_id, slug],
|
||||
post_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_posts_by_project(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<Post>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {POST_COLUMNS} FROM posts WHERE project_id = ?1 ORDER BY created_at DESC"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![project_id], post_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn update_post(conn: &Connection, post: &Post) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE posts SET
|
||||
title = ?1, slug = ?2, excerpt = ?3, content = ?4, status = ?5,
|
||||
author = ?6, language = ?7, do_not_translate = ?8, template_slug = ?9,
|
||||
file_path = ?10, checksum = ?11, tags = ?12, categories = ?13,
|
||||
published_title = ?14, published_content = ?15, published_tags = ?16,
|
||||
published_categories = ?17, published_excerpt = ?18,
|
||||
updated_at = ?19, published_at = ?20
|
||||
WHERE id = ?21",
|
||||
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.updated_at,
|
||||
post.published_at,
|
||||
post.id,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_post_status(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
status: &PostStatus,
|
||||
updated_at: i64,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE posts SET status = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![post_status_to_str(status), updated_at, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_post_content(conn: &Connection, id: &str, updated_at: i64) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE posts SET content = NULL, updated_at = ?1 WHERE id = ?2",
|
||||
params![updated_at, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_post_file_path(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
file_path: &str,
|
||||
updated_at: i64,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE posts SET file_path = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![file_path, updated_at, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_published_snapshot(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
title: &str,
|
||||
content: &str,
|
||||
tags: &str,
|
||||
categories: &str,
|
||||
excerpt: Option<&str>,
|
||||
published_at: i64,
|
||||
updated_at: i64,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE posts SET
|
||||
published_title = ?1, published_content = ?2, published_tags = ?3,
|
||||
published_categories = ?4, published_excerpt = ?5,
|
||||
published_at = ?6, updated_at = ?7
|
||||
WHERE id = ?8",
|
||||
params![title, content, tags, categories, excerpt, published_at, updated_at, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_post(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM posts WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn count_posts_by_project(conn: &Connection, project_id: &str) -> rusqlite::Result<i64> {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM posts WHERE project_id = ?1",
|
||||
params![project_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
fn make_post(id: &str, slug: &str) -> Post {
|
||||
Post {
|
||||
id: id.to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
title: format!("Post {id}"),
|
||||
slug: slug.to_string(),
|
||||
excerpt: Some("excerpt".into()),
|
||||
content: Some("body".into()),
|
||||
status: PostStatus::Draft,
|
||||
author: Some("Alice".into()),
|
||||
language: Some("en".into()),
|
||||
do_not_translate: false,
|
||||
template_slug: None,
|
||||
file_path: format!("posts/{slug}.md"),
|
||||
checksum: None,
|
||||
tags: vec!["rust".into()],
|
||||
categories: vec!["tech".into()],
|
||||
published_title: None,
|
||||
published_content: None,
|
||||
published_tags: None,
|
||||
published_categories: None,
|
||||
published_excerpt: None,
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
published_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_get_by_id() {
|
||||
let db = setup();
|
||||
let post = make_post("x1", "hello");
|
||||
insert_post(db.conn(), &post).unwrap();
|
||||
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
|
||||
assert_eq!(fetched.title, "Post x1");
|
||||
assert_eq!(fetched.tags, vec!["rust"]);
|
||||
assert_eq!(fetched.categories, vec!["tech"]);
|
||||
assert_eq!(fetched.status, PostStatus::Draft);
|
||||
assert!(!fetched.do_not_translate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_by_project_and_slug() {
|
||||
let db = setup();
|
||||
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
|
||||
let fetched = get_post_by_project_and_slug(db.conn(), "p1", "hello").unwrap();
|
||||
assert_eq!(fetched.id, "x1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_posts_ordered_by_created_at_desc() {
|
||||
let db = setup();
|
||||
let mut p1 = make_post("x1", "first");
|
||||
p1.created_at = 1000;
|
||||
let mut p2 = make_post("x2", "second");
|
||||
p2.created_at = 2000;
|
||||
insert_post(db.conn(), &p1).unwrap();
|
||||
insert_post(db.conn(), &p2).unwrap();
|
||||
let list = list_posts_by_project(db.conn(), "p1").unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].id, "x2");
|
||||
assert_eq!(list[1].id, "x1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_post_fields() {
|
||||
let db = setup();
|
||||
let mut post = make_post("x1", "hello");
|
||||
insert_post(db.conn(), &post).unwrap();
|
||||
post.title = "Updated".into();
|
||||
post.tags = vec!["new-tag".into()];
|
||||
post.updated_at = 9999;
|
||||
update_post(db.conn(), &post).unwrap();
|
||||
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
|
||||
assert_eq!(fetched.title, "Updated");
|
||||
assert_eq!(fetched.tags, vec!["new-tag"]);
|
||||
assert_eq!(fetched.updated_at, 9999);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_status() {
|
||||
let db = setup();
|
||||
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
|
||||
update_post_status(db.conn(), "x1", &PostStatus::Published, 5000).unwrap();
|
||||
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
|
||||
assert_eq!(fetched.status, PostStatus::Published);
|
||||
assert_eq!(fetched.updated_at, 5000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_content_sets_null() {
|
||||
let db = setup();
|
||||
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
|
||||
clear_post_content(db.conn(), "x1", 5000).unwrap();
|
||||
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
|
||||
assert!(fetched.content.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_file_path_updates() {
|
||||
let db = setup();
|
||||
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
|
||||
set_post_file_path(db.conn(), "x1", "posts/2024/01/hello.md", 5000).unwrap();
|
||||
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
|
||||
assert_eq!(fetched.file_path, "posts/2024/01/hello.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn published_snapshot() {
|
||||
let db = setup();
|
||||
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
|
||||
set_published_snapshot(
|
||||
db.conn(), "x1", "Pub Title", "Pub Body",
|
||||
"[\"rust\"]", "[\"tech\"]", Some("Pub Excerpt"), 3000, 3000,
|
||||
).unwrap();
|
||||
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
|
||||
assert_eq!(fetched.published_title.as_deref(), Some("Pub Title"));
|
||||
assert_eq!(fetched.published_content.as_deref(), Some("Pub Body"));
|
||||
assert_eq!(fetched.published_at, Some(3000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_removes_post() {
|
||||
let db = setup();
|
||||
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
|
||||
delete_post(db.conn(), "x1").unwrap();
|
||||
assert!(get_post_by_id(db.conn(), "x1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_posts() {
|
||||
let db = setup();
|
||||
assert_eq!(count_posts_by_project(db.conn(), "p1").unwrap(), 0);
|
||||
insert_post(db.conn(), &make_post("x1", "a")).unwrap();
|
||||
insert_post(db.conn(), &make_post("x2", "b")).unwrap();
|
||||
assert_eq!(count_posts_by_project(db.conn(), "p1").unwrap(), 2);
|
||||
}
|
||||
}
|
||||
117
crates/bds-core/src/db/queries/post_link.rs
Normal file
117
crates/bds-core/src/db/queries/post_link.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::db::from_row::{post_link_from_row, POST_LINK_COLUMNS};
|
||||
use crate::model::PostLink;
|
||||
|
||||
pub fn insert_post_link(conn: &Connection, link: &PostLink) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO post_links (id, source_post_id, target_post_id, link_text, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
link.id,
|
||||
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<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM post_links WHERE source_post_id = ?1",
|
||||
params![source_post_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_links_by_source(
|
||||
conn: &Connection,
|
||||
source_post_id: &str,
|
||||
) -> rusqlite::Result<Vec<PostLink>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {POST_LINK_COLUMNS} FROM post_links WHERE source_post_id = ?1 ORDER BY created_at"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![source_post_id], post_link_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn list_links_by_target(
|
||||
conn: &Connection,
|
||||
target_post_id: &str,
|
||||
) -> rusqlite::Result<Vec<PostLink>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {POST_LINK_COLUMNS} FROM post_links WHERE target_post_id = ?1 ORDER BY created_at"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![target_post_id], post_link_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
let c = db.conn();
|
||||
c.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||
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
|
||||
}
|
||||
|
||||
fn make_link(id: &str, src: &str, tgt: &str) -> PostLink {
|
||||
PostLink {
|
||||
id: id.to_string(),
|
||||
source_post_id: src.to_string(),
|
||||
target_post_id: tgt.to_string(),
|
||||
link_text: Some("see also".into()),
|
||||
created_at: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_list_by_source() {
|
||||
let db = setup();
|
||||
insert_post_link(db.conn(), &make_link("l1", "a", "b")).unwrap();
|
||||
insert_post_link(db.conn(), &make_link("l2", "a", "c")).unwrap();
|
||||
let links = list_links_by_source(db.conn(), "a").unwrap();
|
||||
assert_eq!(links.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_by_target() {
|
||||
let db = setup();
|
||||
insert_post_link(db.conn(), &make_link("l1", "a", "c")).unwrap();
|
||||
insert_post_link(db.conn(), &make_link("l2", "b", "c")).unwrap();
|
||||
let links = list_links_by_target(db.conn(), "c").unwrap();
|
||||
assert_eq!(links.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_by_source() {
|
||||
let db = setup();
|
||||
insert_post_link(db.conn(), &make_link("l1", "a", "b")).unwrap();
|
||||
insert_post_link(db.conn(), &make_link("l2", "a", "c")).unwrap();
|
||||
delete_links_by_source(db.conn(), "a").unwrap();
|
||||
let links = list_links_by_source(db.conn(), "a").unwrap();
|
||||
assert!(links.is_empty());
|
||||
}
|
||||
}
|
||||
144
crates/bds-core/src/db/queries/post_media.rs
Normal file
144
crates/bds-core/src/db/queries/post_media.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::db::from_row::{post_media_from_row, POST_MEDIA_COLUMNS};
|
||||
use crate::model::PostMedia;
|
||||
|
||||
pub fn link_media(conn: &Connection, pm: &PostMedia) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![
|
||||
pm.id,
|
||||
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<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM post_media WHERE post_id = ?1 AND media_id = ?2",
|
||||
params![post_id, media_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_post_media_by_post(
|
||||
conn: &Connection,
|
||||
post_id: &str,
|
||||
) -> rusqlite::Result<Vec<PostMedia>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"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_media(
|
||||
conn: &Connection,
|
||||
media_id: &str,
|
||||
) -> rusqlite::Result<Vec<PostMedia>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {POST_MEDIA_COLUMNS} FROM post_media WHERE media_id = ?1 ORDER BY created_at"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![media_id], post_media_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn update_sort_order(
|
||||
conn: &Connection,
|
||||
post_id: &str,
|
||||
media_id: &str,
|
||||
sort_order: i32,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE post_media SET sort_order = ?1 WHERE post_id = ?2 AND media_id = ?3",
|
||||
params![sort_order, post_id, media_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::media::{insert_media, make_test_media};
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).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();
|
||||
insert_media(db.conn(), &make_test_media("m1", "p1")).unwrap();
|
||||
insert_media(db.conn(), &make_test_media("m2", "p1")).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
fn make_pm(id: &str, media_id: &str, order: i32) -> PostMedia {
|
||||
PostMedia {
|
||||
id: id.to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
post_id: "post1".to_string(),
|
||||
media_id: media_id.to_string(),
|
||||
sort_order: order,
|
||||
created_at: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_and_list_by_post() {
|
||||
let db = setup();
|
||||
link_media(db.conn(), &make_pm("pm1", "m1", 1)).unwrap();
|
||||
link_media(db.conn(), &make_pm("pm2", "m2", 0)).unwrap();
|
||||
let list = list_post_media_by_post(db.conn(), "post1").unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].media_id, "m2"); // sort_order 0 first
|
||||
assert_eq!(list[1].media_id, "m1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_by_media() {
|
||||
let db = setup();
|
||||
link_media(db.conn(), &make_pm("pm1", "m1", 0)).unwrap();
|
||||
let list = list_post_media_by_media(db.conn(), "m1").unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].post_id, "post1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlink_removes_association() {
|
||||
let db = setup();
|
||||
link_media(db.conn(), &make_pm("pm1", "m1", 0)).unwrap();
|
||||
unlink_media(db.conn(), "post1", "m1").unwrap();
|
||||
let list = list_post_media_by_post(db.conn(), "post1").unwrap();
|
||||
assert!(list.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_sort_order_changes_value() {
|
||||
let db = setup();
|
||||
link_media(db.conn(), &make_pm("pm1", "m1", 0)).unwrap();
|
||||
update_sort_order(db.conn(), "post1", "m1", 10).unwrap();
|
||||
let list = list_post_media_by_post(db.conn(), "post1").unwrap();
|
||||
assert_eq!(list[0].sort_order, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_post_media_rejected() {
|
||||
let db = setup();
|
||||
link_media(db.conn(), &make_pm("pm1", "m1", 0)).unwrap();
|
||||
let result = link_media(db.conn(), &make_pm("pm2", "m1", 1));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
221
crates/bds-core/src/db/queries/post_translation.rs
Normal file
221
crates/bds-core/src/db/queries/post_translation.rs
Normal file
@@ -0,0 +1,221 @@
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::db::from_row::{post_status_to_str, post_translation_from_row, POST_TRANSLATION_COLUMNS};
|
||||
use crate::model::PostTranslation;
|
||||
|
||||
pub fn insert_post_translation(
|
||||
conn: &Connection,
|
||||
t: &PostTranslation,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO post_translations (
|
||||
id, project_id, translation_for, language, title, excerpt, content,
|
||||
status, file_path, checksum, created_at, updated_at, published_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
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(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
) -> rusqlite::Result<PostTranslation> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {POST_TRANSLATION_COLUMNS} FROM post_translations WHERE id = ?1"),
|
||||
params![id],
|
||||
post_translation_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_post_translation_by_post_and_language(
|
||||
conn: &Connection,
|
||||
translation_for: &str,
|
||||
language: &str,
|
||||
) -> rusqlite::Result<PostTranslation> {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT {POST_TRANSLATION_COLUMNS} FROM post_translations
|
||||
WHERE translation_for = ?1 AND language = ?2"
|
||||
),
|
||||
params![translation_for, language],
|
||||
post_translation_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_post_translations_by_post(
|
||||
conn: &Connection,
|
||||
translation_for: &str,
|
||||
) -> rusqlite::Result<Vec<PostTranslation>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {POST_TRANSLATION_COLUMNS} FROM post_translations
|
||||
WHERE translation_for = ?1 ORDER BY language"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![translation_for], post_translation_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn update_post_translation(
|
||||
conn: &Connection,
|
||||
t: &PostTranslation,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE post_translations SET
|
||||
title = ?1, excerpt = ?2, content = ?3, status = ?4,
|
||||
file_path = ?5, checksum = ?6, updated_at = ?7, published_at = ?8
|
||||
WHERE id = ?9",
|
||||
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<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM post_translations WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_all_translations_for_post(
|
||||
conn: &Connection,
|
||||
translation_for: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM post_translations WHERE translation_for = ?1",
|
||||
params![translation_for],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::model::PostStatus;
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
// insert a parent post
|
||||
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
|
||||
}
|
||||
|
||||
fn make_translation(id: &str, lang: &str) -> PostTranslation {
|
||||
PostTranslation {
|
||||
id: id.to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
translation_for: "post1".to_string(),
|
||||
language: lang.to_string(),
|
||||
title: format!("Title {lang}"),
|
||||
excerpt: Some("excerpt".into()),
|
||||
content: Some("body".into()),
|
||||
status: PostStatus::Draft,
|
||||
file_path: format!("posts/hello.{lang}.md"),
|
||||
checksum: None,
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
published_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_get_by_id() {
|
||||
let db = setup();
|
||||
insert_post_translation(db.conn(), &make_translation("t1", "de")).unwrap();
|
||||
let t = get_post_translation_by_id(db.conn(), "t1").unwrap();
|
||||
assert_eq!(t.language, "de");
|
||||
assert_eq!(t.title, "Title de");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_by_post_and_language() {
|
||||
let db = setup();
|
||||
insert_post_translation(db.conn(), &make_translation("t1", "de")).unwrap();
|
||||
let t = get_post_translation_by_post_and_language(db.conn(), "post1", "de").unwrap();
|
||||
assert_eq!(t.id, "t1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_by_post() {
|
||||
let db = setup();
|
||||
insert_post_translation(db.conn(), &make_translation("t1", "de")).unwrap();
|
||||
insert_post_translation(db.conn(), &make_translation("t2", "fr")).unwrap();
|
||||
let list = list_post_translations_by_post(db.conn(), "post1").unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].language, "de");
|
||||
assert_eq!(list[1].language, "fr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_translation() {
|
||||
let db = setup();
|
||||
let mut t = make_translation("t1", "de");
|
||||
insert_post_translation(db.conn(), &t).unwrap();
|
||||
t.title = "Neuer Titel".into();
|
||||
t.updated_at = 9999;
|
||||
update_post_translation(db.conn(), &t).unwrap();
|
||||
let fetched = get_post_translation_by_id(db.conn(), "t1").unwrap();
|
||||
assert_eq!(fetched.title, "Neuer Titel");
|
||||
assert_eq!(fetched.updated_at, 9999);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_single() {
|
||||
let db = setup();
|
||||
insert_post_translation(db.conn(), &make_translation("t1", "de")).unwrap();
|
||||
delete_post_translation(db.conn(), "t1").unwrap();
|
||||
assert!(get_post_translation_by_id(db.conn(), "t1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_all_for_post() {
|
||||
let db = setup();
|
||||
insert_post_translation(db.conn(), &make_translation("t1", "de")).unwrap();
|
||||
insert_post_translation(db.conn(), &make_translation("t2", "fr")).unwrap();
|
||||
delete_all_translations_for_post(db.conn(), "post1").unwrap();
|
||||
let list = list_post_translations_by_post(db.conn(), "post1").unwrap();
|
||||
assert!(list.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_language_rejected() {
|
||||
let db = setup();
|
||||
insert_post_translation(db.conn(), &make_translation("t1", "de")).unwrap();
|
||||
let result = insert_post_translation(db.conn(), &make_translation("t2", "de"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
215
crates/bds-core/src/db/queries/project.rs
Normal file
215
crates/bds-core/src/db/queries/project.rs
Normal file
@@ -0,0 +1,215 @@
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::db::from_row::{project_from_row, PROJECT_COLUMNS};
|
||||
use crate::model::Project;
|
||||
|
||||
pub fn insert_project(conn: &Connection, project: &Project) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO projects (id, name, slug, description, data_path, is_active, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
params![
|
||||
project.id,
|
||||
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> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE id = ?1"),
|
||||
params![id],
|
||||
project_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_project_by_slug(conn: &Connection, slug: &str) -> rusqlite::Result<Project> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE slug = ?1"),
|
||||
params![slug],
|
||||
project_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_active_project(conn: &Connection) -> rusqlite::Result<Project> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE is_active = 1 LIMIT 1"),
|
||||
[],
|
||||
project_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn set_active_project(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("UPDATE projects SET is_active = 0 WHERE is_active = 1", [])?;
|
||||
conn.execute(
|
||||
"UPDATE projects SET is_active = 1 WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_projects(conn: &Connection) -> rusqlite::Result<Vec<Project>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {PROJECT_COLUMNS} FROM projects ORDER BY name"
|
||||
))?;
|
||||
let rows = stmt.query_map([], project_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn update_project(conn: &Connection, project: &Project) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE projects SET name = ?1, slug = ?2, description = ?3, data_path = ?4,
|
||||
is_active = ?5, updated_at = ?6
|
||||
WHERE id = ?7",
|
||||
params![
|
||||
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<()> {
|
||||
conn.execute("DELETE FROM projects WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test helper: create a minimal Project value (available to sibling test modules).
|
||||
#[cfg(test)]
|
||||
pub fn make_test_project(id: &str, slug: &str) -> Project {
|
||||
Project {
|
||||
id: id.to_string(),
|
||||
name: format!("Project {id}"),
|
||||
slug: slug.to_string(),
|
||||
description: Some("A test project".into()),
|
||||
data_path: Some("/data".into()),
|
||||
is_active: false,
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
fn make_project(id: &str, slug: &str) -> Project {
|
||||
Project {
|
||||
id: id.to_string(),
|
||||
name: format!("Project {id}"),
|
||||
slug: slug.to_string(),
|
||||
description: Some("A test project".into()),
|
||||
data_path: Some("/data".into()),
|
||||
is_active: false,
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_get_by_id() {
|
||||
let db = setup();
|
||||
let p = make_project("p1", "blog");
|
||||
insert_project(db.conn(), &p).unwrap();
|
||||
let fetched = get_project_by_id(db.conn(), "p1").unwrap();
|
||||
assert_eq!(fetched.id, "p1");
|
||||
assert_eq!(fetched.name, "Project p1");
|
||||
assert_eq!(fetched.slug, "blog");
|
||||
assert_eq!(fetched.description.as_deref(), Some("A test project"));
|
||||
assert_eq!(fetched.data_path.as_deref(), Some("/data"));
|
||||
assert!(!fetched.is_active);
|
||||
assert_eq!(fetched.created_at, 1000);
|
||||
assert_eq!(fetched.updated_at, 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_by_slug() {
|
||||
let db = setup();
|
||||
insert_project(db.conn(), &make_project("p1", "my-blog")).unwrap();
|
||||
let fetched = get_project_by_slug(db.conn(), "my-blog").unwrap();
|
||||
assert_eq!(fetched.id, "p1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_project_flow() {
|
||||
let db = setup();
|
||||
insert_project(db.conn(), &make_project("p1", "blog1")).unwrap();
|
||||
insert_project(db.conn(), &make_project("p2", "blog2")).unwrap();
|
||||
|
||||
set_active_project(db.conn(), "p1").unwrap();
|
||||
let active = get_active_project(db.conn()).unwrap();
|
||||
assert_eq!(active.id, "p1");
|
||||
|
||||
set_active_project(db.conn(), "p2").unwrap();
|
||||
let active = get_active_project(db.conn()).unwrap();
|
||||
assert_eq!(active.id, "p2");
|
||||
|
||||
let p1 = get_project_by_id(db.conn(), "p1").unwrap();
|
||||
assert!(!p1.is_active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_projects_ordered() {
|
||||
let db = setup();
|
||||
let mut p2 = make_project("p2", "zebra");
|
||||
p2.name = "Zebra".into();
|
||||
let mut p1 = make_project("p1", "alpha");
|
||||
p1.name = "Alpha".into();
|
||||
insert_project(db.conn(), &p2).unwrap();
|
||||
insert_project(db.conn(), &p1).unwrap();
|
||||
let list = list_projects(db.conn()).unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].name, "Alpha");
|
||||
assert_eq!(list[1].name, "Zebra");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_project_fields() {
|
||||
let db = setup();
|
||||
let mut p = make_project("p1", "blog");
|
||||
insert_project(db.conn(), &p).unwrap();
|
||||
p.name = "Updated".into();
|
||||
p.description = None;
|
||||
p.updated_at = 9999;
|
||||
update_project(db.conn(), &p).unwrap();
|
||||
let fetched = get_project_by_id(db.conn(), "p1").unwrap();
|
||||
assert_eq!(fetched.name, "Updated");
|
||||
assert!(fetched.description.is_none());
|
||||
assert_eq!(fetched.updated_at, 9999);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_project_removes_row() {
|
||||
let db = setup();
|
||||
insert_project(db.conn(), &make_project("p1", "blog")).unwrap();
|
||||
delete_project(db.conn(), "p1").unwrap();
|
||||
let result = get_project_by_id(db.conn(), "p1");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_slug_rejected() {
|
||||
let db = setup();
|
||||
insert_project(db.conn(), &make_project("p1", "blog")).unwrap();
|
||||
let result = insert_project(db.conn(), &make_project("p2", "blog"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
194
crates/bds-core/src/db/queries/script.rs
Normal file
194
crates/bds-core/src/db/queries/script.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::db::from_row::{
|
||||
script_from_row, script_kind_to_str, script_status_to_str, SCRIPT_COLUMNS,
|
||||
};
|
||||
use crate::model::Script;
|
||||
|
||||
pub fn insert_script(conn: &Connection, s: &Script) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO scripts (
|
||||
id, project_id, slug, title, kind, entrypoint, enabled, version,
|
||||
file_path, status, content, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
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> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {SCRIPT_COLUMNS} FROM scripts WHERE id = ?1"),
|
||||
params![id],
|
||||
script_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_script_by_slug(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<Script> {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT {SCRIPT_COLUMNS} FROM scripts WHERE project_id = ?1 AND slug = ?2"
|
||||
),
|
||||
params![project_id, slug],
|
||||
script_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_scripts_by_project(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> rusqlite::Result<Vec<Script>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {SCRIPT_COLUMNS} FROM scripts WHERE project_id = ?1 ORDER BY title"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![project_id], script_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn update_script(conn: &Connection, s: &Script) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE scripts SET
|
||||
slug = ?1, title = ?2, kind = ?3, entrypoint = ?4, enabled = ?5,
|
||||
version = ?6, file_path = ?7, status = ?8, content = ?9, updated_at = ?10
|
||||
WHERE id = ?11",
|
||||
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<()> {
|
||||
conn.execute("DELETE FROM scripts WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::model::{ScriptKind, ScriptStatus};
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
fn make_script(id: &str, slug: &str) -> Script {
|
||||
Script {
|
||||
id: id.to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
slug: slug.to_string(),
|
||||
title: format!("Script {slug}"),
|
||||
kind: ScriptKind::Macro,
|
||||
entrypoint: "render".to_string(),
|
||||
enabled: true,
|
||||
version: 1,
|
||||
file_path: format!("scripts/{slug}.lua"),
|
||||
status: ScriptStatus::Published,
|
||||
content: Some("return html".into()),
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_get_by_id() {
|
||||
let db = setup();
|
||||
insert_script(db.conn(), &make_script("s1", "gallery")).unwrap();
|
||||
let s = get_script_by_id(db.conn(), "s1").unwrap();
|
||||
assert_eq!(s.slug, "gallery");
|
||||
assert_eq!(s.kind, ScriptKind::Macro);
|
||||
assert_eq!(s.entrypoint, "render");
|
||||
assert!(s.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_by_slug() {
|
||||
let db = setup();
|
||||
insert_script(db.conn(), &make_script("s1", "gallery")).unwrap();
|
||||
let s = get_script_by_slug(db.conn(), "p1", "gallery").unwrap();
|
||||
assert_eq!(s.id, "s1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_by_project() {
|
||||
let db = setup();
|
||||
let mut s1 = make_script("s1", "zebra");
|
||||
s1.title = "Zebra".into();
|
||||
let mut s2 = make_script("s2", "alpha");
|
||||
s2.title = "Alpha".into();
|
||||
insert_script(db.conn(), &s1).unwrap();
|
||||
insert_script(db.conn(), &s2).unwrap();
|
||||
let list = list_scripts_by_project(db.conn(), "p1").unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].title, "Alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_script_fields() {
|
||||
let db = setup();
|
||||
let mut s = make_script("s1", "gallery");
|
||||
insert_script(db.conn(), &s).unwrap();
|
||||
s.kind = ScriptKind::Utility;
|
||||
s.enabled = false;
|
||||
s.version = 3;
|
||||
s.status = ScriptStatus::Draft;
|
||||
s.content = None;
|
||||
s.updated_at = 9999;
|
||||
update_script(db.conn(), &s).unwrap();
|
||||
let fetched = get_script_by_id(db.conn(), "s1").unwrap();
|
||||
assert_eq!(fetched.kind, ScriptKind::Utility);
|
||||
assert!(!fetched.enabled);
|
||||
assert_eq!(fetched.version, 3);
|
||||
assert_eq!(fetched.status, ScriptStatus::Draft);
|
||||
assert!(fetched.content.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_removes_script() {
|
||||
let db = setup();
|
||||
insert_script(db.conn(), &make_script("s1", "gallery")).unwrap();
|
||||
delete_script(db.conn(), "s1").unwrap();
|
||||
assert!(get_script_by_id(db.conn(), "s1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_slug_rejected() {
|
||||
let db = setup();
|
||||
insert_script(db.conn(), &make_script("s1", "gallery")).unwrap();
|
||||
let result = insert_script(db.conn(), &make_script("s2", "gallery"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
83
crates/bds-core/src/db/queries/setting.rs
Normal file
83
crates/bds-core/src/db/queries/setting.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::db::from_row::{setting_from_row, SETTING_COLUMNS};
|
||||
use crate::model::Setting;
|
||||
|
||||
pub fn get_setting_by_key(conn: &Connection, key: &str) -> rusqlite::Result<Setting> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {SETTING_COLUMNS} FROM settings WHERE key = ?1"),
|
||||
params![key],
|
||||
setting_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn set_setting_value(
|
||||
conn: &Connection,
|
||||
key: &str,
|
||||
value: &str,
|
||||
updated_at: i64,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value, updated_at) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
|
||||
params![key, value, updated_at],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_all_settings(conn: &Connection) -> rusqlite::Result<Vec<Setting>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {SETTING_COLUMNS} FROM settings ORDER BY key"
|
||||
))?;
|
||||
let rows = stmt.query_map([], setting_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_and_get() {
|
||||
let db = setup();
|
||||
set_setting_value(db.conn(), "theme", "dark", 1000).unwrap();
|
||||
let s = get_setting_by_key(db.conn(), "theme").unwrap();
|
||||
assert_eq!(s.key, "theme");
|
||||
assert_eq!(s.value, "dark");
|
||||
assert_eq!(s.updated_at, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_updates_existing() {
|
||||
let db = setup();
|
||||
set_setting_value(db.conn(), "theme", "dark", 1000).unwrap();
|
||||
set_setting_value(db.conn(), "theme", "light", 2000).unwrap();
|
||||
let s = get_setting_by_key(db.conn(), "theme").unwrap();
|
||||
assert_eq!(s.value, "light");
|
||||
assert_eq!(s.updated_at, 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_all() {
|
||||
let db = setup();
|
||||
set_setting_value(db.conn(), "b_key", "val_b", 1000).unwrap();
|
||||
set_setting_value(db.conn(), "a_key", "val_a", 1000).unwrap();
|
||||
let list = list_all_settings(db.conn()).unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].key, "a_key");
|
||||
assert_eq!(list[1].key, "b_key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_nonexistent_returns_error() {
|
||||
let db = setup();
|
||||
assert!(get_setting_by_key(db.conn(), "nope").is_err());
|
||||
}
|
||||
}
|
||||
157
crates/bds-core/src/db/queries/tag.rs
Normal file
157
crates/bds-core/src/db/queries/tag.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::db::from_row::{tag_from_row, TAG_COLUMNS};
|
||||
use crate::model::Tag;
|
||||
|
||||
pub fn insert_tag(conn: &Connection, tag: &Tag) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO tags (id, project_id, name, color, post_template_slug, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
tag.id,
|
||||
tag.project_id,
|
||||
tag.name,
|
||||
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> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {TAG_COLUMNS} FROM tags WHERE id = ?1"),
|
||||
params![id],
|
||||
tag_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_tag_by_project_and_name(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
name: &str,
|
||||
) -> rusqlite::Result<Tag> {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT {TAG_COLUMNS} FROM tags WHERE project_id = ?1 AND LOWER(name) = LOWER(?2)"
|
||||
),
|
||||
params![project_id, name],
|
||||
tag_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_tags_by_project(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<Tag>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {TAG_COLUMNS} FROM tags WHERE project_id = ?1 ORDER BY name"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![project_id], tag_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn update_tag(conn: &Connection, tag: &Tag) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE tags SET name = ?1, color = ?2, post_template_slug = ?3, updated_at = ?4
|
||||
WHERE id = ?5",
|
||||
params![
|
||||
tag.name,
|
||||
tag.color,
|
||||
tag.post_template_slug,
|
||||
tag.updated_at,
|
||||
tag.id,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_tag(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM tags WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
fn make_tag(id: &str, name: &str) -> Tag {
|
||||
Tag {
|
||||
id: id.to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
name: name.to_string(),
|
||||
color: Some("#ff0000".into()),
|
||||
post_template_slug: None,
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_get_by_id() {
|
||||
let db = setup();
|
||||
insert_tag(db.conn(), &make_tag("t1", "rust")).unwrap();
|
||||
let tag = get_tag_by_id(db.conn(), "t1").unwrap();
|
||||
assert_eq!(tag.name, "rust");
|
||||
assert_eq!(tag.color.as_deref(), Some("#ff0000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_by_project_and_name_case_insensitive() {
|
||||
let db = setup();
|
||||
insert_tag(db.conn(), &make_tag("t1", "Rust")).unwrap();
|
||||
let tag = get_tag_by_project_and_name(db.conn(), "p1", "rust").unwrap();
|
||||
assert_eq!(tag.id, "t1");
|
||||
let tag = get_tag_by_project_and_name(db.conn(), "p1", "RUST").unwrap();
|
||||
assert_eq!(tag.id, "t1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_ordered_by_name() {
|
||||
let db = setup();
|
||||
insert_tag(db.conn(), &make_tag("t1", "zebra")).unwrap();
|
||||
insert_tag(db.conn(), &make_tag("t2", "alpha")).unwrap();
|
||||
let list = list_tags_by_project(db.conn(), "p1").unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].name, "alpha");
|
||||
assert_eq!(list[1].name, "zebra");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_tag_fields() {
|
||||
let db = setup();
|
||||
let mut tag = make_tag("t1", "rust");
|
||||
insert_tag(db.conn(), &tag).unwrap();
|
||||
tag.name = "go".into();
|
||||
tag.color = None;
|
||||
tag.updated_at = 9999;
|
||||
update_tag(db.conn(), &tag).unwrap();
|
||||
let fetched = get_tag_by_id(db.conn(), "t1").unwrap();
|
||||
assert_eq!(fetched.name, "go");
|
||||
assert!(fetched.color.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_tag_removes_row() {
|
||||
let db = setup();
|
||||
insert_tag(db.conn(), &make_tag("t1", "rust")).unwrap();
|
||||
delete_tag(db.conn(), "t1").unwrap();
|
||||
assert!(get_tag_by_id(db.conn(), "t1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_name_rejected() {
|
||||
let db = setup();
|
||||
insert_tag(db.conn(), &make_tag("t1", "rust")).unwrap();
|
||||
let result = insert_tag(db.conn(), &make_tag("t2", "rust"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
190
crates/bds-core/src/db/queries/template.rs
Normal file
190
crates/bds-core/src/db/queries/template.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::db::from_row::{
|
||||
template_from_row, template_kind_to_str, template_status_to_str, TEMPLATE_COLUMNS,
|
||||
};
|
||||
use crate::model::Template;
|
||||
|
||||
pub fn insert_template(conn: &Connection, t: &Template) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO templates (
|
||||
id, project_id, slug, title, kind, enabled, version,
|
||||
file_path, status, content, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
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> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {TEMPLATE_COLUMNS} FROM templates WHERE id = ?1"),
|
||||
params![id],
|
||||
template_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_template_by_slug(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<Template> {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT {TEMPLATE_COLUMNS} FROM templates WHERE project_id = ?1 AND slug = ?2"
|
||||
),
|
||||
params![project_id, slug],
|
||||
template_from_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_templates_by_project(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> rusqlite::Result<Vec<Template>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {TEMPLATE_COLUMNS} FROM templates WHERE project_id = ?1 ORDER BY title"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![project_id], template_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn update_template(conn: &Connection, t: &Template) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE templates SET
|
||||
slug = ?1, title = ?2, kind = ?3, enabled = ?4, version = ?5,
|
||||
file_path = ?6, status = ?7, content = ?8, updated_at = ?9
|
||||
WHERE id = ?10",
|
||||
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<()> {
|
||||
conn.execute("DELETE FROM templates WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::db::Database;
|
||||
use crate::model::{TemplateKind, TemplateStatus};
|
||||
|
||||
fn setup() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
fn make_tpl(id: &str, slug: &str) -> Template {
|
||||
Template {
|
||||
id: id.to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
slug: slug.to_string(),
|
||||
title: format!("Template {slug}"),
|
||||
kind: TemplateKind::Post,
|
||||
enabled: true,
|
||||
version: 1,
|
||||
file_path: format!("templates/{slug}.liquid"),
|
||||
status: TemplateStatus::Published,
|
||||
content: Some("html".into()),
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_get_by_id() {
|
||||
let db = setup();
|
||||
insert_template(db.conn(), &make_tpl("t1", "default")).unwrap();
|
||||
let t = get_template_by_id(db.conn(), "t1").unwrap();
|
||||
assert_eq!(t.slug, "default");
|
||||
assert_eq!(t.kind, TemplateKind::Post);
|
||||
assert!(t.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_by_slug() {
|
||||
let db = setup();
|
||||
insert_template(db.conn(), &make_tpl("t1", "default")).unwrap();
|
||||
let t = get_template_by_slug(db.conn(), "p1", "default").unwrap();
|
||||
assert_eq!(t.id, "t1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_by_project() {
|
||||
let db = setup();
|
||||
let mut t1 = make_tpl("t1", "zebra");
|
||||
t1.title = "Zebra".into();
|
||||
let mut t2 = make_tpl("t2", "alpha");
|
||||
t2.title = "Alpha".into();
|
||||
insert_template(db.conn(), &t1).unwrap();
|
||||
insert_template(db.conn(), &t2).unwrap();
|
||||
let list = list_templates_by_project(db.conn(), "p1").unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].title, "Alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_template_fields() {
|
||||
let db = setup();
|
||||
let mut t = make_tpl("t1", "default");
|
||||
insert_template(db.conn(), &t).unwrap();
|
||||
t.kind = TemplateKind::List;
|
||||
t.enabled = false;
|
||||
t.version = 5;
|
||||
t.status = TemplateStatus::Draft;
|
||||
t.content = None;
|
||||
t.updated_at = 9999;
|
||||
update_template(db.conn(), &t).unwrap();
|
||||
let fetched = get_template_by_id(db.conn(), "t1").unwrap();
|
||||
assert_eq!(fetched.kind, TemplateKind::List);
|
||||
assert!(!fetched.enabled);
|
||||
assert_eq!(fetched.version, 5);
|
||||
assert_eq!(fetched.status, TemplateStatus::Draft);
|
||||
assert!(fetched.content.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_removes_template() {
|
||||
let db = setup();
|
||||
insert_template(db.conn(), &make_tpl("t1", "default")).unwrap();
|
||||
delete_template(db.conn(), "t1").unwrap();
|
||||
assert!(get_template_by_id(db.conn(), "t1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_slug_rejected() {
|
||||
let db = setup();
|
||||
insert_template(db.conn(), &make_tpl("t1", "default")).unwrap();
|
||||
let result = insert_template(db.conn(), &make_tpl("t2", "default"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user