Replace literal SQL with Diesel abstractions
This commit is contained in:
@@ -1,37 +1,88 @@
|
||||
use crate::db::migrations;
|
||||
use rusqlite::Connection;
|
||||
use std::cell::RefCell;
|
||||
use std::path::Path;
|
||||
|
||||
use diesel::connection::SimpleConnection;
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::migrations;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DatabaseError {
|
||||
#[error("{0}")]
|
||||
Connection(#[from] diesel::ConnectionError),
|
||||
#[error("{0}")]
|
||||
Query(#[from] diesel::result::Error),
|
||||
}
|
||||
|
||||
/// Shared synchronous Diesel connection used by the engine query API.
|
||||
pub struct DbConnection(RefCell<SqliteConnection>);
|
||||
|
||||
impl DbConnection {
|
||||
pub fn with<T>(
|
||||
&self,
|
||||
operation: impl FnOnce(&mut SqliteConnection) -> diesel::QueryResult<T>,
|
||||
) -> diesel::QueryResult<T> {
|
||||
operation(&mut self.0.borrow_mut())
|
||||
}
|
||||
|
||||
pub(crate) fn with_migrations<T>(
|
||||
&self,
|
||||
operation: impl FnOnce(&mut SqliteConnection) -> T,
|
||||
) -> T {
|
||||
operation(&mut self.0.borrow_mut())
|
||||
}
|
||||
|
||||
pub(crate) fn begin_savepoint(&self) -> diesel::QueryResult<()> {
|
||||
self.0.borrow_mut().batch_execute("SAVEPOINT bds_operation")
|
||||
}
|
||||
|
||||
pub(crate) fn release_savepoint(&self) -> diesel::QueryResult<()> {
|
||||
self.0.borrow_mut().batch_execute("RELEASE bds_operation")
|
||||
}
|
||||
|
||||
pub(crate) fn rollback_savepoint(&self) -> diesel::QueryResult<()> {
|
||||
self.0
|
||||
.borrow_mut()
|
||||
.batch_execute("ROLLBACK TO bds_operation; RELEASE bds_operation")
|
||||
}
|
||||
}
|
||||
|
||||
/// Database wrapper managing a SQLite connection.
|
||||
pub struct Database {
|
||||
conn: Connection,
|
||||
conn: DbConnection,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Open an existing bDS project database.
|
||||
pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;",
|
||||
)?;
|
||||
Ok(Self { conn })
|
||||
pub fn open(path: &Path) -> Result<Self, DatabaseError> {
|
||||
Self::establish(path.to_string_lossy().as_ref(), true)
|
||||
}
|
||||
|
||||
/// Open an in-memory database (for tests).
|
||||
pub fn open_in_memory() -> Result<Self, rusqlite::Error> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
conn.execute_batch("PRAGMA foreign_keys=ON;")?;
|
||||
Ok(Self { conn })
|
||||
pub fn open_in_memory() -> Result<Self, DatabaseError> {
|
||||
Self::establish(":memory:", false)
|
||||
}
|
||||
|
||||
/// Get a reference to the underlying connection.
|
||||
pub fn conn(&self) -> &Connection {
|
||||
fn establish(database_url: &str, wal: bool) -> Result<Self, DatabaseError> {
|
||||
let mut conn = SqliteConnection::establish(database_url)?;
|
||||
// SQLite connection configuration is backend-specific and not expressible in Diesel's DSL.
|
||||
conn.batch_execute(if wal {
|
||||
"PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;"
|
||||
} else {
|
||||
"PRAGMA foreign_keys=ON;"
|
||||
})?;
|
||||
Ok(Self {
|
||||
conn: DbConnection(RefCell::new(conn)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn conn(&self) -> &DbConnection {
|
||||
&self.conn
|
||||
}
|
||||
|
||||
/// Run all pending migrations via refinery.
|
||||
pub fn migrate(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
migrations::run_migrations(&mut self.conn)
|
||||
/// Run all pending embedded Diesel migrations.
|
||||
pub fn migrate(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
migrations::run_migrations(&self.conn)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,9 +93,11 @@ mod tests {
|
||||
#[test]
|
||||
fn open_in_memory() {
|
||||
let db = Database::open_in_memory().expect("should open in-memory db");
|
||||
let result: i64 = db
|
||||
let result = db
|
||||
.conn()
|
||||
.query_row("SELECT 1", [], |row| row.get(0))
|
||||
.with(|conn| {
|
||||
diesel::select(1.into_sql::<diesel::sql_types::Integer>()).get_result::<i32>(conn)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(result, 1);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,57 @@
|
||||
use rusqlite::Connection;
|
||||
use diesel::connection::SimpleConnection;
|
||||
use diesel::prelude::*;
|
||||
use diesel::sql_types::{BigInt, Text};
|
||||
use diesel::sqlite::Sqlite;
|
||||
use rust_stemmers::{Algorithm, Stemmer};
|
||||
|
||||
use crate::db::DbConnection as Connection;
|
||||
use crate::db::schema::{post_translations, posts};
|
||||
use crate::util::calendar_range_unix_ms;
|
||||
|
||||
diesel::define_sql_function!(fn instr(haystack: Text, needle: Text) -> diesel::sql_types::Integer);
|
||||
diesel::define_sql_function!(fn lower(value: Text) -> Text);
|
||||
|
||||
#[derive(QueryableByName)]
|
||||
#[diesel(check_for_backend(Sqlite))]
|
||||
struct PostIdRow {
|
||||
#[diesel(sql_type = Text)]
|
||||
post_id: String,
|
||||
}
|
||||
|
||||
#[derive(QueryableByName)]
|
||||
#[diesel(check_for_backend(Sqlite))]
|
||||
struct MediaIdRow {
|
||||
#[diesel(sql_type = Text)]
|
||||
media_id: String,
|
||||
}
|
||||
|
||||
#[derive(QueryableByName)]
|
||||
#[diesel(check_for_backend(Sqlite))]
|
||||
struct TableCountRow {
|
||||
#[diesel(sql_type = BigInt)]
|
||||
count: i64,
|
||||
}
|
||||
|
||||
/// Whether both application FTS5 virtual tables are present.
|
||||
pub fn tables_exist(conn: &Connection) -> QueryResult<bool> {
|
||||
conn.with(|c| {
|
||||
diesel::sql_query(
|
||||
"SELECT COUNT(*) AS count FROM sqlite_master \
|
||||
WHERE type = 'table' AND name IN ('posts_fts', 'media_fts')",
|
||||
)
|
||||
.get_result::<TableCountRow>(c)
|
||||
.map(|row| row.count == 2)
|
||||
})
|
||||
}
|
||||
|
||||
/// Create FTS5 virtual tables at runtime (not in migrations per spec).
|
||||
///
|
||||
/// Schema follows specs/schema.allium: multi-column FTS5 with separate fields
|
||||
/// for weighted search. Not content-sync — we manually manage stemmed content.
|
||||
pub fn ensure_fts_tables(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(
|
||||
"CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
|
||||
pub fn ensure_fts_tables(conn: &Connection) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
c.batch_execute(
|
||||
"CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
|
||||
post_id UNINDEXED,
|
||||
title,
|
||||
excerpt,
|
||||
@@ -25,8 +67,16 @@ pub fn ensure_fts_tables(conn: &Connection) -> rusqlite::Result<()> {
|
||||
original_name,
|
||||
tags
|
||||
);",
|
||||
)?;
|
||||
Ok(())
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clear_indexes(conn: &Connection) -> QueryResult<()> {
|
||||
conn.with(|c| c.batch_execute("DELETE FROM posts_fts; DELETE FROM media_fts;"))
|
||||
}
|
||||
|
||||
pub fn drop_post_index(conn: &Connection) -> QueryResult<()> {
|
||||
conn.with(|c| c.batch_execute("DROP TABLE posts_fts"))
|
||||
}
|
||||
|
||||
/// Map ISO 639-1 language code to Snowball stemmer algorithm.
|
||||
@@ -98,7 +148,7 @@ pub fn index_post(
|
||||
categories: &[String],
|
||||
translations: &[PostTranslationFts],
|
||||
language: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
) -> QueryResult<()> {
|
||||
// Remove existing entry
|
||||
remove_post_from_index(conn, post_id)?;
|
||||
|
||||
@@ -133,11 +183,10 @@ pub fn index_post(
|
||||
let stemmed_tags = stem_text(&tags.join(" "), language);
|
||||
let stemmed_categories = stem_text(&categories.join(" "), language);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO posts_fts (post_id, title, excerpt, content, tags, categories) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![post_id, stemmed_title, stemmed_excerpt, stemmed_content, stemmed_tags, stemmed_categories],
|
||||
)?;
|
||||
Ok(())
|
||||
conn.with(|c| diesel::sql_query("INSERT INTO posts_fts (post_id, title, excerpt, content, tags, categories) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.bind::<Text, _>(post_id).bind::<Text, _>(stemmed_title).bind::<Text, _>(stemmed_excerpt)
|
||||
.bind::<Text, _>(stemmed_content).bind::<Text, _>(stemmed_tags).bind::<Text, _>(stemmed_categories)
|
||||
.execute(c).map(|_| ()))
|
||||
}
|
||||
|
||||
/// Index a media item in the FTS table with separate columns per spec.
|
||||
@@ -157,7 +206,7 @@ pub fn index_media(
|
||||
tags: &[String],
|
||||
translations: &[MediaTranslationFts],
|
||||
language: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
) -> QueryResult<()> {
|
||||
remove_media_from_index(conn, media_id)?;
|
||||
|
||||
// Title column: media title + all translation titles
|
||||
@@ -193,42 +242,41 @@ pub fn index_media(
|
||||
let stemmed_name = stem_text(original_name, language);
|
||||
let stemmed_tags = stem_text(&tags.join(" "), language);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO media_fts (media_id, title, alt, caption, original_name, tags) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![media_id, stemmed_title, stemmed_alt, stemmed_caption, stemmed_name, stemmed_tags],
|
||||
)?;
|
||||
Ok(())
|
||||
conn.with(|c| diesel::sql_query("INSERT INTO media_fts (media_id, title, alt, caption, original_name, tags) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.bind::<Text, _>(media_id).bind::<Text, _>(stemmed_title).bind::<Text, _>(stemmed_alt)
|
||||
.bind::<Text, _>(stemmed_caption).bind::<Text, _>(stemmed_name).bind::<Text, _>(stemmed_tags)
|
||||
.execute(c).map(|_| ()))
|
||||
}
|
||||
|
||||
/// 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(())
|
||||
pub fn remove_post_from_index(conn: &Connection, post_id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::sql_query("DELETE FROM posts_fts WHERE post_id = ?")
|
||||
.bind::<Text, _>(post_id)
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
/// 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(())
|
||||
pub fn remove_media_from_index(conn: &Connection, media_id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::sql_query("DELETE FROM media_fts WHERE media_id = ?")
|
||||
.bind::<Text, _>(media_id)
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
/// Search posts by full-text query. Returns matching post IDs.
|
||||
pub fn search_posts(
|
||||
conn: &Connection,
|
||||
query: &str,
|
||||
language: &str,
|
||||
) -> rusqlite::Result<Vec<String>> {
|
||||
pub fn search_posts(conn: &Connection, query: &str, language: &str) -> QueryResult<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()
|
||||
conn.with(|c| {
|
||||
diesel::sql_query("SELECT post_id FROM posts_fts WHERE posts_fts MATCH ? ORDER BY rank")
|
||||
.bind::<Text, _>(stemmed)
|
||||
.load::<PostIdRow>(c)
|
||||
.map(|rows| rows.into_iter().map(|row| row.post_id).collect())
|
||||
})
|
||||
}
|
||||
|
||||
/// Filters for post search.
|
||||
@@ -262,7 +310,7 @@ pub fn search_posts_filtered(
|
||||
query: &str,
|
||||
language: &str,
|
||||
filters: &PostSearchFilters,
|
||||
) -> rusqlite::Result<SearchResults> {
|
||||
) -> QueryResult<SearchResults> {
|
||||
// Get FTS matches first
|
||||
let fts_ids = search_posts(conn, query, language)?;
|
||||
if fts_ids.is_empty() {
|
||||
@@ -274,115 +322,64 @@ pub fn search_posts_filtered(
|
||||
});
|
||||
}
|
||||
|
||||
// Apply filters by querying posts table
|
||||
let placeholders: Vec<String> = fts_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, _)| format!("?{}", i + 1))
|
||||
.collect();
|
||||
let mut sql = format!(
|
||||
"SELECT id FROM posts WHERE id IN ({}) ",
|
||||
placeholders.join(",")
|
||||
);
|
||||
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = fts_ids
|
||||
.iter()
|
||||
.map(|id| Box::new(id.clone()) as Box<dyn rusqlite::types::ToSql>)
|
||||
.collect();
|
||||
let mut param_idx = fts_ids.len() + 1;
|
||||
|
||||
if let Some(status) = filters.status {
|
||||
sql.push_str(&format!("AND status = ?{param_idx} "));
|
||||
params.push(Box::new(status.to_string()));
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
if let Some(tags) = filters.tags {
|
||||
// Filter posts whose JSON tags array contains ALL specified tags (case-insensitive)
|
||||
for tag in tags {
|
||||
sql.push_str(&format!(
|
||||
"AND EXISTS (SELECT 1 FROM json_each(posts.tags) WHERE LOWER(json_each.value) = LOWER(?{param_idx})) "
|
||||
));
|
||||
params.push(Box::new(tag.clone()));
|
||||
param_idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(categories) = filters.categories {
|
||||
// Filter posts whose JSON categories array contains ALL specified categories (case-insensitive)
|
||||
for cat in categories {
|
||||
sql.push_str(&format!(
|
||||
"AND EXISTS (SELECT 1 FROM json_each(posts.categories) WHERE LOWER(json_each.value) = LOWER(?{param_idx})) "
|
||||
));
|
||||
params.push(Box::new(cat.clone()));
|
||||
param_idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(year) = filters.year {
|
||||
let (start, end) =
|
||||
calendar_range_unix_ms(year, filters.month).ok_or(rusqlite::Error::InvalidQuery)?;
|
||||
sql.push_str(&format!(
|
||||
"AND created_at >= ?{param_idx} AND created_at < ?{} ",
|
||||
param_idx + 1
|
||||
));
|
||||
params.push(Box::new(start));
|
||||
params.push(Box::new(end));
|
||||
param_idx += 2;
|
||||
}
|
||||
|
||||
if let Some(lang) = filters.language {
|
||||
sql.push_str(&format!(
|
||||
"AND (language = ?{param_idx} OR language IS NULL) "
|
||||
));
|
||||
params.push(Box::new(lang.to_string()));
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
if let Some(missing_lang) = filters.missing_translation_language {
|
||||
sql.push_str(&format!(
|
||||
"AND NOT EXISTS (SELECT 1 FROM post_translations WHERE post_translations.translation_for = posts.id AND post_translations.language = ?{param_idx}) "
|
||||
));
|
||||
params.push(Box::new(missing_lang.to_string()));
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
if let Some(from_ts) = filters.from {
|
||||
sql.push_str(&format!("AND created_at >= ?{param_idx} "));
|
||||
params.push(Box::new(from_ts));
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
if let Some(to_ts) = filters.to {
|
||||
sql.push_str(&format!("AND created_at <= ?{param_idx} "));
|
||||
params.push(Box::new(to_ts));
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
let _ = param_idx; // suppress unused warning
|
||||
|
||||
// First get total count (without LIMIT/OFFSET)
|
||||
let count_sql = sql.replace("SELECT id FROM posts", "SELECT COUNT(*) FROM posts");
|
||||
let total: usize = {
|
||||
let mut stmt = conn.prepare(&count_sql)?;
|
||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
|
||||
params.iter().map(|p| p.as_ref()).collect();
|
||||
stmt.query_row(params_refs.as_slice(), |row| row.get::<_, usize>(0))?
|
||||
};
|
||||
|
||||
sql.push_str("ORDER BY created_at DESC ");
|
||||
|
||||
let offset = filters.offset.unwrap_or(0);
|
||||
let mut post_ids = conn.with(|c| {
|
||||
let mut query = posts::table.filter(posts::id.eq_any(fts_ids)).into_boxed();
|
||||
if let Some(status) = filters.status {
|
||||
query = query.filter(posts::status.eq(status));
|
||||
}
|
||||
if let Some(tags) = filters.tags {
|
||||
for tag in tags {
|
||||
query = query.filter(
|
||||
instr(
|
||||
lower(posts::tags),
|
||||
serde_json::to_string(&tag.to_lowercase()).unwrap(),
|
||||
)
|
||||
.gt(0),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(categories) = filters.categories {
|
||||
for category in categories {
|
||||
query = query.filter(
|
||||
instr(
|
||||
lower(posts::categories),
|
||||
serde_json::to_string(&category.to_lowercase()).unwrap(),
|
||||
)
|
||||
.gt(0),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(year) = filters.year {
|
||||
let (start, end) = calendar_range_unix_ms(year, filters.month).ok_or_else(|| {
|
||||
diesel::result::Error::SerializationError("invalid calendar range".into())
|
||||
})?;
|
||||
query = query.filter(posts::created_at.ge(start).and(posts::created_at.lt(end)));
|
||||
}
|
||||
if let Some(language) = filters.language {
|
||||
query = query.filter(posts::language.eq(language).or(posts::language.is_null()));
|
||||
}
|
||||
if let Some(language) = filters.missing_translation_language {
|
||||
query = query.filter(diesel::dsl::not(diesel::dsl::exists(
|
||||
post_translations::table
|
||||
.filter(post_translations::translation_for.eq(posts::id))
|
||||
.filter(post_translations::language.eq(language)),
|
||||
)));
|
||||
}
|
||||
if let Some(from) = filters.from {
|
||||
query = query.filter(posts::created_at.ge(from));
|
||||
}
|
||||
if let Some(to) = filters.to {
|
||||
query = query.filter(posts::created_at.le(to));
|
||||
}
|
||||
query
|
||||
.order(posts::created_at.desc())
|
||||
.select(posts::id)
|
||||
.load::<String>(c)
|
||||
})?;
|
||||
let total = post_ids.len();
|
||||
let limit = filters.limit.unwrap_or(total);
|
||||
|
||||
if filters.limit.is_some() {
|
||||
sql.push_str(&format!("LIMIT {limit} "));
|
||||
sql.push_str(&format!("OFFSET {offset} "));
|
||||
}
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
let rows = stmt.query_map(params_refs.as_slice(), |row| row.get::<_, String>(0))?;
|
||||
let post_ids: Vec<String> = rows.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
post_ids = post_ids.into_iter().skip(offset).take(limit).collect();
|
||||
Ok(SearchResults {
|
||||
post_ids,
|
||||
total,
|
||||
@@ -392,41 +389,51 @@ pub fn search_posts_filtered(
|
||||
}
|
||||
|
||||
/// Search media by full-text query. Returns matching media IDs.
|
||||
pub fn search_media(
|
||||
conn: &Connection,
|
||||
query: &str,
|
||||
language: &str,
|
||||
) -> rusqlite::Result<Vec<String>> {
|
||||
pub fn search_media(conn: &Connection, query: &str, language: &str) -> QueryResult<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()
|
||||
conn.with(|c| {
|
||||
diesel::sql_query("SELECT media_id FROM media_fts WHERE media_fts MATCH ? ORDER BY rank")
|
||||
.bind::<Text, _>(stemmed)
|
||||
.load::<MediaIdRow>(c)
|
||||
.map(|rows| rows.into_iter().map(|row| row.media_id).collect())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::post::{insert_post, make_test_post};
|
||||
use crate::model::PostStatus;
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
ensure_fts_tables(db.conn()).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
fn insert_test_post(
|
||||
db: &Database,
|
||||
id: &str,
|
||||
slug: &str,
|
||||
title: &str,
|
||||
status: PostStatus,
|
||||
timestamp: i64,
|
||||
) {
|
||||
let mut post = make_test_post(id, "p1", slug);
|
||||
post.title = title.into();
|
||||
post.status = status;
|
||||
post.created_at = timestamp;
|
||||
post.updated_at = timestamp;
|
||||
insert_post(db.conn(), &post).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
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);
|
||||
assert!(search_posts(db.conn(), "nothing", "en").unwrap().is_empty());
|
||||
assert!(search_media(db.conn(), "nothing", "en").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -438,16 +445,30 @@ mod tests {
|
||||
#[test]
|
||||
fn fts_multi_column_schema() {
|
||||
let db = setup();
|
||||
// Verify posts_fts has the expected columns by inserting into each
|
||||
db.conn().execute(
|
||||
"INSERT INTO posts_fts (post_id, title, excerpt, content, tags, categories) VALUES ('p1', 'T', 'E', 'C', 'tg', 'ct')",
|
||||
[],
|
||||
).unwrap();
|
||||
// Verify media_fts has the expected columns
|
||||
db.conn().execute(
|
||||
"INSERT INTO media_fts (media_id, title, alt, caption, original_name, tags) VALUES ('m1', 'T', 'A', 'C', 'N', 'tg')",
|
||||
[],
|
||||
).unwrap();
|
||||
index_post(
|
||||
db.conn(),
|
||||
"p1",
|
||||
"T",
|
||||
Some("E"),
|
||||
Some("C"),
|
||||
&["tg".into()],
|
||||
&["ct".into()],
|
||||
&[],
|
||||
"en",
|
||||
)
|
||||
.unwrap();
|
||||
index_media(
|
||||
db.conn(),
|
||||
"m1",
|
||||
Some("T"),
|
||||
Some("A"),
|
||||
Some("C"),
|
||||
"N",
|
||||
&["tg".into()],
|
||||
&[],
|
||||
"en",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -694,16 +715,22 @@ mod tests {
|
||||
// Insert a post in the posts table (needed for the filter query to join)
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
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', 'Test Post', 'test', 'published', 1700000000000, 1700000000000)",
|
||||
[],
|
||||
).unwrap();
|
||||
db.conn().execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||
VALUES ('post2', 'p1', 'Draft Post', 'draft-post', 'draft', 1700000000000, 1700000000000)",
|
||||
[],
|
||||
).unwrap();
|
||||
insert_test_post(
|
||||
&db,
|
||||
"post1",
|
||||
"test",
|
||||
"Test Post",
|
||||
PostStatus::Published,
|
||||
1700000000000,
|
||||
);
|
||||
insert_test_post(
|
||||
&db,
|
||||
"post2",
|
||||
"draft-post",
|
||||
"Draft Post",
|
||||
PostStatus::Draft,
|
||||
1700000000000,
|
||||
);
|
||||
|
||||
index_post(
|
||||
db.conn(),
|
||||
@@ -750,20 +777,22 @@ mod tests {
|
||||
// 2023-06-15 in unix ms
|
||||
let ts_2023: i64 = 1686873600000;
|
||||
|
||||
db.conn()
|
||||
.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||
VALUES ('p2024', 'p1', 'Year 2024', 'y2024', 'draft', ?1, ?1)",
|
||||
rusqlite::params![ts_2024],
|
||||
)
|
||||
.unwrap();
|
||||
db.conn()
|
||||
.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||
VALUES ('p2023', 'p1', 'Year 2023', 'y2023', 'draft', ?1, ?1)",
|
||||
rusqlite::params![ts_2023],
|
||||
)
|
||||
.unwrap();
|
||||
insert_test_post(
|
||||
&db,
|
||||
"p2024",
|
||||
"y2024",
|
||||
"Year 2024",
|
||||
PostStatus::Draft,
|
||||
ts_2024,
|
||||
);
|
||||
insert_test_post(
|
||||
&db,
|
||||
"p2023",
|
||||
"y2023",
|
||||
"Year 2023",
|
||||
PostStatus::Draft,
|
||||
ts_2023,
|
||||
);
|
||||
|
||||
index_post(
|
||||
db.conn(),
|
||||
@@ -808,11 +837,14 @@ mod tests {
|
||||
for i in 0..5 {
|
||||
let id = format!("p{i}");
|
||||
let slug = format!("post-{i}");
|
||||
db.conn().execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
|
||||
VALUES (?1, 'p1', 'Searchable', ?2, 'draft', ?3, ?3)",
|
||||
rusqlite::params![id, slug, 1700000000000i64 - i as i64 * 1000],
|
||||
).unwrap();
|
||||
insert_test_post(
|
||||
&db,
|
||||
&id,
|
||||
&slug,
|
||||
"Searchable",
|
||||
PostStatus::Draft,
|
||||
1700000000000i64 - i as i64 * 1000,
|
||||
);
|
||||
index_post(
|
||||
db.conn(),
|
||||
&id,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,8 @@ pub mod from_row;
|
||||
pub mod fts;
|
||||
mod migrations;
|
||||
pub mod queries;
|
||||
pub mod schema;
|
||||
|
||||
pub use connection::Database;
|
||||
pub use connection::{Database, DatabaseError, DbConnection};
|
||||
pub use diesel::result::Error as DbQueryError;
|
||||
pub use migrations::run_migrations;
|
||||
|
||||
@@ -1,62 +1,74 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::from_row::{GENERATED_FILE_HASH_COLUMNS, generated_file_hash_from_row};
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::from_row::GeneratedFileHashRecord;
|
||||
use crate::db::schema::generated_file_hashes;
|
||||
use crate::model::GeneratedFileHash;
|
||||
|
||||
pub fn get_generated_file_hash(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
relative_path: &str,
|
||||
) -> rusqlite::Result<GeneratedFileHash> {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT {GENERATED_FILE_HASH_COLUMNS} FROM generated_file_hashes WHERE project_id = ?1 AND relative_path = ?2"
|
||||
),
|
||||
params![project_id, relative_path],
|
||||
generated_file_hash_from_row,
|
||||
)
|
||||
) -> QueryResult<GeneratedFileHash> {
|
||||
conn.with(|c| {
|
||||
generated_file_hashes::table
|
||||
.filter(generated_file_hashes::project_id.eq(project_id))
|
||||
.filter(generated_file_hashes::relative_path.eq(relative_path))
|
||||
.select(GeneratedFileHashRecord::as_select())
|
||||
.first(c)
|
||||
.map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn upsert_generated_file_hash(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
hash: &GeneratedFileHash,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO generated_file_hashes (project_id, relative_path, content_hash, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(project_id, relative_path)
|
||||
DO UPDATE SET content_hash = excluded.content_hash, updated_at = excluded.updated_at",
|
||||
params![
|
||||
hash.project_id,
|
||||
hash.relative_path,
|
||||
hash.content_hash,
|
||||
hash.updated_at
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(generated_file_hashes::table)
|
||||
.values(GeneratedFileHashRecord::from(hash))
|
||||
.on_conflict((
|
||||
generated_file_hashes::project_id,
|
||||
generated_file_hashes::relative_path,
|
||||
))
|
||||
.do_update()
|
||||
.set((
|
||||
generated_file_hashes::content_hash.eq(&hash.content_hash),
|
||||
generated_file_hashes::updated_at.eq(hash.updated_at),
|
||||
))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_generated_file_hash(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
relative_path: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM generated_file_hashes WHERE project_id = ?1 AND relative_path = ?2",
|
||||
params![project_id, relative_path],
|
||||
)?;
|
||||
Ok(())
|
||||
) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(
|
||||
generated_file_hashes::table
|
||||
.filter(generated_file_hashes::project_id.eq(project_id))
|
||||
.filter(generated_file_hashes::relative_path.eq(relative_path)),
|
||||
)
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_generated_file_hashes_by_project(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
) -> rusqlite::Result<Vec<GeneratedFileHash>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {GENERATED_FILE_HASH_COLUMNS} FROM generated_file_hashes WHERE project_id = ?1 ORDER BY relative_path"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![project_id], generated_file_hash_from_row)?;
|
||||
rows.collect()
|
||||
) -> QueryResult<Vec<GeneratedFileHash>> {
|
||||
conn.with(|c| {
|
||||
generated_file_hashes::table
|
||||
.filter(generated_file_hashes::project_id.eq(project_id))
|
||||
.order(generated_file_hashes::relative_path)
|
||||
.select(GeneratedFileHashRecord::as_select())
|
||||
.load(c)
|
||||
.map(|rows: Vec<GeneratedFileHashRecord>| rows.into_iter().map(Into::into).collect())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -66,7 +78,7 @@ mod tests {
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db
|
||||
|
||||
@@ -1,120 +1,87 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use chrono::{Datelike, TimeZone, Utc};
|
||||
use diesel::prelude::*;
|
||||
use diesel::sql_types::Text;
|
||||
|
||||
use crate::db::from_row::{MEDIA_COLUMNS, media_from_row};
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::from_row::{MediaRecord, convert, convert_all};
|
||||
use crate::db::schema::media;
|
||||
use crate::model::Media;
|
||||
use crate::util::calendar_range_unix_ms;
|
||||
|
||||
fn tags_to_json(tags: &[String]) -> String {
|
||||
serde_json::to_string(tags).unwrap_or_else(|_| "[]".into())
|
||||
diesel::define_sql_function!(fn instr(haystack: Text, needle: Text) -> Integer);
|
||||
|
||||
pub fn insert_media(conn: &DbConnection, m: &Media) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(media::table)
|
||||
.values(MediaRecord::from(m))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
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: &DbConnection, id: &str) -> QueryResult<Media> {
|
||||
conn.with(|c| {
|
||||
media::table
|
||||
.filter(media::id.eq(id))
|
||||
.select(MediaRecord::as_select())
|
||||
.first(c)
|
||||
.and_then(convert)
|
||||
})
|
||||
}
|
||||
|
||||
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 list_media_by_project(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<Media>> {
|
||||
conn.with(|c| {
|
||||
media::table
|
||||
.filter(media::project_id.eq(project_id))
|
||||
.order(media::created_at.desc())
|
||||
.select(MediaRecord::as_select())
|
||||
.load(c)
|
||||
.and_then(convert_all)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_media_by_project_limited(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> rusqlite::Result<Vec<Media>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {MEDIA_COLUMNS} FROM media WHERE project_id = ?1 ORDER BY created_at DESC LIMIT ?2 OFFSET ?3"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![project_id, limit, offset], media_from_row)?;
|
||||
rows.collect()
|
||||
) -> QueryResult<Vec<Media>> {
|
||||
conn.with(|c| {
|
||||
media::table
|
||||
.filter(media::project_id.eq(project_id))
|
||||
.order(media::created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.select(MediaRecord::as_select())
|
||||
.load(c)
|
||||
.and_then(convert_all)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn count_media_by_project(conn: &Connection, project_id: &str) -> rusqlite::Result<i64> {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM media WHERE project_id = ?1",
|
||||
params![project_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
pub fn count_media_by_project(conn: &DbConnection, project_id: &str) -> QueryResult<i64> {
|
||||
conn.with(|c| {
|
||||
media::table
|
||||
.filter(media::project_id.eq(project_id))
|
||||
.count()
|
||||
.get_result(c)
|
||||
})
|
||||
}
|
||||
|
||||
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 update_media(conn: &DbConnection, m: &Media) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::update(media::table.filter(media::id.eq(&m.id)))
|
||||
.set(MediaRecord::from(m))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_media(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM media WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
pub fn delete_media(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(media::table.filter(media::id.eq(id)))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
// ── Filtered queries (per sidebar_views.allium MediaView) ───
|
||||
@@ -140,95 +107,86 @@ impl MediaFilterParams {
|
||||
|
||||
/// List media with optional filters applied.
|
||||
pub fn list_media_filtered(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
filters: &MediaFilterParams,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> rusqlite::Result<Vec<Media>> {
|
||||
let mut conditions = vec!["project_id = ?1".to_string()];
|
||||
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
param_values.push(Box::new(project_id.to_string()));
|
||||
|
||||
if !filters.search_query.is_empty() {
|
||||
let idx = param_values.len() + 1;
|
||||
let pattern = format!("%{}%", filters.search_query.replace('%', "\\%"));
|
||||
conditions.push(format!(
|
||||
"(COALESCE(title, original_name) LIKE ?{idx} ESCAPE '\\')"
|
||||
));
|
||||
param_values.push(Box::new(pattern));
|
||||
}
|
||||
|
||||
if let Some(year) = filters.year {
|
||||
let (start, end) =
|
||||
calendar_range_unix_ms(year, filters.month).ok_or(rusqlite::Error::InvalidQuery)?;
|
||||
let idx1 = param_values.len() + 1;
|
||||
let idx2 = param_values.len() + 2;
|
||||
conditions.push(format!("(created_at >= ?{idx1} AND created_at < ?{idx2})"));
|
||||
param_values.push(Box::new(start));
|
||||
param_values.push(Box::new(end));
|
||||
}
|
||||
|
||||
for tag in &filters.tags {
|
||||
let idx = param_values.len() + 1;
|
||||
let pattern = format!("%\"{}\"%", tag.replace('"', "\\\""));
|
||||
conditions.push(format!("(tags LIKE ?{idx})"));
|
||||
param_values.push(Box::new(pattern));
|
||||
}
|
||||
|
||||
let where_clause = conditions.join(" AND ");
|
||||
let idx_limit = param_values.len() + 1;
|
||||
let idx_offset = param_values.len() + 2;
|
||||
param_values.push(Box::new(limit));
|
||||
param_values.push(Box::new(offset));
|
||||
|
||||
let sql = format!(
|
||||
"SELECT {MEDIA_COLUMNS} FROM media WHERE {where_clause} ORDER BY created_at DESC LIMIT ?{idx_limit} OFFSET ?{idx_offset}"
|
||||
);
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
|
||||
param_values.iter().map(|b| b.as_ref()).collect();
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params_refs.as_slice(), media_from_row)?;
|
||||
rows.collect()
|
||||
) -> QueryResult<Vec<Media>> {
|
||||
conn.with(|c| {
|
||||
let mut query = media::table
|
||||
.filter(media::project_id.eq(project_id))
|
||||
.into_boxed();
|
||||
if !filters.search_query.is_empty() {
|
||||
query = query.filter(
|
||||
media::title
|
||||
.is_not_null()
|
||||
.and(instr(media::title.assume_not_null(), &filters.search_query).gt(0))
|
||||
.or(media::title
|
||||
.is_null()
|
||||
.and(instr(media::original_name, &filters.search_query).gt(0))),
|
||||
);
|
||||
}
|
||||
if let Some(year) = filters.year {
|
||||
let (start, end) = calendar_range_unix_ms(year, filters.month).ok_or_else(|| {
|
||||
diesel::result::Error::SerializationError("invalid calendar range".into())
|
||||
})?;
|
||||
query = query.filter(media::created_at.ge(start).and(media::created_at.lt(end)));
|
||||
}
|
||||
for tag in &filters.tags {
|
||||
query = query.filter(instr(media::tags, serde_json::to_string(tag).unwrap()).gt(0));
|
||||
}
|
||||
query
|
||||
.order(media::created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.select(MediaRecord::as_select())
|
||||
.load(c)
|
||||
.and_then(convert_all)
|
||||
})
|
||||
}
|
||||
|
||||
/// Year/month counts for the media calendar archive widget.
|
||||
pub fn media_calendar_counts(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
) -> rusqlite::Result<Vec<(i32, u32, usize)>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT
|
||||
CAST(strftime('%Y', created_at / 1000, 'unixepoch') AS INTEGER) AS y,
|
||||
CAST(strftime('%m', created_at / 1000, 'unixepoch') AS INTEGER) AS m,
|
||||
COUNT(*) AS cnt
|
||||
FROM media
|
||||
WHERE project_id = ?1
|
||||
GROUP BY y, m
|
||||
ORDER BY y DESC, m DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![project_id], |row| {
|
||||
Ok((
|
||||
row.get::<_, i32>(0)?,
|
||||
row.get::<_, u32>(1)?,
|
||||
row.get::<_, usize>(2)?,
|
||||
))
|
||||
})?;
|
||||
rows.collect()
|
||||
) -> QueryResult<Vec<(i32, u32, usize)>> {
|
||||
conn.with(|c| {
|
||||
let timestamps = media::table
|
||||
.filter(media::project_id.eq(project_id))
|
||||
.select(media::created_at)
|
||||
.load::<i64>(c)?;
|
||||
let mut counts = std::collections::BTreeMap::new();
|
||||
for timestamp in timestamps {
|
||||
let date = Utc
|
||||
.timestamp_millis_opt(timestamp)
|
||||
.single()
|
||||
.ok_or_else(|| {
|
||||
diesel::result::Error::DeserializationError("invalid timestamp".into())
|
||||
})?;
|
||||
*counts.entry((date.year(), date.month())).or_insert(0) += 1;
|
||||
}
|
||||
Ok(counts
|
||||
.into_iter()
|
||||
.rev()
|
||||
.map(|((year, month), count)| (year, month, count))
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
|
||||
/// Collect all distinct tag values across media for a project.
|
||||
pub fn distinct_media_tags(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<String>> {
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT DISTINCT tags FROM media WHERE project_id = ?1 AND tags != '[]'")?;
|
||||
let rows = stmt.query_map(params![project_id], |row| row.get::<_, String>(0))?;
|
||||
pub fn distinct_media_tags(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<String>> {
|
||||
let rows = conn.with(|c| {
|
||||
media::table
|
||||
.filter(media::project_id.eq(project_id))
|
||||
.filter(media::tags.ne("[]"))
|
||||
.select(media::tags)
|
||||
.distinct()
|
||||
.load::<String>(c)
|
||||
})?;
|
||||
let mut all_tags = std::collections::BTreeSet::new();
|
||||
for json_str in rows {
|
||||
if let Ok(json_str) = json_str
|
||||
&& let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str)
|
||||
{
|
||||
if let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str) {
|
||||
all_tags.extend(tags);
|
||||
}
|
||||
}
|
||||
@@ -268,7 +226,7 @@ mod tests {
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db
|
||||
|
||||
@@ -1,92 +1,82 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::from_row::{MEDIA_TRANSLATION_COLUMNS, media_translation_from_row};
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::from_row::MediaTranslationRecord;
|
||||
use crate::db::schema::media_translations;
|
||||
use crate::model::MediaTranslation;
|
||||
|
||||
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 insert_media_translation(conn: &DbConnection, t: &MediaTranslation) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(media_translations::table)
|
||||
.values(MediaTranslationRecord::from(t))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_media_translation_by_media_and_language(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
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,
|
||||
)
|
||||
) -> QueryResult<MediaTranslation> {
|
||||
conn.with(|c| {
|
||||
media_translations::table
|
||||
.filter(media_translations::translation_for.eq(translation_for))
|
||||
.filter(media_translations::language.eq(language))
|
||||
.select(MediaTranslationRecord::as_select())
|
||||
.first(c)
|
||||
.map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_media_translations_by_media(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
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()
|
||||
) -> QueryResult<Vec<MediaTranslation>> {
|
||||
conn.with(|c| {
|
||||
media_translations::table
|
||||
.filter(media_translations::translation_for.eq(translation_for))
|
||||
.order(media_translations::language)
|
||||
.select(MediaTranslationRecord::as_select())
|
||||
.load(c)
|
||||
.map(|rows: Vec<MediaTranslationRecord>| rows.into_iter().map(Into::into).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 upsert_media_translation(conn: &DbConnection, t: &MediaTranslation) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(media_translations::table)
|
||||
.values(MediaTranslationRecord::from(t))
|
||||
.on_conflict((
|
||||
media_translations::translation_for,
|
||||
media_translations::language,
|
||||
))
|
||||
.do_update()
|
||||
.set((
|
||||
media_translations::title.eq(&t.title),
|
||||
media_translations::alt.eq(&t.alt),
|
||||
media_translations::caption.eq(&t.caption),
|
||||
media_translations::updated_at.eq(t.updated_at),
|
||||
))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_media_translation(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
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(())
|
||||
) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(
|
||||
media_translations::table
|
||||
.filter(media_translations::translation_for.eq(translation_for))
|
||||
.filter(media_translations::language.eq(language)),
|
||||
)
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -97,7 +87,7 @@ mod tests {
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
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();
|
||||
|
||||
@@ -1,169 +1,142 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use chrono::{Datelike, TimeZone, Utc};
|
||||
use diesel::prelude::*;
|
||||
use diesel::sql_types::Text;
|
||||
use diesel::sqlite::Sqlite;
|
||||
|
||||
use crate::db::from_row::{POST_COLUMNS, post_from_row, post_status_to_str};
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::from_row::{PostRecord, convert, convert_all, post_status_to_str};
|
||||
use crate::db::schema::posts;
|
||||
use crate::model::{Post, PostStatus};
|
||||
use crate::util::calendar_range_unix_ms;
|
||||
|
||||
fn tags_to_json(tags: &[String]) -> String {
|
||||
serde_json::to_string(tags).unwrap_or_else(|_| "[]".into())
|
||||
diesel::define_sql_function!(fn instr(haystack: Text, needle: Text) -> Integer);
|
||||
diesel::define_sql_function!(fn lower(value: Text) -> Text);
|
||||
|
||||
pub fn insert_post(conn: &DbConnection, post: &Post) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(posts::table)
|
||||
.values(PostRecord::from(post))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn insert_post(conn: &Connection, post: &Post) -> rusqlite::Result<()> {
|
||||
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_id(conn: &DbConnection, id: &str) -> QueryResult<Post> {
|
||||
conn.with(|c| {
|
||||
posts::table
|
||||
.filter(posts::id.eq(id))
|
||||
.select(PostRecord::as_select())
|
||||
.first(c)
|
||||
.and_then(convert)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_post_by_project_and_slug(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
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,
|
||||
)
|
||||
) -> QueryResult<Post> {
|
||||
conn.with(|c| {
|
||||
posts::table
|
||||
.filter(posts::project_id.eq(project_id))
|
||||
.filter(posts::slug.eq(slug))
|
||||
.select(PostRecord::as_select())
|
||||
.first(c)
|
||||
.and_then(convert)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_posts_by_project(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<Post>> {
|
||||
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 list_posts_by_project(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<Post>> {
|
||||
conn.with(|c| {
|
||||
posts::table
|
||||
.filter(posts::project_id.eq(project_id))
|
||||
.order(posts::created_at.desc())
|
||||
.select(PostRecord::as_select())
|
||||
.load(c)
|
||||
.and_then(convert_all)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_posts_by_project_limited(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> rusqlite::Result<Vec<Post>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {POST_COLUMNS} FROM posts WHERE project_id = ?1 ORDER BY created_at DESC LIMIT ?2 OFFSET ?3"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![project_id, limit, offset], post_from_row)?;
|
||||
rows.collect()
|
||||
) -> QueryResult<Vec<Post>> {
|
||||
conn.with(|c| {
|
||||
posts::table
|
||||
.filter(posts::project_id.eq(project_id))
|
||||
.order(posts::created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.select(PostRecord::as_select())
|
||||
.load(c)
|
||||
.and_then(convert_all)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_post(conn: &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,
|
||||
created_at = ?19, updated_at = ?20, published_at = ?21
|
||||
WHERE id = ?22",
|
||||
params![
|
||||
post.title,
|
||||
post.slug,
|
||||
post.excerpt,
|
||||
post.content,
|
||||
post_status_to_str(&post.status),
|
||||
post.author,
|
||||
post.language,
|
||||
post.do_not_translate as i64,
|
||||
post.template_slug,
|
||||
post.file_path,
|
||||
post.checksum,
|
||||
tags_to_json(&post.tags),
|
||||
tags_to_json(&post.categories),
|
||||
post.published_title,
|
||||
post.published_content,
|
||||
post.published_tags,
|
||||
post.published_categories,
|
||||
post.published_excerpt,
|
||||
post.created_at,
|
||||
post.updated_at,
|
||||
post.published_at,
|
||||
post.id,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
pub fn update_post(conn: &DbConnection, post: &Post) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::update(posts::table.filter(posts::id.eq(&post.id)))
|
||||
.set(PostRecord::from(post))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_post_status(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
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(())
|
||||
) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::update(posts::table.filter(posts::id.eq(id)))
|
||||
.set((
|
||||
posts::status.eq(post_status_to_str(status)),
|
||||
posts::updated_at.eq(updated_at),
|
||||
))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
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 clear_post_content(conn: &DbConnection, id: &str, updated_at: i64) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::update(posts::table.filter(posts::id.eq(id)))
|
||||
.set((
|
||||
posts::content.eq(None::<String>),
|
||||
posts::updated_at.eq(updated_at),
|
||||
))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_post_file_path(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
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(())
|
||||
) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::update(posts::table.filter(posts::id.eq(id)))
|
||||
.set((
|
||||
posts::file_path.eq(file_path),
|
||||
posts::updated_at.eq(updated_at),
|
||||
))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_post_checksum(conn: &DbConnection, id: &str, checksum: Option<&str>) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::update(posts::table.filter(posts::id.eq(id)))
|
||||
.set(posts::checksum.eq(checksum))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
#[expect(
|
||||
@@ -171,7 +144,7 @@ pub fn set_post_file_path(
|
||||
reason = "arguments mirror the published snapshot columns"
|
||||
)]
|
||||
pub fn set_published_snapshot(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
id: &str,
|
||||
title: &str,
|
||||
content: &str,
|
||||
@@ -180,38 +153,38 @@ pub fn set_published_snapshot(
|
||||
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(())
|
||||
) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::update(posts::table.filter(posts::id.eq(id)))
|
||||
.set((
|
||||
posts::published_title.eq(title),
|
||||
posts::published_content.eq(content),
|
||||
posts::published_tags.eq(tags),
|
||||
posts::published_categories.eq(categories),
|
||||
posts::published_excerpt.eq(excerpt),
|
||||
posts::published_at.eq(published_at),
|
||||
posts::updated_at.eq(updated_at),
|
||||
))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_post(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM posts WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
pub fn delete_post(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(posts::table.filter(posts::id.eq(id)))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn count_posts_by_project(conn: &Connection, project_id: &str) -> rusqlite::Result<i64> {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM posts WHERE project_id = ?1",
|
||||
params![project_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
pub fn count_posts_by_project(conn: &DbConnection, project_id: &str) -> QueryResult<i64> {
|
||||
conn.with(|c| {
|
||||
posts::table
|
||||
.filter(posts::project_id.eq(project_id))
|
||||
.count()
|
||||
.get_result(c)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Filtered queries (per sidebar_views.allium PostsView) ───
|
||||
@@ -266,163 +239,155 @@ impl PostFilterParams {
|
||||
///
|
||||
/// Returns all matching posts (up to `limit`), ordered by created_at DESC.
|
||||
/// Caller splits into draft/published/archived sections.
|
||||
fn post_query<'a>(
|
||||
project_id: &'a str,
|
||||
filters: &'a PostFilterParams,
|
||||
apply_filters: bool,
|
||||
exclude_drafts: bool,
|
||||
) -> posts::BoxedQuery<'a, Sqlite> {
|
||||
let mut query = posts::table
|
||||
.filter(posts::project_id.eq(project_id))
|
||||
.into_boxed();
|
||||
|
||||
let page = serde_json::to_string("page").unwrap();
|
||||
if filters.pages_only {
|
||||
query = query.filter(instr(lower(posts::categories), page.clone()).gt(0));
|
||||
} else if filters.exclude_pages {
|
||||
query = query.filter(instr(lower(posts::categories), page).eq(0));
|
||||
}
|
||||
|
||||
if exclude_drafts {
|
||||
query = query.filter(posts::status.ne("draft"));
|
||||
}
|
||||
if !apply_filters {
|
||||
return query;
|
||||
}
|
||||
|
||||
if !filters.search_query.is_empty() {
|
||||
query = query.filter(instr(posts::title, &filters.search_query).gt(0));
|
||||
}
|
||||
if let Some(status) = &filters.status {
|
||||
query = query.filter(posts::status.eq(status));
|
||||
}
|
||||
if let Some(language) = &filters.language {
|
||||
query = query.filter(posts::language.eq(language).or(posts::language.is_null()));
|
||||
}
|
||||
if let Some(year) = filters.year
|
||||
&& let Some((start, end)) = calendar_range_unix_ms(year, filters.month)
|
||||
{
|
||||
query = query.filter(posts::created_at.ge(start).and(posts::created_at.lt(end)));
|
||||
}
|
||||
for tag in &filters.tags {
|
||||
query = query.filter(instr(posts::tags, serde_json::to_string(tag).unwrap()).gt(0));
|
||||
}
|
||||
for category in &filters.categories {
|
||||
query =
|
||||
query.filter(instr(posts::categories, serde_json::to_string(category).unwrap()).gt(0));
|
||||
}
|
||||
if let Some(from) = filters.from {
|
||||
query = query.filter(posts::created_at.ge(from));
|
||||
}
|
||||
if let Some(to) = filters.to {
|
||||
query = query.filter(posts::created_at.le(to));
|
||||
}
|
||||
query
|
||||
}
|
||||
|
||||
pub fn list_posts_filtered(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
filters: &PostFilterParams,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> rusqlite::Result<Vec<Post>> {
|
||||
// Build dynamic WHERE clause
|
||||
let mut conditions = vec!["p.project_id = ?1".to_string()];
|
||||
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
param_values.push(Box::new(project_id.to_string()));
|
||||
|
||||
// pages_only / exclude_pages
|
||||
if filters.pages_only {
|
||||
conditions.push("LOWER(p.categories) LIKE '%\"page\"%'".to_string());
|
||||
} else if filters.exclude_pages {
|
||||
conditions.push("LOWER(p.categories) NOT LIKE '%\"page\"%'".to_string());
|
||||
}
|
||||
|
||||
// For non-draft posts, apply filters. Drafts always pass.
|
||||
// We build this as: (status = 'draft') OR (filter conditions)
|
||||
let mut filter_conditions: Vec<String> = Vec::new();
|
||||
|
||||
if !filters.search_query.is_empty() {
|
||||
let idx = param_values.len() + 1;
|
||||
let pattern = format!("%{}%", filters.search_query.replace('%', "\\%"));
|
||||
filter_conditions.push(format!("(p.title LIKE ?{idx} ESCAPE '\\')"));
|
||||
param_values.push(Box::new(pattern));
|
||||
}
|
||||
|
||||
if let Some(status) = &filters.status {
|
||||
let idx = param_values.len() + 1;
|
||||
filter_conditions.push(format!("(p.status = ?{idx})"));
|
||||
param_values.push(Box::new(status.clone()));
|
||||
}
|
||||
|
||||
if let Some(language) = &filters.language {
|
||||
let idx = param_values.len() + 1;
|
||||
filter_conditions.push(format!("(p.language = ?{idx} OR p.language IS NULL)"));
|
||||
param_values.push(Box::new(language.clone()));
|
||||
}
|
||||
|
||||
if let Some(year) = filters.year {
|
||||
let (start, end) =
|
||||
calendar_range_unix_ms(year, filters.month).ok_or(rusqlite::Error::InvalidQuery)?;
|
||||
let idx1 = param_values.len() + 1;
|
||||
let idx2 = param_values.len() + 2;
|
||||
filter_conditions.push(format!(
|
||||
"(p.created_at >= ?{idx1} AND p.created_at < ?{idx2})"
|
||||
));
|
||||
param_values.push(Box::new(start));
|
||||
param_values.push(Box::new(end));
|
||||
}
|
||||
|
||||
for tag in &filters.tags {
|
||||
let idx = param_values.len() + 1;
|
||||
let pattern = format!("%\"{}\"%", tag.replace('"', "\\\""));
|
||||
filter_conditions.push(format!("(p.tags LIKE ?{idx})"));
|
||||
param_values.push(Box::new(pattern));
|
||||
}
|
||||
|
||||
for cat in &filters.categories {
|
||||
let idx = param_values.len() + 1;
|
||||
let pattern = format!("%\"{}\"%", cat.replace('"', "\\\""));
|
||||
filter_conditions.push(format!("(p.categories LIKE ?{idx})"));
|
||||
param_values.push(Box::new(pattern));
|
||||
}
|
||||
|
||||
if let Some(from) = filters.from {
|
||||
let idx = param_values.len() + 1;
|
||||
filter_conditions.push(format!("(p.created_at >= ?{idx})"));
|
||||
param_values.push(Box::new(from));
|
||||
}
|
||||
|
||||
if let Some(to) = filters.to {
|
||||
let idx = param_values.len() + 1;
|
||||
filter_conditions.push(format!("(p.created_at <= ?{idx})"));
|
||||
param_values.push(Box::new(to));
|
||||
}
|
||||
|
||||
// Without an explicit status filter, drafts remain visible regardless of the
|
||||
// rest of the active filters.
|
||||
if !filter_conditions.is_empty() {
|
||||
let combined = filter_conditions.join(" AND ");
|
||||
if filters.status.is_some() {
|
||||
conditions.push(format!("({combined})"));
|
||||
) -> QueryResult<Vec<Post>> {
|
||||
conn.with(|c| {
|
||||
let content_filters = filters.has_active_filters();
|
||||
let records = if filters.status.is_some() {
|
||||
post_query(project_id, filters, true, false)
|
||||
.order(posts::created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.select(PostRecord::as_select())
|
||||
.load(c)?
|
||||
} else if content_filters {
|
||||
let mut records: Vec<PostRecord> = post_query(project_id, filters, false, false)
|
||||
.filter(posts::status.eq("draft"))
|
||||
.select(PostRecord::as_select())
|
||||
.load(c)?;
|
||||
records.extend(
|
||||
post_query(project_id, filters, true, true)
|
||||
.select(PostRecord::as_select())
|
||||
.load::<PostRecord>(c)?,
|
||||
);
|
||||
records.sort_unstable_by_key(|record| std::cmp::Reverse(record.created_at));
|
||||
records
|
||||
.into_iter()
|
||||
.skip(offset.max(0) as usize)
|
||||
.take(limit.max(0) as usize)
|
||||
.collect()
|
||||
} else {
|
||||
conditions.push(format!("(p.status = 'draft' OR ({combined}))"));
|
||||
}
|
||||
}
|
||||
|
||||
let where_clause = conditions.join(" AND ");
|
||||
let idx_limit = param_values.len() + 1;
|
||||
let idx_offset = param_values.len() + 2;
|
||||
param_values.push(Box::new(limit));
|
||||
param_values.push(Box::new(offset));
|
||||
|
||||
let sql = format!(
|
||||
"SELECT {POST_COLUMNS} FROM posts p WHERE {where_clause} ORDER BY p.created_at DESC LIMIT ?{idx_limit} OFFSET ?{idx_offset}"
|
||||
);
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
|
||||
param_values.iter().map(|b| b.as_ref()).collect();
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params_refs.as_slice(), post_from_row)?;
|
||||
rows.collect()
|
||||
post_query(project_id, filters, false, false)
|
||||
.order(posts::created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.select(PostRecord::as_select())
|
||||
.load(c)?
|
||||
};
|
||||
convert_all(records)
|
||||
})
|
||||
}
|
||||
|
||||
/// Year/month counts for the calendar archive widget.
|
||||
/// Returns (year, month, count) tuples, ordered by year DESC, month DESC.
|
||||
pub fn post_calendar_counts(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
project_id: &str,
|
||||
pages_only: bool,
|
||||
exclude_pages: bool,
|
||||
) -> rusqlite::Result<Vec<(i32, u32, usize)>> {
|
||||
let page_filter = if pages_only {
|
||||
" AND LOWER(categories) LIKE '%\"page\"%'"
|
||||
} else if exclude_pages {
|
||||
" AND LOWER(categories) NOT LIKE '%\"page\"%'"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT
|
||||
CAST(strftime('%Y', created_at / 1000, 'unixepoch') AS INTEGER) AS y,
|
||||
CAST(strftime('%m', created_at / 1000, 'unixepoch') AS INTEGER) AS m,
|
||||
COUNT(*) AS cnt
|
||||
FROM posts
|
||||
WHERE project_id = ?1{page_filter}
|
||||
GROUP BY y, m
|
||||
ORDER BY y DESC, m DESC"
|
||||
);
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params![project_id], |row| {
|
||||
Ok((
|
||||
row.get::<_, i32>(0)?,
|
||||
row.get::<_, u32>(1)?,
|
||||
row.get::<_, usize>(2)?,
|
||||
))
|
||||
})?;
|
||||
rows.collect()
|
||||
) -> QueryResult<Vec<(i32, u32, usize)>> {
|
||||
conn.with(|c| {
|
||||
let rows = posts::table
|
||||
.filter(posts::project_id.eq(project_id))
|
||||
.select((posts::created_at, posts::categories))
|
||||
.load::<(i64, String)>(c)?;
|
||||
let mut counts = std::collections::BTreeMap::new();
|
||||
for (timestamp, categories) in rows {
|
||||
let categories: Vec<String> = serde_json::from_str(&categories).unwrap_or_default();
|
||||
let is_page = categories
|
||||
.iter()
|
||||
.any(|category| category.eq_ignore_ascii_case("page"));
|
||||
if (pages_only && !is_page) || (exclude_pages && is_page) {
|
||||
continue;
|
||||
}
|
||||
let date = Utc
|
||||
.timestamp_millis_opt(timestamp)
|
||||
.single()
|
||||
.ok_or_else(|| {
|
||||
diesel::result::Error::DeserializationError("invalid timestamp".into())
|
||||
})?;
|
||||
*counts.entry((date.year(), date.month())).or_insert(0) += 1;
|
||||
}
|
||||
Ok(counts
|
||||
.into_iter()
|
||||
.rev()
|
||||
.map(|((year, month), count)| (year, month, count))
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
|
||||
/// Collect all distinct tag values across posts for a project.
|
||||
pub fn distinct_post_tags(conn: &Connection, project_id: &str) -> rusqlite::Result<Vec<String>> {
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT DISTINCT tags FROM posts WHERE project_id = ?1 AND tags != '[]'")?;
|
||||
let rows = stmt.query_map(params![project_id], |row| row.get::<_, String>(0))?;
|
||||
pub fn distinct_post_tags(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<String>> {
|
||||
let rows = conn.with(|c| {
|
||||
posts::table
|
||||
.filter(posts::project_id.eq(project_id))
|
||||
.filter(posts::tags.ne("[]"))
|
||||
.select(posts::tags)
|
||||
.distinct()
|
||||
.load::<String>(c)
|
||||
})?;
|
||||
let mut all_tags = std::collections::BTreeSet::new();
|
||||
for json_str in rows {
|
||||
if let Ok(json_str) = json_str
|
||||
&& let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str)
|
||||
{
|
||||
if let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str) {
|
||||
all_tags.extend(tags);
|
||||
}
|
||||
}
|
||||
@@ -430,25 +395,53 @@ pub fn distinct_post_tags(conn: &Connection, project_id: &str) -> rusqlite::Resu
|
||||
}
|
||||
|
||||
/// Collect all distinct category values across posts for a project.
|
||||
pub fn distinct_post_categories(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> rusqlite::Result<Vec<String>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT DISTINCT categories FROM posts WHERE project_id = ?1 AND categories != '[]'",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![project_id], |row| row.get::<_, String>(0))?;
|
||||
pub fn distinct_post_categories(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<String>> {
|
||||
let rows = conn.with(|c| {
|
||||
posts::table
|
||||
.filter(posts::project_id.eq(project_id))
|
||||
.filter(posts::categories.ne("[]"))
|
||||
.select(posts::categories)
|
||||
.distinct()
|
||||
.load::<String>(c)
|
||||
})?;
|
||||
let mut all_cats = std::collections::BTreeSet::new();
|
||||
for json_str in rows {
|
||||
if let Ok(json_str) = json_str
|
||||
&& let Ok(cats) = serde_json::from_str::<Vec<String>>(&json_str)
|
||||
{
|
||||
if let Ok(cats) = serde_json::from_str::<Vec<String>>(&json_str) {
|
||||
all_cats.extend(cats);
|
||||
}
|
||||
}
|
||||
Ok(all_cats.into_iter().collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn make_test_post(id: &str, project_id: &str, slug: &str) -> Post {
|
||||
Post {
|
||||
id: id.into(),
|
||||
project_id: project_id.into(),
|
||||
title: id.into(),
|
||||
slug: slug.into(),
|
||||
excerpt: None,
|
||||
content: None,
|
||||
status: PostStatus::Draft,
|
||||
author: None,
|
||||
language: None,
|
||||
do_not_translate: false,
|
||||
template_slug: None,
|
||||
file_path: String::new(),
|
||||
checksum: None,
|
||||
tags: Vec::new(),
|
||||
categories: Vec::new(),
|
||||
published_title: None,
|
||||
published_content: None,
|
||||
published_tags: None,
|
||||
published_categories: None,
|
||||
published_excerpt: None,
|
||||
created_at: 1000,
|
||||
updated_at: 1000,
|
||||
published_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -456,7 +449,7 @@ mod tests {
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db
|
||||
|
||||
@@ -1,82 +1,77 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::from_row::{POST_LINK_COLUMNS, post_link_from_row};
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::from_row::PostLinkRecord;
|
||||
use crate::db::schema::post_links;
|
||||
use crate::model::PostLink;
|
||||
|
||||
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 insert_post_link(conn: &DbConnection, link: &PostLink) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(post_links::table)
|
||||
.values(PostLinkRecord::from(link))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
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 delete_links_by_source(conn: &DbConnection, source_post_id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(post_links::table.filter(post_links::source_post_id.eq(source_post_id)))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_links_by_target(conn: &DbConnection, target_post_id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(post_links::table.filter(post_links::target_post_id.eq(target_post_id)))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_links_by_source(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
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()
|
||||
) -> QueryResult<Vec<PostLink>> {
|
||||
conn.with(|c| {
|
||||
post_links::table
|
||||
.filter(post_links::source_post_id.eq(source_post_id))
|
||||
.order(post_links::created_at)
|
||||
.select(PostLinkRecord::as_select())
|
||||
.load(c)
|
||||
.map(|rows: Vec<PostLinkRecord>| rows.into_iter().map(Into::into).collect())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_links_by_target(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
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()
|
||||
) -> QueryResult<Vec<PostLink>> {
|
||||
conn.with(|c| {
|
||||
post_links::table
|
||||
.filter(post_links::target_post_id.eq(target_post_id))
|
||||
.order(post_links::created_at)
|
||||
.select(PostLinkRecord::as_select())
|
||||
.load(c)
|
||||
.map(|rows: Vec<PostLinkRecord>| rows.into_iter().map(Into::into).collect())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::post::{insert_post, make_test_post};
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
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();
|
||||
insert_post(db.conn(), &make_test_post("a", "p1", "a")).unwrap();
|
||||
insert_post(db.conn(), &make_test_post("b", "p1", "b")).unwrap();
|
||||
insert_post(db.conn(), &make_test_post("c", "p1", "c")).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
|
||||
@@ -1,65 +1,80 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::from_row::{POST_MEDIA_COLUMNS, post_media_from_row};
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::from_row::PostMediaRecord;
|
||||
use crate::db::schema::post_media;
|
||||
use crate::model::PostMedia;
|
||||
|
||||
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 link_media(conn: &DbConnection, pm: &PostMedia) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(post_media::table)
|
||||
.values(PostMediaRecord::from(pm))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
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 unlink_media(conn: &DbConnection, post_id: &str, media_id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(
|
||||
post_media::table
|
||||
.filter(post_media::post_id.eq(post_id))
|
||||
.filter(post_media::media_id.eq(media_id)),
|
||||
)
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
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 delete_post_media_by_post(conn: &DbConnection, post_id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(post_media::table.filter(post_media::post_id.eq(post_id)))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_post_media_by_post(conn: &DbConnection, post_id: &str) -> QueryResult<Vec<PostMedia>> {
|
||||
conn.with(|c| {
|
||||
post_media::table
|
||||
.filter(post_media::post_id.eq(post_id))
|
||||
.order(post_media::sort_order)
|
||||
.select(PostMediaRecord::as_select())
|
||||
.load(c)
|
||||
.map(|rows: Vec<PostMediaRecord>| rows.into_iter().map(Into::into).collect())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_post_media_by_media(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
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()
|
||||
) -> QueryResult<Vec<PostMedia>> {
|
||||
conn.with(|c| {
|
||||
post_media::table
|
||||
.filter(post_media::media_id.eq(media_id))
|
||||
.order(post_media::created_at)
|
||||
.select(PostMediaRecord::as_select())
|
||||
.load(c)
|
||||
.map(|rows: Vec<PostMediaRecord>| rows.into_iter().map(Into::into).collect())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_sort_order(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
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(())
|
||||
) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::update(
|
||||
post_media::table
|
||||
.filter(post_media::post_id.eq(post_id))
|
||||
.filter(post_media::media_id.eq(media_id)),
|
||||
)
|
||||
.set(post_media::sort_order.eq(sort_order))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -67,19 +82,14 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::media::{insert_media, make_test_media};
|
||||
use crate::db::queries::post::{insert_post, make_test_post};
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
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_post(db.conn(), &make_test_post("post1", "p1", "hello")).unwrap();
|
||||
insert_media(db.conn(), &make_test_media("m1", "p1")).unwrap();
|
||||
insert_media(db.conn(), &make_test_media("m2", "p1")).unwrap();
|
||||
db
|
||||
|
||||
@@ -1,139 +1,111 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::from_row::{
|
||||
POST_TRANSLATION_COLUMNS, post_status_to_str, post_translation_from_row,
|
||||
};
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::from_row::{PostTranslationRecord, convert, convert_all};
|
||||
use crate::db::schema::post_translations;
|
||||
use crate::model::PostTranslation;
|
||||
|
||||
pub fn insert_post_translation(conn: &Connection, t: &PostTranslation) -> rusqlite::Result<()> {
|
||||
pub fn insert_post_translation(conn: &DbConnection, t: &PostTranslation) -> QueryResult<()> {
|
||||
if !t.status.is_valid_for_translation() {
|
||||
return Err(rusqlite::Error::InvalidParameterName(
|
||||
"translation status must be draft or published".to_string(),
|
||||
return Err(diesel::result::Error::SerializationError(
|
||||
"translation status must be draft or published".into(),
|
||||
));
|
||||
}
|
||||
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(())
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(post_translations::table)
|
||||
.values(PostTranslationRecord::from(t))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
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_id(conn: &DbConnection, id: &str) -> QueryResult<PostTranslation> {
|
||||
conn.with(|c| {
|
||||
post_translations::table
|
||||
.filter(post_translations::id.eq(id))
|
||||
.select(PostTranslationRecord::as_select())
|
||||
.first(c)
|
||||
.and_then(convert)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_post_translation_by_post_and_language(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
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,
|
||||
)
|
||||
) -> QueryResult<PostTranslation> {
|
||||
conn.with(|c| {
|
||||
post_translations::table
|
||||
.filter(post_translations::translation_for.eq(translation_for))
|
||||
.filter(post_translations::language.eq(language))
|
||||
.select(PostTranslationRecord::as_select())
|
||||
.first(c)
|
||||
.and_then(convert)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_post_translations_by_post(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
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()
|
||||
) -> QueryResult<Vec<PostTranslation>> {
|
||||
conn.with(|c| {
|
||||
post_translations::table
|
||||
.filter(post_translations::translation_for.eq(translation_for))
|
||||
.order(post_translations::language)
|
||||
.select(PostTranslationRecord::as_select())
|
||||
.load(c)
|
||||
.and_then(convert_all)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_post_translation(conn: &Connection, t: &PostTranslation) -> rusqlite::Result<()> {
|
||||
pub fn update_post_translation(conn: &DbConnection, t: &PostTranslation) -> QueryResult<()> {
|
||||
if !t.status.is_valid_for_translation() {
|
||||
return Err(rusqlite::Error::InvalidParameterName(
|
||||
"translation status must be draft or published".to_string(),
|
||||
return Err(diesel::result::Error::SerializationError(
|
||||
"translation status must be draft or published".into(),
|
||||
));
|
||||
}
|
||||
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(())
|
||||
conn.with(|c| {
|
||||
diesel::update(post_translations::table.filter(post_translations::id.eq(&t.id)))
|
||||
.set(PostTranslationRecord::from(t))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
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_post_translation(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(post_translations::table.filter(post_translations::id.eq(id)))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_all_translations_for_post(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
translation_for: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM post_translations WHERE translation_for = ?1",
|
||||
params![translation_for],
|
||||
)?;
|
||||
Ok(())
|
||||
) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(
|
||||
post_translations::table.filter(post_translations::translation_for.eq(translation_for)),
|
||||
)
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::post::{insert_post, make_test_post};
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::model::PostStatus;
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
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();
|
||||
insert_post(db.conn(), &make_test_post("post1", "p1", "hello")).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
|
||||
@@ -1,90 +1,88 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::from_row::{PROJECT_COLUMNS, project_from_row};
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::from_row::ProjectRecord;
|
||||
use crate::db::schema::projects;
|
||||
use crate::model::Project;
|
||||
|
||||
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 insert_project(conn: &DbConnection, project: &Project) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(projects::table)
|
||||
.values(ProjectRecord::from(project))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
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_id(conn: &DbConnection, id: &str) -> QueryResult<Project> {
|
||||
conn.with(|c| {
|
||||
projects::table
|
||||
.filter(projects::id.eq(id))
|
||||
.select(ProjectRecord::as_select())
|
||||
.first(c)
|
||||
.map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
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_project_by_slug(conn: &DbConnection, slug: &str) -> QueryResult<Project> {
|
||||
conn.with(|c| {
|
||||
projects::table
|
||||
.filter(projects::slug.eq(slug))
|
||||
.select(ProjectRecord::as_select())
|
||||
.first(c)
|
||||
.map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
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 get_active_project(conn: &DbConnection) -> QueryResult<Project> {
|
||||
conn.with(|c| {
|
||||
projects::table
|
||||
.filter(projects::is_active.eq(1))
|
||||
.select(ProjectRecord::as_select())
|
||||
.first(c)
|
||||
.map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_active_project(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute("UPDATE projects SET is_active = 0 WHERE is_active = 1", [])?;
|
||||
tx.execute(
|
||||
"UPDATE projects SET is_active = 1 WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
pub fn set_active_project(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
c.transaction(|c| {
|
||||
diesel::update(projects::table.filter(projects::is_active.eq(1)))
|
||||
.set(projects::is_active.eq(0))
|
||||
.execute(c)?;
|
||||
diesel::update(projects::table.filter(projects::id.eq(id)))
|
||||
.set(projects::is_active.eq(1))
|
||||
.execute(c)?;
|
||||
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 list_projects(conn: &DbConnection) -> QueryResult<Vec<Project>> {
|
||||
conn.with(|c| {
|
||||
projects::table
|
||||
.order(projects::name)
|
||||
.select(ProjectRecord::as_select())
|
||||
.load(c)
|
||||
.map(|rows: Vec<ProjectRecord>| rows.into_iter().map(Into::into).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 update_project(conn: &DbConnection, project: &Project) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::update(projects::table.filter(projects::id.eq(&project.id)))
|
||||
.set(ProjectRecord::from(project))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_project(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM projects WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
pub fn delete_project(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(projects::table.filter(projects::id.eq(id)))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
/// Test helper: create a minimal Project value (available to sibling test modules).
|
||||
@@ -108,7 +106,7 @@ mod tests {
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
@@ -1,92 +1,70 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::from_row::{
|
||||
SCRIPT_COLUMNS, script_from_row, script_kind_to_str, script_status_to_str,
|
||||
};
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::from_row::{ScriptRecord, convert, convert_all};
|
||||
use crate::db::schema::scripts;
|
||||
use crate::model::Script;
|
||||
|
||||
pub fn insert_script(conn: &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 insert_script(conn: &DbConnection, s: &Script) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(scripts::table)
|
||||
.values(ScriptRecord::from(s))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
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_id(conn: &DbConnection, id: &str) -> QueryResult<Script> {
|
||||
conn.with(|c| {
|
||||
scripts::table
|
||||
.filter(scripts::id.eq(id))
|
||||
.select(ScriptRecord::as_select())
|
||||
.first(c)
|
||||
.and_then(convert)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_script_by_slug(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
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,
|
||||
)
|
||||
) -> QueryResult<Script> {
|
||||
conn.with(|c| {
|
||||
scripts::table
|
||||
.filter(scripts::project_id.eq(project_id))
|
||||
.filter(scripts::slug.eq(slug))
|
||||
.select(ScriptRecord::as_select())
|
||||
.first(c)
|
||||
.and_then(convert)
|
||||
})
|
||||
}
|
||||
|
||||
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 list_scripts_by_project(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<Script>> {
|
||||
conn.with(|c| {
|
||||
scripts::table
|
||||
.filter(scripts::project_id.eq(project_id))
|
||||
.order(scripts::title)
|
||||
.select(ScriptRecord::as_select())
|
||||
.load(c)
|
||||
.and_then(convert_all)
|
||||
})
|
||||
}
|
||||
|
||||
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 update_script(conn: &DbConnection, s: &Script) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::update(scripts::table.filter(scripts::id.eq(&s.id)))
|
||||
.set(ScriptRecord::from(s))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_script(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM scripts WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
pub fn delete_script(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(scripts::table.filter(scripts::id.eq(id)))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -97,7 +75,7 @@ mod tests {
|
||||
use crate::model::{ScriptKind, ScriptStatus};
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db
|
||||
|
||||
@@ -1,36 +1,52 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::from_row::{SETTING_COLUMNS, setting_from_row};
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::from_row::SettingRecord;
|
||||
use crate::db::schema::settings;
|
||||
use crate::model::Setting;
|
||||
|
||||
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 get_setting_by_key(conn: &DbConnection, key: &str) -> QueryResult<Setting> {
|
||||
conn.with(|c| {
|
||||
settings::table
|
||||
.filter(settings::key.eq(key))
|
||||
.select(SettingRecord::as_select())
|
||||
.first(c)
|
||||
.map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_setting_value(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
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(())
|
||||
) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(settings::table)
|
||||
.values((
|
||||
settings::key.eq(key),
|
||||
settings::value.eq(value),
|
||||
settings::updated_at.eq(updated_at),
|
||||
))
|
||||
.on_conflict(settings::key)
|
||||
.do_update()
|
||||
.set((
|
||||
settings::value.eq(value),
|
||||
settings::updated_at.eq(updated_at),
|
||||
))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_all_settings(conn: &Connection) -> rusqlite::Result<Vec<Setting>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {SETTING_COLUMNS} FROM settings ORDER BY key"
|
||||
))?;
|
||||
let rows = stmt.query_map([], setting_from_row)?;
|
||||
rows.collect()
|
||||
pub fn list_all_settings(conn: &DbConnection) -> QueryResult<Vec<Setting>> {
|
||||
conn.with(|c| {
|
||||
settings::table
|
||||
.order(settings::key)
|
||||
.select(SettingRecord::as_select())
|
||||
.load(c)
|
||||
.map(|rows: Vec<SettingRecord>| rows.into_iter().map(Into::into).collect())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -39,7 +55,7 @@ mod tests {
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
@@ -1,73 +1,73 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use diesel::prelude::*;
|
||||
use diesel::sql_types::Text;
|
||||
|
||||
use crate::db::from_row::{TAG_COLUMNS, tag_from_row};
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::from_row::TagRecord;
|
||||
use crate::db::schema::tags;
|
||||
use crate::model::Tag;
|
||||
|
||||
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(())
|
||||
diesel::define_sql_function!(fn lower(value: Text) -> Text);
|
||||
|
||||
pub fn insert_tag(conn: &DbConnection, tag: &Tag) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(tags::table)
|
||||
.values(TagRecord::from(tag))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
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_id(conn: &DbConnection, id: &str) -> QueryResult<Tag> {
|
||||
conn.with(|c| {
|
||||
tags::table
|
||||
.filter(tags::id.eq(id))
|
||||
.select(TagRecord::as_select())
|
||||
.first(c)
|
||||
.map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_tag_by_project_and_name(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
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,
|
||||
)
|
||||
) -> QueryResult<Tag> {
|
||||
conn.with(|c| {
|
||||
tags::table
|
||||
.filter(tags::project_id.eq(project_id))
|
||||
.filter(lower(tags::name).eq(name.to_lowercase()))
|
||||
.select(TagRecord::as_select())
|
||||
.first(c)
|
||||
.map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
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 list_tags_by_project(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<Tag>> {
|
||||
conn.with(|c| {
|
||||
tags::table
|
||||
.filter(tags::project_id.eq(project_id))
|
||||
.order(tags::name)
|
||||
.select(TagRecord::as_select())
|
||||
.load(c)
|
||||
.map(|rows: Vec<TagRecord>| rows.into_iter().map(Into::into).collect())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_tag(conn: &Connection, tag: &Tag) -> rusqlite::Result<()> {
|
||||
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 update_tag(conn: &DbConnection, tag: &Tag) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::update(tags::table.filter(tags::id.eq(&tag.id)))
|
||||
.set(TagRecord::from(tag))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_tag(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM tags WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
pub fn delete_tag(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(tags::table.filter(tags::id.eq(id)))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -77,7 +77,7 @@ mod tests {
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db
|
||||
|
||||
@@ -1,90 +1,73 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::db::from_row::{
|
||||
TEMPLATE_COLUMNS, template_from_row, template_kind_to_str, template_status_to_str,
|
||||
};
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::from_row::{TemplateRecord, convert, convert_all};
|
||||
use crate::db::schema::templates;
|
||||
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 insert_template(conn: &DbConnection, t: &Template) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::insert_into(templates::table)
|
||||
.values(TemplateRecord::from(t))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
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_id(conn: &DbConnection, id: &str) -> QueryResult<Template> {
|
||||
conn.with(|c| {
|
||||
templates::table
|
||||
.filter(templates::id.eq(id))
|
||||
.select(TemplateRecord::as_select())
|
||||
.first(c)
|
||||
.and_then(convert)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_template_by_slug(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
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,
|
||||
)
|
||||
) -> QueryResult<Template> {
|
||||
conn.with(|c| {
|
||||
templates::table
|
||||
.filter(templates::project_id.eq(project_id))
|
||||
.filter(templates::slug.eq(slug))
|
||||
.select(TemplateRecord::as_select())
|
||||
.first(c)
|
||||
.and_then(convert)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_templates_by_project(
|
||||
conn: &Connection,
|
||||
conn: &DbConnection,
|
||||
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()
|
||||
) -> QueryResult<Vec<Template>> {
|
||||
conn.with(|c| {
|
||||
templates::table
|
||||
.filter(templates::project_id.eq(project_id))
|
||||
.order(templates::title)
|
||||
.select(TemplateRecord::as_select())
|
||||
.load(c)
|
||||
.and_then(convert_all)
|
||||
})
|
||||
}
|
||||
|
||||
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 update_template(conn: &DbConnection, t: &Template) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::update(templates::table.filter(templates::id.eq(&t.id)))
|
||||
.set(TemplateRecord::from(t))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_template(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM templates WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
pub fn delete_template(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::delete(templates::table.filter(templates::id.eq(id)))
|
||||
.execute(c)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -95,7 +78,7 @@ mod tests {
|
||||
use crate::model::{TemplateKind, TemplateStatus};
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
db
|
||||
|
||||
91
crates/bds-core/src/db/schema.patch
Normal file
91
crates/bds-core/src/db/schema.patch
Normal file
@@ -0,0 +1,91 @@
|
||||
--- schema.rs
|
||||
+++ schema.rs
|
||||
@@ -45 +45 @@
|
||||
- updated_at -> Integer,
|
||||
+ updated_at -> BigInt,
|
||||
@@ -57 +57 @@
|
||||
- updated_at -> Integer,
|
||||
+ updated_at -> BigInt,
|
||||
@@ -67,2 +67,2 @@
|
||||
- created_at -> Integer,
|
||||
- updated_at -> Integer,
|
||||
+ created_at -> BigInt,
|
||||
+ updated_at -> BigInt,
|
||||
@@ -80 +80 @@
|
||||
- created_at -> Integer,
|
||||
+ created_at -> BigInt,
|
||||
@@ -93,2 +93,2 @@
|
||||
- seen_at -> Nullable<Integer>,
|
||||
- created_at -> Integer,
|
||||
+ seen_at -> Nullable<BigInt>,
|
||||
+ created_at -> BigInt,
|
||||
@@ -104 +104 @@
|
||||
- dismissed_at -> Integer,
|
||||
+ dismissed_at -> BigInt,
|
||||
@@ -110 +110 @@
|
||||
- label -> Integer,
|
||||
+ label -> BigInt,
|
||||
@@ -124 +124 @@
|
||||
- updated_at -> Integer,
|
||||
+ updated_at -> BigInt,
|
||||
@@ -136,2 +136,2 @@
|
||||
- created_at -> Integer,
|
||||
- updated_at -> Integer,
|
||||
+ created_at -> BigInt,
|
||||
+ updated_at -> BigInt,
|
||||
@@ -148 +148 @@
|
||||
- size -> Integer,
|
||||
+ size -> BigInt,
|
||||
@@ -157,2 +157,2 @@
|
||||
- created_at -> Integer,
|
||||
- updated_at -> Integer,
|
||||
+ created_at -> BigInt,
|
||||
+ updated_at -> BigInt,
|
||||
@@ -174,2 +174,2 @@
|
||||
- created_at -> Integer,
|
||||
- updated_at -> Integer,
|
||||
+ created_at -> BigInt,
|
||||
+ updated_at -> BigInt,
|
||||
@@ -185 +185 @@
|
||||
- created_at -> Integer,
|
||||
+ created_at -> BigInt,
|
||||
@@ -196 +196 @@
|
||||
- created_at -> Integer,
|
||||
+ created_at -> BigInt,
|
||||
@@ -210,3 +210,3 @@
|
||||
- created_at -> Integer,
|
||||
- updated_at -> Integer,
|
||||
- published_at -> Nullable<Integer>,
|
||||
+ created_at -> BigInt,
|
||||
+ updated_at -> BigInt,
|
||||
+ published_at -> Nullable<BigInt>,
|
||||
@@ -228,3 +228,3 @@
|
||||
- created_at -> Integer,
|
||||
- updated_at -> Integer,
|
||||
- published_at -> Nullable<Integer>,
|
||||
+ created_at -> BigInt,
|
||||
+ updated_at -> BigInt,
|
||||
+ published_at -> Nullable<BigInt>,
|
||||
@@ -254,2 +254,2 @@
|
||||
- created_at -> Integer,
|
||||
- updated_at -> Integer,
|
||||
+ created_at -> BigInt,
|
||||
+ updated_at -> BigInt,
|
||||
@@ -272,2 +272,2 @@
|
||||
- created_at -> Integer,
|
||||
- updated_at -> Integer,
|
||||
+ created_at -> BigInt,
|
||||
+ updated_at -> BigInt,
|
||||
@@ -281 +281 @@
|
||||
- updated_at -> Integer,
|
||||
+ updated_at -> BigInt,
|
||||
@@ -292,2 +292,2 @@
|
||||
- created_at -> Integer,
|
||||
- updated_at -> Integer,
|
||||
+ created_at -> BigInt,
|
||||
+ updated_at -> BigInt,
|
||||
@@ -309,2 +309,2 @@
|
||||
- created_at -> Integer,
|
||||
- updated_at -> Integer,
|
||||
+ created_at -> BigInt,
|
||||
+ updated_at -> BigInt,
|
||||
355
crates/bds-core/src/db/schema.rs
Normal file
355
crates/bds-core/src/db/schema.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
// @generated automatically by Diesel CLI.
|
||||
|
||||
diesel::table! {
|
||||
ai_catalog_meta (key) {
|
||||
key -> Text,
|
||||
value -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
ai_model_modalities (rowid) {
|
||||
rowid -> Integer,
|
||||
provider -> Text,
|
||||
model_id -> Text,
|
||||
direction -> Text,
|
||||
modality -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
ai_models (provider, model_id) {
|
||||
provider -> Text,
|
||||
model_id -> Text,
|
||||
name -> Text,
|
||||
family -> Nullable<Text>,
|
||||
attachment -> Integer,
|
||||
reasoning -> Integer,
|
||||
tool_call -> Integer,
|
||||
structured_output -> Integer,
|
||||
temperature -> Integer,
|
||||
knowledge -> Nullable<Text>,
|
||||
release_date -> Nullable<Text>,
|
||||
last_updated_date -> Nullable<Text>,
|
||||
open_weights -> Integer,
|
||||
input_price -> Nullable<Integer>,
|
||||
output_price -> Nullable<Integer>,
|
||||
cache_read_price -> Nullable<Integer>,
|
||||
cache_write_price -> Nullable<Integer>,
|
||||
context_window -> Integer,
|
||||
max_input_tokens -> Integer,
|
||||
max_output_tokens -> Integer,
|
||||
interleaved -> Nullable<Text>,
|
||||
status -> Nullable<Text>,
|
||||
provider_package_ref -> Nullable<Text>,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
ai_providers (id) {
|
||||
id -> Text,
|
||||
name -> Text,
|
||||
env -> Nullable<Text>,
|
||||
package_ref -> Nullable<Text>,
|
||||
api -> Nullable<Text>,
|
||||
doc -> Nullable<Text>,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
chat_conversations (id) {
|
||||
id -> Text,
|
||||
title -> Text,
|
||||
model -> Nullable<Text>,
|
||||
copilot_session_id -> Nullable<Text>,
|
||||
created_at -> BigInt,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
chat_messages (id) {
|
||||
id -> Integer,
|
||||
conversation_id -> Text,
|
||||
role -> Text,
|
||||
content -> Nullable<Text>,
|
||||
tool_call_id -> Nullable<Text>,
|
||||
tool_calls -> Nullable<Text>,
|
||||
created_at -> BigInt,
|
||||
cache_read_tokens -> Nullable<Integer>,
|
||||
cache_write_tokens -> Nullable<Integer>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
db_notifications (id) {
|
||||
id -> Integer,
|
||||
entity_type -> Text,
|
||||
entity_id -> Text,
|
||||
action -> Text,
|
||||
from_cli -> Integer,
|
||||
seen_at -> Nullable<BigInt>,
|
||||
created_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
dismissed_duplicate_pairs (id) {
|
||||
id -> Text,
|
||||
project_id -> Text,
|
||||
post_id_a -> Text,
|
||||
post_id_b -> Text,
|
||||
dismissed_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
embedding_keys (label) {
|
||||
label -> BigInt,
|
||||
post_id -> Text,
|
||||
project_id -> Text,
|
||||
content_hash -> Text,
|
||||
vector -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
generated_file_hashes (rowid) {
|
||||
rowid -> Integer,
|
||||
project_id -> Text,
|
||||
relative_path -> Text,
|
||||
content_hash -> Text,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
import_definitions (id) {
|
||||
id -> Text,
|
||||
project_id -> Text,
|
||||
name -> Text,
|
||||
wxr_file_path -> Nullable<Text>,
|
||||
uploads_folder_path -> Nullable<Text>,
|
||||
last_analysis_result -> Nullable<Text>,
|
||||
created_at -> BigInt,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
media (id) {
|
||||
id -> Text,
|
||||
project_id -> Text,
|
||||
filename -> Text,
|
||||
original_name -> Text,
|
||||
mime_type -> Text,
|
||||
size -> BigInt,
|
||||
width -> Nullable<Integer>,
|
||||
height -> Nullable<Integer>,
|
||||
title -> Nullable<Text>,
|
||||
alt -> Nullable<Text>,
|
||||
caption -> Nullable<Text>,
|
||||
author -> Nullable<Text>,
|
||||
file_path -> Text,
|
||||
sidecar_path -> Text,
|
||||
created_at -> BigInt,
|
||||
updated_at -> BigInt,
|
||||
checksum -> Nullable<Text>,
|
||||
tags -> Text,
|
||||
language -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
media_translations (id) {
|
||||
id -> Text,
|
||||
project_id -> Text,
|
||||
translation_for -> Text,
|
||||
language -> Text,
|
||||
title -> Nullable<Text>,
|
||||
alt -> Nullable<Text>,
|
||||
caption -> Nullable<Text>,
|
||||
created_at -> BigInt,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
post_links (id) {
|
||||
id -> Text,
|
||||
source_post_id -> Text,
|
||||
target_post_id -> Text,
|
||||
link_text -> Nullable<Text>,
|
||||
created_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
post_media (id) {
|
||||
id -> Text,
|
||||
project_id -> Text,
|
||||
post_id -> Text,
|
||||
media_id -> Text,
|
||||
sort_order -> Integer,
|
||||
created_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
post_translations (id) {
|
||||
id -> Text,
|
||||
project_id -> Text,
|
||||
translation_for -> Text,
|
||||
language -> Text,
|
||||
title -> Text,
|
||||
excerpt -> Nullable<Text>,
|
||||
content -> Nullable<Text>,
|
||||
status -> Text,
|
||||
created_at -> BigInt,
|
||||
updated_at -> BigInt,
|
||||
published_at -> Nullable<BigInt>,
|
||||
file_path -> Text,
|
||||
checksum -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
posts (id) {
|
||||
id -> Text,
|
||||
project_id -> Text,
|
||||
title -> Text,
|
||||
slug -> Text,
|
||||
excerpt -> Nullable<Text>,
|
||||
content -> Nullable<Text>,
|
||||
status -> Text,
|
||||
author -> Nullable<Text>,
|
||||
created_at -> BigInt,
|
||||
updated_at -> BigInt,
|
||||
published_at -> Nullable<BigInt>,
|
||||
file_path -> Text,
|
||||
checksum -> Nullable<Text>,
|
||||
tags -> Text,
|
||||
categories -> Text,
|
||||
template_slug -> Nullable<Text>,
|
||||
language -> Nullable<Text>,
|
||||
do_not_translate -> Integer,
|
||||
published_title -> Nullable<Text>,
|
||||
published_content -> Nullable<Text>,
|
||||
published_tags -> Nullable<Text>,
|
||||
published_categories -> Nullable<Text>,
|
||||
published_excerpt -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
projects (id) {
|
||||
id -> Text,
|
||||
name -> Text,
|
||||
slug -> Text,
|
||||
description -> Nullable<Text>,
|
||||
data_path -> Nullable<Text>,
|
||||
is_active -> Integer,
|
||||
created_at -> BigInt,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
scripts (id) {
|
||||
id -> Text,
|
||||
project_id -> Text,
|
||||
slug -> Text,
|
||||
title -> Text,
|
||||
kind -> Text,
|
||||
entrypoint -> Text,
|
||||
enabled -> Integer,
|
||||
version -> Integer,
|
||||
file_path -> Text,
|
||||
status -> Text,
|
||||
content -> Nullable<Text>,
|
||||
created_at -> BigInt,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
settings (key) {
|
||||
key -> Text,
|
||||
value -> Text,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
tags (id) {
|
||||
id -> Text,
|
||||
project_id -> Text,
|
||||
name -> Text,
|
||||
color -> Nullable<Text>,
|
||||
post_template_slug -> Nullable<Text>,
|
||||
created_at -> BigInt,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
templates (id) {
|
||||
id -> Text,
|
||||
project_id -> Text,
|
||||
slug -> Text,
|
||||
title -> Text,
|
||||
kind -> Text,
|
||||
enabled -> Integer,
|
||||
version -> Integer,
|
||||
file_path -> Text,
|
||||
status -> Text,
|
||||
content -> Nullable<Text>,
|
||||
created_at -> BigInt,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::joinable!(ai_models -> ai_providers (provider));
|
||||
diesel::joinable!(chat_messages -> chat_conversations (conversation_id));
|
||||
diesel::joinable!(dismissed_duplicate_pairs -> projects (project_id));
|
||||
diesel::joinable!(generated_file_hashes -> projects (project_id));
|
||||
diesel::joinable!(import_definitions -> projects (project_id));
|
||||
diesel::joinable!(media -> projects (project_id));
|
||||
diesel::joinable!(media_translations -> media (translation_for));
|
||||
diesel::joinable!(media_translations -> projects (project_id));
|
||||
diesel::joinable!(post_media -> media (media_id));
|
||||
diesel::joinable!(post_media -> posts (post_id));
|
||||
diesel::joinable!(post_media -> projects (project_id));
|
||||
diesel::joinable!(post_translations -> posts (translation_for));
|
||||
diesel::joinable!(post_translations -> projects (project_id));
|
||||
diesel::joinable!(posts -> projects (project_id));
|
||||
diesel::joinable!(scripts -> projects (project_id));
|
||||
diesel::joinable!(tags -> projects (project_id));
|
||||
diesel::joinable!(templates -> projects (project_id));
|
||||
|
||||
diesel::allow_tables_to_appear_in_same_query!(
|
||||
ai_catalog_meta,
|
||||
ai_model_modalities,
|
||||
ai_models,
|
||||
ai_providers,
|
||||
chat_conversations,
|
||||
chat_messages,
|
||||
db_notifications,
|
||||
dismissed_duplicate_pairs,
|
||||
embedding_keys,
|
||||
generated_file_hashes,
|
||||
import_definitions,
|
||||
media,
|
||||
media_translations,
|
||||
post_links,
|
||||
post_media,
|
||||
post_translations,
|
||||
posts,
|
||||
projects,
|
||||
scripts,
|
||||
settings,
|
||||
tags,
|
||||
templates,
|
||||
);
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::db::DbConnection as Connection;
|
||||
use keyring::Entry;
|
||||
use reqwest::blocking::Client;
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -681,7 +681,7 @@ fn get_optional_setting(conn: &Connection, key: &str) -> EngineResult<Option<Str
|
||||
match setting::get_setting_by_key(conn, key) {
|
||||
Ok(setting) if setting.value.trim().is_empty() => Ok(None),
|
||||
Ok(setting) => Ok(Some(setting.value)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(diesel::result::Error::NotFound) => Ok(None),
|
||||
Err(error) => Err(EngineError::Db(error)),
|
||||
}
|
||||
}
|
||||
@@ -724,7 +724,7 @@ mod tests {
|
||||
use crate::db::Database;
|
||||
|
||||
fn setup() -> Database {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use crate::db::DbConnection as Connection;
|
||||
|
||||
use crate::db::queries::post as post_q;
|
||||
use crate::engine::EngineResult;
|
||||
@@ -65,7 +65,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn calendar_empty_project() {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let _ = db.migrate();
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::path::PathBuf;
|
||||
|
||||
/// Shared context passed to engine operations.
|
||||
pub struct EngineContext<'a> {
|
||||
pub conn: &'a rusqlite::Connection,
|
||||
pub conn: &'a crate::db::DbConnection,
|
||||
pub project_id: String,
|
||||
pub data_dir: PathBuf,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ use std::fmt;
|
||||
/// Errors produced by engine operations.
|
||||
#[derive(Debug)]
|
||||
pub enum EngineError {
|
||||
Db(rusqlite::Error),
|
||||
Db(diesel::result::Error),
|
||||
DbConnection(diesel::ConnectionError),
|
||||
Io(std::io::Error),
|
||||
Parse(String),
|
||||
NotFound(String),
|
||||
@@ -15,6 +16,7 @@ impl fmt::Display for EngineError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Db(e) => write!(f, "database error: {e}"),
|
||||
Self::DbConnection(e) => write!(f, "database connection error: {e}"),
|
||||
Self::Io(e) => write!(f, "I/O error: {e}"),
|
||||
Self::Parse(msg) => write!(f, "parse error: {msg}"),
|
||||
Self::NotFound(msg) => write!(f, "not found: {msg}"),
|
||||
@@ -28,18 +30,34 @@ impl std::error::Error for EngineError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Db(e) => Some(e),
|
||||
Self::DbConnection(e) => Some(e),
|
||||
Self::Io(e) => Some(e),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for EngineError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
impl From<diesel::result::Error> for EngineError {
|
||||
fn from(e: diesel::result::Error) -> Self {
|
||||
Self::Db(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<diesel::ConnectionError> for EngineError {
|
||||
fn from(e: diesel::ConnectionError) -> Self {
|
||||
Self::DbConnection(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::db::DatabaseError> for EngineError {
|
||||
fn from(e: crate::db::DatabaseError) -> Self {
|
||||
match e {
|
||||
crate::db::DatabaseError::Connection(e) => Self::DbConnection(e),
|
||||
crate::db::DatabaseError::Query(e) => Self::Db(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for EngineError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
Self::Io(e)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::db::DbConnection as Connection;
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use pagefind::api::PagefindIndex;
|
||||
use pagefind::options::PagefindServiceConfig;
|
||||
use rusqlite::Connection;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::db::queries;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use crate::db::DbConnection as Connection;
|
||||
use uuid::Uuid;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
@@ -670,7 +670,7 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
ensure_fts_tables(db.conn()).unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use crate::db::DbConnection as Connection;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::db::from_row::{script_kind_to_str, template_kind_to_str};
|
||||
@@ -629,7 +629,7 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
ensure_fts_tables(db.conn()).unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
use crate::db::DbConnection as Connection;
|
||||
use uuid::Uuid;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
@@ -189,7 +189,7 @@ pub fn update_post(
|
||||
|
||||
/// Publish a post: write file, clear content, set published_at.
|
||||
pub fn publish_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult<Post> {
|
||||
let mut post = qp::get_post_by_id(conn, post_id)?;
|
||||
let post = qp::get_post_by_id(conn, post_id)?;
|
||||
|
||||
// Require Draft or Archived status
|
||||
match post.status {
|
||||
@@ -201,10 +201,27 @@ pub fn publish_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine
|
||||
}
|
||||
}
|
||||
|
||||
// Compute file_path from created_at + slug
|
||||
// Use a savepoint for atomicity
|
||||
// Note: savepoint auto-rolls-back if not released (on error propagation)
|
||||
conn.execute_batch("SAVEPOINT publish_post")?;
|
||||
conn.begin_savepoint()?;
|
||||
match publish_post_in_savepoint(conn, data_dir, post) {
|
||||
Ok(post) => {
|
||||
conn.release_savepoint()?;
|
||||
Ok(post)
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = conn.rollback_savepoint();
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_post_in_savepoint(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
mut post: Post,
|
||||
) -> EngineResult<Post> {
|
||||
let post_id = post.id.clone();
|
||||
|
||||
// Compute file_path from created_at + slug.
|
||||
let rel_path = post_file_path(post.created_at, &post.slug);
|
||||
let abs_path = data_dir.join(&rel_path);
|
||||
|
||||
@@ -245,7 +262,7 @@ pub fn publish_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine
|
||||
|
||||
qp::set_published_snapshot(
|
||||
conn,
|
||||
post_id,
|
||||
&post_id,
|
||||
&post.title,
|
||||
&body,
|
||||
&tags_json,
|
||||
@@ -256,27 +273,24 @@ pub fn publish_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine
|
||||
)?;
|
||||
|
||||
// Set file_path and checksum in DB
|
||||
qp::set_post_file_path(conn, post_id, &post.file_path, now)?;
|
||||
conn.execute(
|
||||
"UPDATE posts SET checksum = ?1 WHERE id = ?2",
|
||||
params![post.checksum, post_id],
|
||||
)?;
|
||||
qp::set_post_file_path(conn, &post_id, &post.file_path, now)?;
|
||||
qp::set_post_checksum(conn, &post_id, post.checksum.as_deref())?;
|
||||
|
||||
// Clear content in DB
|
||||
qp::clear_post_content(conn, post_id, now)?;
|
||||
qp::clear_post_content(conn, &post_id, now)?;
|
||||
post.content = None;
|
||||
|
||||
// Set status = Published
|
||||
qp::update_post_status(conn, post_id, &PostStatus::Published, now)?;
|
||||
qp::update_post_status(conn, &post_id, &PostStatus::Published, now)?;
|
||||
|
||||
// Publish all translations
|
||||
let translations = qt::list_post_translations_by_post(conn, post_id)?;
|
||||
let translations = qt::list_post_translations_by_post(conn, &post_id)?;
|
||||
for mut t in translations {
|
||||
publish_translation(conn, data_dir, &mut t, &post)?;
|
||||
}
|
||||
|
||||
// Parse inter-post links and update link graph
|
||||
ql::delete_links_by_source(conn, post_id)?;
|
||||
ql::delete_links_by_source(conn, &post_id)?;
|
||||
let link_body = if let Some(ref pc) = post.published_content {
|
||||
pc.as_str()
|
||||
} else {
|
||||
@@ -287,7 +301,7 @@ pub fn publish_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine
|
||||
if let Ok(target) = qp::get_post_by_project_and_slug(conn, &post.project_id, target_slug) {
|
||||
let link = PostLink {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
source_post_id: post_id.to_string(),
|
||||
source_post_id: post_id.clone(),
|
||||
target_post_id: target.id.clone(),
|
||||
link_text: Some(link_text.clone()),
|
||||
created_at: now,
|
||||
@@ -299,8 +313,6 @@ pub fn publish_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine
|
||||
// Re-index FTS
|
||||
fts_index_post(conn, &post)?;
|
||||
|
||||
conn.execute_batch("RELEASE publish_post")?;
|
||||
|
||||
Ok(post)
|
||||
}
|
||||
|
||||
@@ -515,16 +527,10 @@ pub fn delete_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineR
|
||||
|
||||
// Delete post links (source and target)
|
||||
ql::delete_links_by_source(conn, post_id)?;
|
||||
conn.execute(
|
||||
"DELETE FROM post_links WHERE target_post_id = ?1",
|
||||
params![post_id],
|
||||
)?;
|
||||
ql::delete_links_by_target(conn, post_id)?;
|
||||
|
||||
// Delete post-media associations
|
||||
conn.execute(
|
||||
"DELETE FROM post_media WHERE post_id = ?1",
|
||||
params![post_id],
|
||||
)?;
|
||||
crate::db::queries::post_media::delete_post_media_by_post(conn, post_id)?;
|
||||
|
||||
// Remove from FTS
|
||||
fts::remove_post_from_index(conn, post_id)?;
|
||||
@@ -882,13 +888,6 @@ fn publish_translation(
|
||||
t.content = None;
|
||||
|
||||
qt::update_post_translation(conn, t)?;
|
||||
// Clear content after update (the update already set content=None via the struct)
|
||||
// but we also do an explicit clear to be safe
|
||||
conn.execute(
|
||||
"UPDATE post_translations SET content = NULL WHERE id = ?1",
|
||||
params![t.id],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1149,7 +1148,7 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
ensure_fts_tables(db.conn()).unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use crate::db::DbConnection as Connection;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db::queries::media as qm;
|
||||
@@ -109,22 +109,17 @@ mod tests {
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::db::queries::media::{insert_media, make_test_media};
|
||||
use crate::db::queries::post::{insert_post, make_test_post};
|
||||
use crate::db::queries::post_media::list_post_media_by_post;
|
||||
use crate::db::queries::project::{insert_project, make_test_project};
|
||||
use crate::util::sidecar::read_sidecar;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Create a post
|
||||
db.conn()
|
||||
.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, file_path, created_at, updated_at) VALUES ('post1', 'p1', 'Test', 'test', 'draft', '', 1000, 1000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
insert_post(db.conn(), &make_test_post("post1", "p1", "test")).unwrap();
|
||||
(db, dir)
|
||||
}
|
||||
|
||||
@@ -197,13 +192,10 @@ mod tests {
|
||||
let (db, dir) = setup();
|
||||
insert_test_media(&db, dir.path(), "m1");
|
||||
|
||||
// Create a second post
|
||||
db.conn()
|
||||
.execute(
|
||||
"INSERT INTO posts (id, project_id, title, slug, status, file_path, created_at, updated_at) VALUES ('post2', 'p1', 'Test2', 'test2', 'draft', '', 2000, 2000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let mut post = make_test_post("post2", "p1", "test2");
|
||||
post.created_at = 2000;
|
||||
post.updated_at = 2000;
|
||||
insert_post(db.conn(), &post).unwrap();
|
||||
|
||||
link_media_to_post(db.conn(), dir.path(), "p1", "post1", "m1", 0).unwrap();
|
||||
link_media_to_post(db.conn(), dir.path(), "p1", "post2", "m1", 0).unwrap();
|
||||
|
||||
@@ -453,7 +453,7 @@ mod tests {
|
||||
meta::write_project_json(dir.path(), &make_metadata()).unwrap();
|
||||
|
||||
let db_path = dir.path().join("bds.db");
|
||||
let mut db = Database::open(&db_path).unwrap();
|
||||
let db = Database::open(&db_path).unwrap();
|
||||
db.migrate().unwrap();
|
||||
queries::project::insert_project(
|
||||
db.conn(),
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use crate::db::DbConnection as Connection;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db::queries::project as q;
|
||||
@@ -64,7 +64,7 @@ pub fn ensure_default_project(
|
||||
) -> EngineResult<Project> {
|
||||
match q::get_project_by_id(conn, DEFAULT_PROJECT_ID) {
|
||||
Ok(p) => Ok(p),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
Err(diesel::result::Error::NotFound) => {
|
||||
let now = now_unix_ms();
|
||||
let project = Project {
|
||||
id: DEFAULT_PROJECT_ID.to_string(),
|
||||
@@ -94,7 +94,7 @@ pub fn ensure_default_project(
|
||||
pub fn get_active_project(conn: &Connection) -> EngineResult<Option<Project>> {
|
||||
match q::get_active_project(conn) {
|
||||
Ok(p) => Ok(Some(p)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(diesel::result::Error::NotFound) => Ok(None),
|
||||
Err(e) => Err(EngineError::Db(e)),
|
||||
}
|
||||
}
|
||||
@@ -280,7 +280,7 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
(db, dir)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use crate::db::DbConnection as Connection;
|
||||
|
||||
use crate::db::fts;
|
||||
use crate::engine::EngineResult;
|
||||
@@ -157,7 +157,7 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
fts::ensure_fts_tables(db.conn()).unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use crate::db::DbConnection as Connection;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db::queries::script as qs;
|
||||
@@ -305,7 +305,7 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use crate::db::DbConnection as Connection;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::db::queries::script as qs;
|
||||
@@ -145,7 +145,7 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Full-text search reindexing engine functions.
|
||||
|
||||
use rusqlite::Connection;
|
||||
use crate::db::DbConnection as Connection;
|
||||
|
||||
use crate::db::fts;
|
||||
use crate::db::queries::{media as media_q, media_translation, post as post_q, post_translation};
|
||||
@@ -32,8 +32,7 @@ pub fn reindex_all_with_progress(
|
||||
on_item: Option<ItemProgressFn>,
|
||||
) -> EngineResult<ReindexReport> {
|
||||
// Wipe existing FTS content
|
||||
conn.execute("DELETE FROM posts_fts", [])?;
|
||||
conn.execute("DELETE FROM media_fts", [])?;
|
||||
fts::clear_indexes(conn)?;
|
||||
|
||||
// Reindex all posts
|
||||
let posts = post_q::list_posts_by_project(conn, project_id)?;
|
||||
@@ -124,7 +123,7 @@ mod tests {
|
||||
use crate::engine;
|
||||
|
||||
fn setup() -> (Database, String) {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let _ = db.migrate();
|
||||
ensure_fts_tables(db.conn()).unwrap();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use crate::db::DbConnection as Connection;
|
||||
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::render::{GeneratedWriteOutcome, write_generated_bytes};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use crate::db::DbConnection as Connection;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db::queries::post as post_q;
|
||||
@@ -360,7 +360,7 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use crate::db::DbConnection as Connection;
|
||||
use crate::db::schema::{posts, tags};
|
||||
use diesel::prelude::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db::queries::template as qt;
|
||||
@@ -383,36 +385,40 @@ fn validate_liquid_blocks(content: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn count_posts_using_template(conn: &Connection, slug: &str) -> EngineResult<usize> {
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM posts WHERE template_slug = ?1",
|
||||
rusqlite::params![slug],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let count: i64 = conn.with(|c| {
|
||||
posts::table
|
||||
.filter(posts::template_slug.eq(slug))
|
||||
.count()
|
||||
.get_result(c)
|
||||
})?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
fn count_tags_using_template(conn: &Connection, slug: &str) -> EngineResult<usize> {
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM tags WHERE post_template_slug = ?1",
|
||||
rusqlite::params![slug],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let count: i64 = conn.with(|c| {
|
||||
tags::table
|
||||
.filter(tags::post_template_slug.eq(slug))
|
||||
.count()
|
||||
.get_result(c)
|
||||
})?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
fn null_template_slug_on_posts(conn: &Connection, slug: &str) -> EngineResult<()> {
|
||||
conn.execute(
|
||||
"UPDATE posts SET template_slug = NULL WHERE template_slug = ?1",
|
||||
rusqlite::params![slug],
|
||||
)?;
|
||||
conn.with(|c| {
|
||||
diesel::update(posts::table.filter(posts::template_slug.eq(slug)))
|
||||
.set(posts::template_slug.eq(None::<String>))
|
||||
.execute(c)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn null_template_slug_on_tags(conn: &Connection, slug: &str) -> EngineResult<()> {
|
||||
conn.execute(
|
||||
"UPDATE tags SET post_template_slug = NULL WHERE post_template_slug = ?1",
|
||||
rusqlite::params![slug],
|
||||
)?;
|
||||
conn.with(|c| {
|
||||
diesel::update(tags::table.filter(tags::post_template_slug.eq(slug)))
|
||||
.set(tags::post_template_slug.eq(None::<String>))
|
||||
.execute(c)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -424,7 +430,7 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use crate::db::DbConnection as Connection;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::db::queries::template as qt;
|
||||
@@ -144,7 +144,7 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use crate::db::DbConnection as Connection;
|
||||
|
||||
use crate::db::queries::media as mq;
|
||||
use crate::db::queries::post_media as pmq;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use crate::db::DbConnection as Connection;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::db::queries;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use crate::db::DbConnection as Connection;
|
||||
|
||||
use crate::db::queries::{post as post_q, post_translation};
|
||||
use crate::engine::EngineResult;
|
||||
|
||||
@@ -2,8 +2,8 @@ use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::db::DbConnection as Connection;
|
||||
use chrono::{Datelike, TimeZone, Utc};
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::db::queries::generated_file_hash as qhash;
|
||||
|
||||
@@ -3,9 +3,9 @@ use std::error::Error;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::db::DbConnection as Connection;
|
||||
use chrono::{Datelike, TimeZone, Utc};
|
||||
use rayon::prelude::*;
|
||||
use rusqlite::Connection;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::db::queries;
|
||||
|
||||
Reference in New Issue
Block a user