[#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:
2025-08-29 18:11:17 -04:00
parent c9c03f9059
commit 0a16667b76
8 changed files with 1946 additions and 320 deletions

1573
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -13,9 +13,14 @@ license = "Apache-2.0"
[dependencies] [dependencies]
chrono = { version = "0.4.41", features = ["serde"] } chrono = { version = "0.4.41", features = ["serde"] }
pulldown-cmark = "0.13.0" pulldown-cmark = "0.13.0"
rusqlite = "0.37.0"
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
sqlx = { version = "0.8.6", features = ["sqlite", "chrono", "runtime-tokio"] }
toml = "0.9.5" toml = "0.9.5"
[dev-dependencies]
tokio = { version = "1", features = ["full"] }
[features] [features]
publisher = [] publisher = []

View File

@ -1,11 +1,11 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use chrono::{NaiveDate, NaiveDateTime}; use chrono::NaiveDate;
use tavern::{Adventurer, Database, FrontMatter, Tale, Tavern};
use tavern::{Adventurer, Database, Tale, Tavern};
#[cfg(feature = "publisher")]
fn generate_tavern() -> Tavern fn generate_tavern() -> Tavern
{ {
let author: Adventurer = let author: Adventurer =
@ -17,14 +17,21 @@ fn generate_tavern() -> Tavern
String::from("https://cybermages.tech/about/myrddin/pic"), String::from("https://cybermages.tech/about/myrddin/pic"),
blurb: String::from("I love code!") }; blurb: String::from("I love code!") };
let tale: Tale = let fm: FrontMatter =
Tale { title: String::from("Test post"), FrontMatter { title: String::from("Test post"),
slug: String::from("test_post"), slug: String::from("test_post"),
author: author.handle.clone(), author: author.handle.clone(),
summary: String::from("The Moon is made of cheese!"), summary: String::from("The Moon is made of cheese!"),
tags: vec![String::from("Space"), String::from("Cheese")], tags: vec![String::from("Space"),
publish_date: NaiveDate::from_ymd_opt(2025, 12, 25).unwrap().and_hms_opt(13, 10, 41).unwrap(), String::from("Cheese")],
content: PathBuf::from("posts/test_post.md") }; 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 // 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::create_dir("posts").unwrap();
} }
std::fs::write("posts/the-rustacean.md", 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"), 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], tales: vec![tale],
authors: vec![author] } authors: vec![author] }
} }
#[cfg(feature = "publisher")]
fn read_from_file<P>(config_file: P) -> Tavern fn read_from_file<P>(config_file: P) -> Tavern
where P: AsRef<Path> 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 // This part would be the entry point of your CI/CD script
// It would load your data and then save it to the database // It would load your data and then save it to the database
// Create a Tavern object // Create a Tavern object
let tavern = read_from_file("Tavern.toml"); let _tavern = read_from_file("Tavern.toml");
//let tavern = generate_tavern(); // let tavern = generate_tavern();
// Open the database and save the Tavern content // Open the database and save the Tavern content
let db = Database::open(Path::new("tavern.db"))?; let _db = Database::open("/home/myrddin/cybermages/blog/tavern.db").await?;
//db.insert_tavern(&tavern.title, &tavern.description)?;
db.insert_tavern(&tavern.title, &tavern.description)?; //println!("Saved site settings: Title='{}', Description='{}'",
println!("Saved site settings: Title='{}', Description='{}'", tavern.title, tavern.description); //tavern.title, tavern.description);
for author in &tavern.authors //for author in &tavern.authors
{ // {
db.insert_adventurer(author)?; // db.insert_adventurer(author)?;
println!("Saved adventurer: {}", author.name); // println!("Saved adventurer: {}", author.name);
} // }
//
for tale in &tavern.tales // for tale in &tavern.tales
{ // {
db.insert_tale(tale)?; // db.insert_tale(tale)?;
println!("Saved tale: {}", tale.title); // println!("Saved tale: {}", tale.title);
} // }
Ok(()) Ok(())
} }
pub fn main() #[cfg(feature = "publisher")]
#[tokio::main]
pub async fn main()
{ {
match std::env::set_current_dir("/home/myrddin/cybermages/blog/") 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(_) => {} Ok(_) =>
Err(e) => { eprintln!("Error: {}", e); } {}
Err(e) =>
{
eprintln!("Error: {}", e);
}
} }
} }
#[cfg(not(feature = "publisher"))]
pub fn main()
{
}

View File

@ -25,42 +25,20 @@ impl Converter
#[cfg(test)] #[cfg(test)]
mod tests mod tests
{ {
use std::fs; use super::*;
use std::io::Write;
use std::path::Path;
use super::*; // Import the parent module's items
#[test] #[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 // Create a temporary Markdown file for the test
let markdown_content = "# Hello, World!\n\nThis is a **test**."; let markdown_content = "# Hello, World!\n\nThis is a **test**.";
let expected_html = let expected_html =
"<h1>Hello, World!</h1>\n<p>This is a <strong>test</strong>.</p>\n"; "<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 // Use the new Converter API
Converter::md_to_html(&temp_md_path, &temp_html_path).unwrap(); let converted_html = Converter::markdown_to_html(&markdown_content);
let converted_html = fs::read_to_string(&temp_html_path).unwrap();
// Assert that the output matches the expected HTML // Assert that the output matches the expected HTML
assert_eq!(converted_html.trim(), expected_html.trim()); assert_eq!(converted_html.trim(), expected_html.trim());
// Clean up the temporary directory after the test
fs::remove_dir_all(&test_dir).unwrap();
} }
} }

View File

@ -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::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"))] #[cfg(not(feature = "publisher"))]
use rusqlite::Statement; use sqlx::Row;
use crate::adventurer::Adventurer; use crate::adventurer::Adventurer;
#[cfg(feature = "publisher")]
use crate::converter::Converter;
#[cfg(not(feature = "publisher"))]
use crate::tale::FrontMatter;
use crate::tale::Tale; 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 pub struct Database
{ {
connection: Connection pool: SqlitePool
} }
impl Database 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. /// 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")] #[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")] #[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 ( "CREATE TABLE IF NOT EXISTS adventurers (
handle TEXT PRIMARY KEY, handle TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
profile TEXT, profile TEXT NOT NULL,
image TEXT, image TEXT NOT NULL,
blurb TEXT blurb TEXT NOT NULL
)", )"
[] ).execute(&self.pool)
)?; .await?;
conn.execute(
sqlx::query!(
"CREATE TABLE IF NOT EXISTS tales ( "CREATE TABLE IF NOT EXISTS tales (
slug TEXT PRIMARY KEY, slug TEXT PRIMARY KEY,
title TEXT NOT NULL, title TEXT NOT NULL,
author TEXT NOT NULL, author TEXT NOT NULL,
summary TEXT, summary TEXT NOT NULL,
publish_date TEXT NOT NULL, publish_date TEXT NOT NULL,
content TEXT content TEXT NOT NULL
)", )"
[] ).execute(&self.pool)
)?; .await?;
conn.execute(
sqlx::query!(
"CREATE TABLE IF NOT EXISTS tags ( "CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE name TEXT NOT NULL UNIQUE
)", )"
[] ).execute(&self.pool)
)?; .await?;
conn.execute(
sqlx::query!(
"CREATE TABLE IF NOT EXISTS tale_tags ( "CREATE TABLE IF NOT EXISTS tale_tags (
tale_slug TEXT, tale_slug TEXT,
tag_id INTEGER, tag_id INTEGER,
FOREIGN KEY(tale_slug) REFERENCES tales(slug), FOREIGN KEY(tale_slug) REFERENCES tales(slug),
FOREIGN KEY(tag_id) REFERENCES tags(id), FOREIGN KEY(tag_id) REFERENCES tags(id),
UNIQUE(tale_slug, tag_id) UNIQUE(tale_slug, tag_id)
)", )"
[] ).execute(&self.pool)
)?; .await?;
conn.execute(
"CREATE TABLE IF NOT EXISTS tavern ( sqlx::query!(
"CREATE TABLE IF NOT EXISTS tavern (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL value TEXT NOT NULL
)", )"
[], ).execute(&self.pool)
)?; .await?;
Ok(()) Ok(())
} }
/// Inserts a single tale into the database. /// Inserts a single tale into the database.
#[cfg(feature = "publisher")] #[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 // Convert the tales content from Markdown to HTML.
let markdown_content = std::fs::read_to_string(&tale.content) let markdown_content = std::fs::read_to_string(&tale.content)?;
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(e.into()))?; let html_content: String = Converter::markdown_to_html(&markdown_content);
// Convert the markdown to HTML // Start a transaction.
let html_content = Converter::markdown_to_html(&markdown_content); let mut tx = self.pool.begin().await?;
self.connection.execute( // Store the tale.
"INSERT OR REPLACE INTO tales ( sqlx::query!(
slug, title, author, summary, publish_date, content "INSERT OR REPLACE INTO tales (
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", slug, title, author, summary, publish_date, content
params![ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
tale.front_matter.slug, tale.front_matter.slug,
tale.front_matter.title, tale.front_matter.title,
tale.front_matter.author, tale.front_matter.author,
tale.front_matter.summary, tale.front_matter.summary,
tale.front_matter.publish_date.to_string(), tale.front_matter.publish_date,
html_content 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 for tag_name in &tale.front_matter.tags
{ {
let tag_id = self.insert_and_get_tag_id(tag_name)?; // Insert a new tag, ignore if it already exists.
self.connection.execute("INSERT OR IGNORE INTO tale_tags \ sqlx::query!("INSERT OR IGNORE INTO tags (name) VALUES (?1)",
(tale_slug, tag_id) VALUES (?1, ?2)", tag_name).execute(&mut *tx) // Pass mutable reference to the transaction
params![tale.front_matter.slug, tag_id])?; .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(()) 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. /// Inserts a single adventurer into the database.
#[cfg(feature = "publisher")] #[cfg(feature = "publisher")]
pub fn insert_adventurer(&self, adventurer: &Adventurer) -> Result<()> pub async fn insert_adventurer(&self, adventurer: &Adventurer)
-> Result<()>
{ {
self.connection.execute( // Start a transaction.
"INSERT OR REPLACE INTO adventurers ( let mut tx = self.pool.begin().await?;
handle, name, profile, image, blurb
) VALUES (?1, ?2, ?3, ?4, ?5)", sqlx::query!(
params![ "INSERT OR REPLACE INTO adventurers (
adventurer.handle, handle, name, profile, image, blurb
adventurer.name, ) VALUES (?1, ?2, ?3, ?4, ?5)",
adventurer.profile, adventurer.handle,
adventurer.image, adventurer.name,
adventurer.blurb adventurer.profile,
] adventurer.image,
)?; adventurer.blurb
).execute(&mut *tx)
.await?;
// Commit the transaction.
tx.commit().await?;
Ok(()) Ok(())
} }
/// Inserts the site-wide settings like title and description. /// Inserts the site-wide settings like title and description.
#[cfg(feature = "publisher")] #[cfg(feature = "publisher")]
pub fn insert_tavern(&self, title: &str, description: &str) -> Result<()> { pub async fn insert_tavern(&self, title: &str, description: &str)
self.connection.execute_batch(&format!( -> Result<()>
"BEGIN; {
INSERT OR REPLACE INTO tavern (key, value) VALUES ('title', '{}'); // Start a transaction.
INSERT OR REPLACE INTO tavern (key, value) VALUES ('description', '{}'); let mut tx = self.pool.begin().await?;
COMMIT;",
title, description
))?;
Ok(())
}
/// Fetches a vector of lightweight FrontMatter objects from the database. // Insert or replace the title.
#[cfg(not(feature = "publisher"))] sqlx::query!("INSERT OR REPLACE INTO tavern (key, value) VALUES \
pub fn get_tales_summary(&self, categories: &[String]) -> Result<Vec<FrontMatter>> { ('title', ?1)",
let mut query = String::from( title).execute(&mut *tx) // Pass mutable reference to the transaction
"SELECT .await?;
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(")");
}
// Group by tale to get all tags for each tale // Insert or replace the description.
query.push_str(" GROUP BY t.slug"); sqlx::query!("INSERT OR REPLACE INTO tavern (key, value) VALUES \
('description', ?1)",
query.push_str(" ORDER BY t.publish_date DESC"); 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| { Ok(())
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,
})
})?;
let summaries: Result<Vec<_>, _> = tales_iter.collect(); #[cfg(not(feature = "publisher"))]
summaries 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. // Start a read-only transaction.
/// This function is only available in the 'reader' build. let mut tx = self.pool.begin().await?;
#[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"
);
// Prepare the statement and execute a query for a single row. // Dynamically build the query.
let result = self.connection.query_row( let mut query = String::from(
&query, "SELECT
params![slug], t.title,
|row| { t.slug,
// Parse the tags string into a vector of strings. t.summary,
let tags_str: Option<String> = row.get(6)?; t.author,
let tags = tags_str.map(|s| s.split(',').map(|tag| tag.to_string()).collect()).unwrap_or_default(); 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. if !categories.is_empty()
let date_str: String = row.get(4)?; {
let publish_date = chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%dT%H:%M:%S") query.push_str(" WHERE tg.name IN (");
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(e.into()))?; let placeholders: Vec<_> =
(0..categories.len()).map(|_| "?").collect();
query.push_str(&placeholders.join(", "));
query.push(')');
}
// Construct the FrontMatter and the full Tale struct. query.push_str(" GROUP BY t.slug ORDER BY t.publish_date DESC");
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)?,
})
}
);
// Handle the result: if no rows were returned, return `None`. let mut q = sqlx::query(&query);
match result { for cat in categories
Ok(tale) => Ok(Some(tale)), {
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), q = q.bind(cat);
Err(e) => Err(e), }
}
}
/// Fetches a single adventurer from the database by their handle. let rows = q.fetch_all(&mut *tx).await?;
/// This function is only available in the 'reader' build.
#[cfg(not(feature = "publisher"))] for row in rows
pub fn get_adventurer(&self, handle: &str) -> Result<Option<Adventurer>> { {
let result = self.connection.query_row( let tags_str: Option<String> = row.try_get("tags")?;
"SELECT handle, name, profile, image, blurb FROM adventurers WHERE handle = ?1", let tags = tags_str.map(|s| s.split(',').map(String::from).collect())
params![handle], .unwrap_or_default();
|row| {
Ok(Adventurer { let date_str: String = row.try_get("publish_date")?;
handle: row.get(0)?, let publish_date = chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%dT%H:%M:%S")
name: row.get(1)?, .map_err(|e| sqlx::Error::Decode(e.into()))?;
profile: row.get(2)?,
image: row.get(3)?, tales.push(FrontMatter { title: row.try_get("title")?,
blurb: row.get(4)?, slug: row.try_get("slug")?,
}) summary: row.try_get("summary")?,
} author: row.try_get("author")?,
); publish_date,
tags });
match result { }
Ok(adventurer) => Ok(Some(adventurer)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), tx.commit().await?; // Explicit commit, even for read transactions.
Err(e) => Err(e),
} 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)
}
} }

View File

@ -15,5 +15,5 @@ mod tavern;
pub use crate::adventurer::Adventurer; pub use crate::adventurer::Adventurer;
pub use crate::database::Database; pub use crate::database::Database;
pub use crate::info::{get_name, get_version}; pub use crate::info::{get_name, get_version};
pub use crate::tale::Tale; pub use crate::tale::{FrontMatter, Tale};
pub use crate::tavern::Tavern; pub use crate::tavern::Tavern;

View File

@ -1,6 +1,5 @@
use serde::{Deserialize, Serialize};
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};

View File

@ -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"), String::from("https://cybermages.tech/about/myrddin/pic"),
blurb: String::from("I love code!") }; 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"), slug: String::from("test_post"),
author: author.handle.clone(), author: author.handle.clone(),
summary: String::from("The Moon is made of cheese!"), summary: String::from("The Moon is made of cheese!"),
tags: vec![String::from("Space"), String::from("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") }; content: std::path::PathBuf::from("posts/test_post.md") };
Tavern { title: String::from("Runes & Ramblings"), 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], tales: vec![tale],
authors: vec![author] } authors: vec![author] }
} }
@ -81,9 +94,9 @@ fn from_file()
// Assert some known values to make this a real test // Assert some known values to make this a real test
let tale = &tavern.tales[0]; let tale = &tavern.tales[0];
assert_eq!(tale.title, "Test post"); assert_eq!(tale.front_matter.title, "Test post");
assert_eq!(tale.slug, "test_post"); assert_eq!(tale.front_matter.slug, "test_post");
assert_eq!(tale.author, "myrddin"); assert_eq!(tale.front_matter.author, "myrddin");
cleanup_temp_file(&path); cleanup_temp_file(&path);
} }