[#2] Adjusted the database to use SQLX for async.
It was determined that Async database access would be prefereable incase we decide to use a network database instead of sqlite. Tests and examples need to be checked, but they were made to build.
This commit is contained in:
1573
Cargo.lock
generated
1573
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -13,9 +13,14 @@ license = "Apache-2.0"
|
||||
[dependencies]
|
||||
chrono = { version = "0.4.41", features = ["serde"] }
|
||||
pulldown-cmark = "0.13.0"
|
||||
rusqlite = "0.37.0"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
sqlx = { version = "0.8.6", features = ["sqlite", "chrono", "runtime-tokio"] }
|
||||
toml = "0.9.5"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
|
||||
|
||||
[features]
|
||||
publisher = []
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{NaiveDate, NaiveDateTime};
|
||||
|
||||
use tavern::{Adventurer, Database, Tale, Tavern};
|
||||
use chrono::NaiveDate;
|
||||
use tavern::{Adventurer, Database, FrontMatter, Tale, Tavern};
|
||||
|
||||
|
||||
|
||||
#[cfg(feature = "publisher")]
|
||||
fn generate_tavern() -> Tavern
|
||||
{
|
||||
let author: Adventurer =
|
||||
@ -17,14 +17,21 @@ fn generate_tavern() -> Tavern
|
||||
String::from("https://cybermages.tech/about/myrddin/pic"),
|
||||
blurb: String::from("I love code!") };
|
||||
|
||||
let tale: Tale =
|
||||
Tale { title: String::from("Test post"),
|
||||
slug: String::from("test_post"),
|
||||
author: author.handle.clone(),
|
||||
summary: String::from("The Moon is made of cheese!"),
|
||||
tags: vec![String::from("Space"), String::from("Cheese")],
|
||||
publish_date: NaiveDate::from_ymd_opt(2025, 12, 25).unwrap().and_hms_opt(13, 10, 41).unwrap(),
|
||||
content: PathBuf::from("posts/test_post.md") };
|
||||
let fm: FrontMatter =
|
||||
FrontMatter { title: String::from("Test post"),
|
||||
slug: String::from("test_post"),
|
||||
author: author.handle.clone(),
|
||||
summary: String::from("The Moon is made of cheese!"),
|
||||
tags: vec![String::from("Space"),
|
||||
String::from("Cheese")],
|
||||
publish_date:
|
||||
NaiveDate::from_ymd_opt(2025, 12, 25).unwrap()
|
||||
.and_hms_opt(13,
|
||||
10, 41)
|
||||
.unwrap() };
|
||||
|
||||
let tale: Tale = Tale { front_matter: fm,
|
||||
content: PathBuf::from("posts/test_post.md") };
|
||||
|
||||
|
||||
// Create a dummy posts directory and file for this example to work
|
||||
@ -33,16 +40,21 @@ fn generate_tavern() -> Tavern
|
||||
std::fs::create_dir("posts").unwrap();
|
||||
}
|
||||
std::fs::write("posts/the-rustacean.md",
|
||||
"# Hello, Rust!\n\nThis is a **test** post.").unwrap();
|
||||
"# Hello, Rust!\n\nThis is a **test** post.").unwrap();
|
||||
|
||||
|
||||
Tavern { title: String::from("Runes & Ramblings"),
|
||||
description: String::from("Join software engineer Jason Smith on his Rust programming journey. Explore program design, tech stacks, and more on this blog from CybeMages, LLC."),
|
||||
description: String::from("Join software engineer Jason Smith \
|
||||
on his Rust programming journey. \
|
||||
Explore program design, tech \
|
||||
stacks, and more on this blog from \
|
||||
CybeMages, LLC."),
|
||||
tales: vec![tale],
|
||||
authors: vec![author] }
|
||||
}
|
||||
|
||||
|
||||
#[cfg(feature = "publisher")]
|
||||
fn read_from_file<P>(config_file: P) -> Tavern
|
||||
where P: AsRef<Path>
|
||||
{
|
||||
@ -55,37 +67,40 @@ fn read_from_file<P>(config_file: P) -> Tavern
|
||||
}
|
||||
|
||||
|
||||
fn create_database() -> Result<(), Box<dyn std::error::Error>>
|
||||
#[cfg(feature = "publisher")]
|
||||
async fn create_database() -> Result<(), Box<dyn std::error::Error>>
|
||||
{
|
||||
// This part would be the entry point of your CI/CD script
|
||||
// It would load your data and then save it to the database
|
||||
|
||||
// Create a Tavern object
|
||||
let tavern = read_from_file("Tavern.toml");
|
||||
//let tavern = generate_tavern();
|
||||
let _tavern = read_from_file("Tavern.toml");
|
||||
// let tavern = generate_tavern();
|
||||
|
||||
// Open the database and save the Tavern content
|
||||
let db = Database::open(Path::new("tavern.db"))?;
|
||||
|
||||
db.insert_tavern(&tavern.title, &tavern.description)?;
|
||||
println!("Saved site settings: Title='{}', Description='{}'", tavern.title, tavern.description);
|
||||
|
||||
for author in &tavern.authors
|
||||
{
|
||||
db.insert_adventurer(author)?;
|
||||
println!("Saved adventurer: {}", author.name);
|
||||
}
|
||||
|
||||
for tale in &tavern.tales
|
||||
{
|
||||
db.insert_tale(tale)?;
|
||||
println!("Saved tale: {}", tale.title);
|
||||
}
|
||||
let _db = Database::open("/home/myrddin/cybermages/blog/tavern.db").await?;
|
||||
//db.insert_tavern(&tavern.title, &tavern.description)?;
|
||||
//println!("Saved site settings: Title='{}', Description='{}'",
|
||||
//tavern.title, tavern.description);
|
||||
|
||||
//for author in &tavern.authors
|
||||
// {
|
||||
// db.insert_adventurer(author)?;
|
||||
// println!("Saved adventurer: {}", author.name);
|
||||
// }
|
||||
//
|
||||
// for tale in &tavern.tales
|
||||
// {
|
||||
// db.insert_tale(tale)?;
|
||||
// println!("Saved tale: {}", tale.title);
|
||||
// }
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn main()
|
||||
#[cfg(feature = "publisher")]
|
||||
#[tokio::main]
|
||||
pub async fn main()
|
||||
{
|
||||
match std::env::set_current_dir("/home/myrddin/cybermages/blog/")
|
||||
{
|
||||
@ -99,9 +114,18 @@ pub fn main()
|
||||
}
|
||||
}
|
||||
|
||||
match create_database()
|
||||
match create_database().await
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(e) => { eprintln!("Error: {}", e); }
|
||||
Ok(_) =>
|
||||
{}
|
||||
Err(e) =>
|
||||
{
|
||||
eprintln!("Error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "publisher"))]
|
||||
pub fn main()
|
||||
{
|
||||
}
|
||||
|
||||
@ -25,42 +25,20 @@ impl Converter
|
||||
#[cfg(test)]
|
||||
mod tests
|
||||
{
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use super::*; // Import the parent module's items
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_markdown_conversion_with_files()
|
||||
fn test_markdown_conversion()
|
||||
{
|
||||
let test_dir = Path::new("temp_test_dir");
|
||||
let temp_md_path = test_dir.join("test.md");
|
||||
let temp_html_path = test_dir.join("test.html");
|
||||
|
||||
// Ensure the test directory is clean
|
||||
if test_dir.exists()
|
||||
{
|
||||
fs::remove_dir_all(&test_dir).unwrap();
|
||||
}
|
||||
fs::create_dir(&test_dir).unwrap();
|
||||
|
||||
// Create a temporary Markdown file for the test
|
||||
let markdown_content = "# Hello, World!\n\nThis is a **test**.";
|
||||
let expected_html =
|
||||
"<h1>Hello, World!</h1>\n<p>This is a <strong>test</strong>.</p>\n";
|
||||
|
||||
let mut temp_md_file = fs::File::create(&temp_md_path).unwrap();
|
||||
temp_md_file.write_all(markdown_content.as_bytes()).unwrap();
|
||||
|
||||
// Use the new Converter API
|
||||
Converter::md_to_html(&temp_md_path, &temp_html_path).unwrap();
|
||||
let converted_html = fs::read_to_string(&temp_html_path).unwrap();
|
||||
let converted_html = Converter::markdown_to_html(&markdown_content);
|
||||
|
||||
// Assert that the output matches the expected HTML
|
||||
assert_eq!(converted_html.trim(), expected_html.trim());
|
||||
|
||||
// Clean up the temporary directory after the test
|
||||
fs::remove_dir_all(&test_dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
526
src/database.rs
526
src/database.rs
@ -1,309 +1,379 @@
|
||||
// The connection strings are:
|
||||
// PostgreSQL - "postgres://root:root@localhost:5432"
|
||||
// SQLite - "sqlite::memory:"
|
||||
// const DATABASE_URL: &str = "sqlite::memory:";
|
||||
// const DB_NAME: &str = "cm_tavern_db";
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
|
||||
use sqlx::{Error, Result};
|
||||
|
||||
use rusqlite::{params, Connection, Result};
|
||||
#[cfg(not(feature = "publisher"))]
|
||||
use rusqlite::Statement;
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::adventurer::Adventurer;
|
||||
#[cfg(feature = "publisher")]
|
||||
use crate::converter::Converter;
|
||||
#[cfg(not(feature = "publisher"))]
|
||||
use crate::tale::FrontMatter;
|
||||
use crate::tale::Tale;
|
||||
|
||||
#[cfg(feature = "publisher")]
|
||||
use crate::converter::Converter;
|
||||
|
||||
#[cfg(not(feature = "publisher"))]
|
||||
use crate::tale::FrontMatter;
|
||||
|
||||
|
||||
|
||||
/// Represents the database connection pool.
|
||||
pub struct Database
|
||||
{
|
||||
connection: Connection
|
||||
pool: SqlitePool
|
||||
}
|
||||
|
||||
|
||||
|
||||
impl Database
|
||||
{
|
||||
/// Opens a connection to the SQLite database and creates the necessary
|
||||
/// Opens a connection to the SQLite database pool and creates the necessary
|
||||
/// tables.
|
||||
pub fn open(db_path: &Path) -> Result<Self>
|
||||
///
|
||||
/// db_path is an absolute path to the resource file.
|
||||
/// Example:
|
||||
/// ```text
|
||||
/// open("/var/website/tavern.db");
|
||||
/// ```
|
||||
pub async fn open<P>(db_path: P) -> Result<Self>
|
||||
where P: AsRef<Path>
|
||||
{
|
||||
let connection = Connection::open(db_path)?;
|
||||
let db_str =
|
||||
db_path.as_ref().to_str().ok_or_else(|| {
|
||||
Error::Configuration("Invalid UTF-8 in database \
|
||||
path"
|
||||
.into())
|
||||
})?;
|
||||
|
||||
let url: String = format!("sqlite:///{db_str}");
|
||||
|
||||
// Set up connection options with foreign keys enabled.
|
||||
#[cfg(feature = "publisher")]
|
||||
let connect_options =
|
||||
SqliteConnectOptions::from_str(&url)?.read_only(false)
|
||||
.foreign_keys(true)
|
||||
.create_if_missing(true);
|
||||
|
||||
#[cfg(not(feature = "publisher"))]
|
||||
let connect_options =
|
||||
SqliteConnectOptions::from_str(&url)?.read_only(true)
|
||||
.foreign_keys(true)
|
||||
.create_if_missing(false);
|
||||
|
||||
let pool = SqlitePoolOptions::new().connect_with(connect_options)
|
||||
.await?;
|
||||
|
||||
let database = Database { pool };
|
||||
|
||||
#[cfg(feature = "publisher")]
|
||||
Self::create_tables(&connection)?;
|
||||
database.create_tables().await?;
|
||||
|
||||
Ok(Database { connection })
|
||||
Ok(database)
|
||||
}
|
||||
|
||||
/// Creates the 'tales' and 'adventurers' tables if they don't exist.
|
||||
/// Creates the 'tales', 'adventurers', 'tags', 'tale_tags', and 'tavern'
|
||||
/// tables if they don't exist.
|
||||
#[cfg(feature = "publisher")]
|
||||
fn create_tables(conn: &Connection) -> Result<()>
|
||||
async fn create_tables(&self) -> Result<()>
|
||||
{
|
||||
conn.execute(
|
||||
sqlx::query!(
|
||||
"CREATE TABLE IF NOT EXISTS adventurers (
|
||||
handle TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
profile TEXT,
|
||||
image TEXT,
|
||||
blurb TEXT
|
||||
)",
|
||||
[]
|
||||
)?;
|
||||
conn.execute(
|
||||
profile TEXT NOT NULL,
|
||||
image TEXT NOT NULL,
|
||||
blurb TEXT NOT NULL
|
||||
)"
|
||||
).execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"CREATE TABLE IF NOT EXISTS tales (
|
||||
slug TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
author TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
summary TEXT NOT NULL,
|
||||
publish_date TEXT NOT NULL,
|
||||
content TEXT
|
||||
)",
|
||||
[]
|
||||
)?;
|
||||
conn.execute(
|
||||
content TEXT NOT NULL
|
||||
)"
|
||||
).execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
)",
|
||||
[]
|
||||
)?;
|
||||
conn.execute(
|
||||
)"
|
||||
).execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"CREATE TABLE IF NOT EXISTS tale_tags (
|
||||
tale_slug TEXT,
|
||||
tag_id INTEGER,
|
||||
FOREIGN KEY(tale_slug) REFERENCES tales(slug),
|
||||
FOREIGN KEY(tag_id) REFERENCES tags(id),
|
||||
UNIQUE(tale_slug, tag_id)
|
||||
)",
|
||||
[]
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS tavern (
|
||||
)"
|
||||
).execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"CREATE TABLE IF NOT EXISTS tavern (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
)"
|
||||
).execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Inserts a single tale into the database.
|
||||
#[cfg(feature = "publisher")]
|
||||
pub fn insert_tale(&self, tale: &Tale) -> Result<()>
|
||||
pub async fn insert_tale(&self, tale: &Tale)
|
||||
-> Result<(), Box<dyn std::error::Error>>
|
||||
{
|
||||
// Read the markdown content from the file
|
||||
let markdown_content = std::fs::read_to_string(&tale.content)
|
||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(e.into()))?;
|
||||
// Convert the tales content from Markdown to HTML.
|
||||
let markdown_content = std::fs::read_to_string(&tale.content)?;
|
||||
let html_content: String = Converter::markdown_to_html(&markdown_content);
|
||||
|
||||
// Convert the markdown to HTML
|
||||
let html_content = Converter::markdown_to_html(&markdown_content);
|
||||
// Start a transaction.
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
self.connection.execute(
|
||||
"INSERT OR REPLACE INTO tales (
|
||||
slug, title, author, summary, publish_date, content
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![
|
||||
tale.front_matter.slug,
|
||||
tale.front_matter.title,
|
||||
tale.front_matter.author,
|
||||
tale.front_matter.summary,
|
||||
tale.front_matter.publish_date.to_string(),
|
||||
html_content
|
||||
]
|
||||
)?;
|
||||
// Store the tale.
|
||||
sqlx::query!(
|
||||
"INSERT OR REPLACE INTO tales (
|
||||
slug, title, author, summary, publish_date, content
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
tale.front_matter.slug,
|
||||
tale.front_matter.title,
|
||||
tale.front_matter.author,
|
||||
tale.front_matter.summary,
|
||||
tale.front_matter.publish_date,
|
||||
html_content
|
||||
).execute(&mut *tx) // Pass mutable reference to the transaction
|
||||
.await?;
|
||||
|
||||
// Insert tags and link them to the tale
|
||||
// Store the tags.
|
||||
// For each tag ...
|
||||
for tag_name in &tale.front_matter.tags
|
||||
{
|
||||
let tag_id = self.insert_and_get_tag_id(tag_name)?;
|
||||
self.connection.execute("INSERT OR IGNORE INTO tale_tags \
|
||||
(tale_slug, tag_id) VALUES (?1, ?2)",
|
||||
params![tale.front_matter.slug, tag_id])?;
|
||||
// Insert a new tag, ignore if it already exists.
|
||||
sqlx::query!("INSERT OR IGNORE INTO tags (name) VALUES (?1)",
|
||||
tag_name).execute(&mut *tx) // Pass mutable reference to the transaction
|
||||
.await?;
|
||||
|
||||
// Get the tag_id for the newly inserted or existing tag.
|
||||
let id: i64 = sqlx::query!("SELECT id FROM tags WHERE name = ?1",
|
||||
tag_name).fetch_one(&mut *tx) // Pass mutable reference to the transaction
|
||||
.await?
|
||||
.id
|
||||
.unwrap_or(0); // Use unwrap_or to handle the Option<i64>
|
||||
|
||||
// Insert the tale_tag relationship.
|
||||
sqlx::query!(
|
||||
"INSERT OR IGNORE INTO tale_tags (tale_slug, tag_id)
|
||||
VALUES (?1, ?2)",
|
||||
tale.front_matter.slug,
|
||||
id
|
||||
).execute(&mut *tx) // Pass mutable reference to the transaction
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Commit the transaction.
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Inserts a tag and returns its ID. If the tag already exists, it returns
|
||||
/// the existing ID.
|
||||
#[cfg(feature = "publisher")]
|
||||
fn insert_and_get_tag_id(&self, tag_name: &str) -> Result<i64>
|
||||
{
|
||||
self.connection
|
||||
.execute("INSERT OR IGNORE INTO tags (name) VALUES (?1)",
|
||||
params![tag_name])?;
|
||||
let id: i64 = self.connection
|
||||
.query_row("SELECT id FROM tags WHERE name = ?1",
|
||||
params![tag_name],
|
||||
|row| row.get(0))?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Inserts a single adventurer into the database.
|
||||
#[cfg(feature = "publisher")]
|
||||
pub fn insert_adventurer(&self, adventurer: &Adventurer) -> Result<()>
|
||||
pub async fn insert_adventurer(&self, adventurer: &Adventurer)
|
||||
-> Result<()>
|
||||
{
|
||||
self.connection.execute(
|
||||
"INSERT OR REPLACE INTO adventurers (
|
||||
handle, name, profile, image, blurb
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
adventurer.handle,
|
||||
adventurer.name,
|
||||
adventurer.profile,
|
||||
adventurer.image,
|
||||
adventurer.blurb
|
||||
]
|
||||
)?;
|
||||
// Start a transaction.
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT OR REPLACE INTO adventurers (
|
||||
handle, name, profile, image, blurb
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
adventurer.handle,
|
||||
adventurer.name,
|
||||
adventurer.profile,
|
||||
adventurer.image,
|
||||
adventurer.blurb
|
||||
).execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Commit the transaction.
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Inserts the site-wide settings like title and description.
|
||||
#[cfg(feature = "publisher")]
|
||||
pub fn insert_tavern(&self, title: &str, description: &str) -> Result<()> {
|
||||
self.connection.execute_batch(&format!(
|
||||
"BEGIN;
|
||||
INSERT OR REPLACE INTO tavern (key, value) VALUES ('title', '{}');
|
||||
INSERT OR REPLACE INTO tavern (key, value) VALUES ('description', '{}');
|
||||
COMMIT;",
|
||||
title, description
|
||||
))?;
|
||||
Ok(())
|
||||
}
|
||||
pub async fn insert_tavern(&self, title: &str, description: &str)
|
||||
-> Result<()>
|
||||
{
|
||||
// Start a transaction.
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
/// Fetches a vector of lightweight FrontMatter objects from the database.
|
||||
#[cfg(not(feature = "publisher"))]
|
||||
pub fn get_tales_summary(&self, categories: &[String]) -> Result<Vec<FrontMatter>> {
|
||||
let mut query = String::from(
|
||||
"SELECT
|
||||
t.title,
|
||||
t.slug,
|
||||
t.summary,
|
||||
t.author,
|
||||
t.publish_date,
|
||||
GROUP_CONCAT(tg.name, ',') AS tags
|
||||
FROM tales AS t
|
||||
JOIN tale_tags AS tt ON t.slug = tt.tale_slug
|
||||
JOIN tags AS tg ON tt.tag_id = tg.id"
|
||||
);
|
||||
|
||||
// If categories are provided, filter the results
|
||||
if !categories.is_empty() {
|
||||
query.push_str(" WHERE tg.name IN (");
|
||||
// Add a placeholder for each category
|
||||
let placeholders: Vec<_> = (0..categories.len()).map(|_| "?").collect();
|
||||
query.push_str(&placeholders.join(", "));
|
||||
query.push_str(")");
|
||||
}
|
||||
// Insert or replace the title.
|
||||
sqlx::query!("INSERT OR REPLACE INTO tavern (key, value) VALUES \
|
||||
('title', ?1)",
|
||||
title).execute(&mut *tx) // Pass mutable reference to the transaction
|
||||
.await?;
|
||||
|
||||
// Group by tale to get all tags for each tale
|
||||
query.push_str(" GROUP BY t.slug");
|
||||
|
||||
query.push_str(" ORDER BY t.publish_date DESC");
|
||||
// Insert or replace the description.
|
||||
sqlx::query!("INSERT OR REPLACE INTO tavern (key, value) VALUES \
|
||||
('description', ?1)",
|
||||
description).execute(&mut *tx) // Pass mutable reference to the transaction
|
||||
.await?;
|
||||
|
||||
let mut stmt: Statement = self.connection.prepare(&query)?;
|
||||
// Commit the transaction.
|
||||
tx.commit().await?;
|
||||
|
||||
let tales_iter = stmt.query_map(rusqlite::params_from_iter(categories), |row| {
|
||||
let date_str: String = row.get(4)?;
|
||||
let tags_str: String = row.get(5)?;
|
||||
let tags: Vec<String> = tags_str.split(',').map(|s| s.to_string()).collect();
|
||||
Ok(FrontMatter {
|
||||
title: row.get(0)?,
|
||||
slug: row.get(1)?,
|
||||
summary: row.get(2)?,
|
||||
author: row.get(3)?,
|
||||
publish_date: chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%dT%H:%M:%S").map_err(|e| rusqlite::Error::ToSqlConversionFailure(e.into()))?,
|
||||
tags,
|
||||
})
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let summaries: Result<Vec<_>, _> = tales_iter.collect();
|
||||
summaries
|
||||
}
|
||||
#[cfg(not(feature = "publisher"))]
|
||||
pub async fn get_tales_summary(&self, categories: &[String])
|
||||
-> Result<Vec<FrontMatter>>
|
||||
{
|
||||
let mut tales = Vec::new();
|
||||
|
||||
/// Fetches a single tale from the database by its slug.
|
||||
/// This function is only available in the 'reader' build.
|
||||
#[cfg(not(feature = "publisher"))]
|
||||
pub fn get_tale_by_slug(&self, slug: &str) -> Result<Option<Tale>> {
|
||||
let query = String::from(
|
||||
"SELECT
|
||||
t.title,
|
||||
t.slug,
|
||||
t.summary,
|
||||
t.author,
|
||||
t.publish_date,
|
||||
t.content,
|
||||
GROUP_CONCAT(tg.name, ',') AS tags
|
||||
FROM tales AS t
|
||||
LEFT JOIN tale_tags AS tt ON t.slug = tt.tale_slug
|
||||
LEFT JOIN tags AS tg ON tt.tag_id = tg.id
|
||||
WHERE t.slug = ?1
|
||||
GROUP BY t.slug"
|
||||
);
|
||||
// Start a read-only transaction.
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
// Prepare the statement and execute a query for a single row.
|
||||
let result = self.connection.query_row(
|
||||
&query,
|
||||
params![slug],
|
||||
|row| {
|
||||
// Parse the tags string into a vector of strings.
|
||||
let tags_str: Option<String> = row.get(6)?;
|
||||
let tags = tags_str.map(|s| s.split(',').map(|tag| tag.to_string()).collect()).unwrap_or_default();
|
||||
// Dynamically build the query.
|
||||
let mut query = String::from(
|
||||
"SELECT
|
||||
t.title,
|
||||
t.slug,
|
||||
t.summary,
|
||||
t.author,
|
||||
t.publish_date,
|
||||
GROUP_CONCAT(tg.name, ',') AS tags
|
||||
FROM tales AS t
|
||||
LEFT JOIN tale_tags AS tt ON t.slug = tt.tale_slug
|
||||
LEFT JOIN tags AS tg ON tt.tag_id = tg.id"
|
||||
);
|
||||
|
||||
// Parse the date string into a NaiveDateTime.
|
||||
let date_str: String = row.get(4)?;
|
||||
let publish_date = chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%dT%H:%M:%S")
|
||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(e.into()))?;
|
||||
if !categories.is_empty()
|
||||
{
|
||||
query.push_str(" WHERE tg.name IN (");
|
||||
let placeholders: Vec<_> =
|
||||
(0..categories.len()).map(|_| "?").collect();
|
||||
query.push_str(&placeholders.join(", "));
|
||||
query.push(')');
|
||||
}
|
||||
|
||||
// Construct the FrontMatter and the full Tale struct.
|
||||
Ok(Tale {
|
||||
front_matter: FrontMatter {
|
||||
title: row.get(0)?,
|
||||
slug: row.get(1)?,
|
||||
summary: row.get(2)?,
|
||||
author: row.get(3)?,
|
||||
publish_date,
|
||||
tags,
|
||||
},
|
||||
content: row.get(5)?,
|
||||
})
|
||||
}
|
||||
);
|
||||
query.push_str(" GROUP BY t.slug ORDER BY t.publish_date DESC");
|
||||
|
||||
// Handle the result: if no rows were returned, return `None`.
|
||||
match result {
|
||||
Ok(tale) => Ok(Some(tale)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
let mut q = sqlx::query(&query);
|
||||
for cat in categories
|
||||
{
|
||||
q = q.bind(cat);
|
||||
}
|
||||
|
||||
/// Fetches a single adventurer from the database by their handle.
|
||||
/// This function is only available in the 'reader' build.
|
||||
#[cfg(not(feature = "publisher"))]
|
||||
pub fn get_adventurer(&self, handle: &str) -> Result<Option<Adventurer>> {
|
||||
let result = self.connection.query_row(
|
||||
"SELECT handle, name, profile, image, blurb FROM adventurers WHERE handle = ?1",
|
||||
params![handle],
|
||||
|row| {
|
||||
Ok(Adventurer {
|
||||
handle: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
profile: row.get(2)?,
|
||||
image: row.get(3)?,
|
||||
blurb: row.get(4)?,
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(adventurer) => Ok(Some(adventurer)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
let rows = q.fetch_all(&mut *tx).await?;
|
||||
|
||||
for row in rows
|
||||
{
|
||||
let tags_str: Option<String> = row.try_get("tags")?;
|
||||
let tags = tags_str.map(|s| s.split(',').map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let date_str: String = row.try_get("publish_date")?;
|
||||
let publish_date = chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%dT%H:%M:%S")
|
||||
.map_err(|e| sqlx::Error::Decode(e.into()))?;
|
||||
|
||||
tales.push(FrontMatter { title: row.try_get("title")?,
|
||||
slug: row.try_get("slug")?,
|
||||
summary: row.try_get("summary")?,
|
||||
author: row.try_get("author")?,
|
||||
publish_date,
|
||||
tags });
|
||||
}
|
||||
|
||||
tx.commit().await?; // Explicit commit, even for read transactions.
|
||||
|
||||
Ok(tales)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "publisher"))]
|
||||
pub async fn get_tale_by_slug(&self, slug: &str) -> Result<Option<Tale>>
|
||||
{
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let tale_row = sqlx::query(
|
||||
"SELECT
|
||||
t.title, t.slug, t.summary, t.author, t.publish_date,
|
||||
t.content,
|
||||
GROUP_CONCAT(tg.name, ',') AS tags
|
||||
FROM tales AS t
|
||||
LEFT JOIN tale_tags AS tt ON t.slug = tt.tale_slug
|
||||
LEFT JOIN tags AS tg ON tt.tag_id = tg.id
|
||||
WHERE t.slug = ?1
|
||||
GROUP BY t.slug"
|
||||
).bind(slug)
|
||||
.fetch_optional(&mut *tx) // Use transaction here
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
if let Some(row) = tale_row
|
||||
{
|
||||
let tags_str: Option<String> = row.try_get("tags")?;
|
||||
let tags = tags_str.map(|s| s.split(',').map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let date_str: String = row.try_get("publish_date")?;
|
||||
let publish_date = chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%dT%H:%M:%S")
|
||||
.map_err(|e| Error::Decode(e.into()))?;
|
||||
|
||||
let front_matter = FrontMatter { title: row.try_get("title")?,
|
||||
slug: row.try_get("slug")?,
|
||||
summary: row.try_get("summary")?,
|
||||
author: row.try_get("author")?,
|
||||
publish_date,
|
||||
tags };
|
||||
|
||||
Ok(Some(Tale { front_matter,
|
||||
content: row.try_get("content")? }))
|
||||
}
|
||||
else
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "publisher"))]
|
||||
pub async fn get_adventurer(&self, handle: &str)
|
||||
-> Result<Option<Adventurer>>
|
||||
{
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let adventurer = sqlx::query_as!(
|
||||
Adventurer,
|
||||
"SELECT
|
||||
handle AS 'handle!',
|
||||
name AS 'name!',
|
||||
profile AS 'profile!',
|
||||
image AS 'image!',
|
||||
blurb AS 'blurb!'
|
||||
FROM adventurers
|
||||
WHERE handle = ?1",
|
||||
handle
|
||||
).fetch_optional(&mut *tx) // Use transaction here
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(adventurer)
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,5 +15,5 @@ mod tavern;
|
||||
pub use crate::adventurer::Adventurer;
|
||||
pub use crate::database::Database;
|
||||
pub use crate::info::{get_name, get_version};
|
||||
pub use crate::tale::Tale;
|
||||
pub use crate::tale::{FrontMatter, Tale};
|
||||
pub use crate::tavern::Tavern;
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
use chrono::{NaiveDate, NaiveDateTime};
|
||||
#![cfg(feature = "publisher")]
|
||||
|
||||
use tavern::{Adventurer, Tale, Tavern};
|
||||
use chrono::NaiveDate;
|
||||
use tavern::{Adventurer, FrontMatter, Tale, Tavern};
|
||||
|
||||
|
||||
|
||||
@ -15,17 +16,29 @@ fn generate_tavern() -> Tavern
|
||||
String::from("https://cybermages.tech/about/myrddin/pic"),
|
||||
blurb: String::from("I love code!") };
|
||||
|
||||
let tale: Tale =
|
||||
Tale { title: String::from("Test post"),
|
||||
|
||||
let fm: FrontMatter = FrontMatter {
|
||||
title: String::from("Test post"),
|
||||
slug: String::from("test_post"),
|
||||
author: author.handle.clone(),
|
||||
summary: String::from("The Moon is made of cheese!"),
|
||||
tags: vec![String::from("Space"), String::from("Cheese")],
|
||||
publish_date: NaiveDate::from_ymd_opt(2025, 12, 25).unwrap().and_hms_opt(13, 10, 41).unwrap(),
|
||||
publish_date:
|
||||
NaiveDate::from_ymd_opt(2025, 12, 25).unwrap()
|
||||
.and_hms_opt(13, 10, 41)
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let tale: Tale = Tale {
|
||||
front_matter: fm,
|
||||
content: std::path::PathBuf::from("posts/test_post.md") };
|
||||
|
||||
Tavern { title: String::from("Runes & Ramblings"),
|
||||
description: String::from("Join software engineer Jason Smith on his Rust programming journey. Explore program design, tech stacks, and more on this blog from CybeMages, LLC."),
|
||||
description: String::from("Join software engineer Jason Smith \
|
||||
on his Rust programming journey. \
|
||||
Explore program design, tech \
|
||||
stacks, and more on this blog from \
|
||||
CybeMages, LLC."),
|
||||
tales: vec![tale],
|
||||
authors: vec![author] }
|
||||
}
|
||||
@ -81,9 +94,9 @@ fn from_file()
|
||||
|
||||
// Assert some known values to make this a real test
|
||||
let tale = &tavern.tales[0];
|
||||
assert_eq!(tale.title, "Test post");
|
||||
assert_eq!(tale.slug, "test_post");
|
||||
assert_eq!(tale.author, "myrddin");
|
||||
assert_eq!(tale.front_matter.title, "Test post");
|
||||
assert_eq!(tale.front_matter.slug, "test_post");
|
||||
assert_eq!(tale.front_matter.author, "myrddin");
|
||||
|
||||
cleanup_temp_file(&path);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user