[#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,13 +17,20 @@ 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")],
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") }; content: PathBuf::from("posts/test_post.md") };
@ -37,12 +44,17 @@ fn generate_tavern() -> Tavern
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)?;
//println!("Saved site settings: Title='{}', Description='{}'",
//tavern.title, tavern.description);
db.insert_tavern(&tavern.title, &tavern.description)?; //for author in &tavern.authors
println!("Saved site settings: Title='{}', Description='{}'", tavern.title, tavern.description); // {
// db.insert_adventurer(author)?;
for author in &tavern.authors // println!("Saved adventurer: {}", author.name);
{ // }
db.insert_adventurer(author)?; //
println!("Saved adventurer: {}", author.name); // for tale in &tavern.tales
} // {
// db.insert_tale(tale)?;
for tale in &tavern.tales // println!("Saved tale: {}", tale.title);
{ // }
db.insert_tale(tale)?;
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,183 +1,255 @@
// 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(
sqlx::query!(
"CREATE TABLE IF NOT EXISTS tavern ( "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.
sqlx::query!(
"INSERT OR REPLACE INTO tales ( "INSERT OR REPLACE INTO tales (
slug, title, author, summary, publish_date, content slug, title, author, summary, publish_date, content
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
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.
let mut tx = self.pool.begin().await?;
sqlx::query!(
"INSERT OR REPLACE INTO adventurers ( "INSERT OR REPLACE INTO adventurers (
handle, name, profile, image, blurb handle, name, profile, image, blurb
) VALUES (?1, ?2, ?3, ?4, ?5)", ) VALUES (?1, ?2, ?3, ?4, ?5)",
params![
adventurer.handle, adventurer.handle,
adventurer.name, adventurer.name,
adventurer.profile, adventurer.profile,
adventurer.image, adventurer.image,
adventurer.blurb 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 // 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?;
// 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?;
// Commit the transaction.
tx.commit().await?;
Ok(()) Ok(())
} }
/// Fetches a vector of lightweight FrontMatter objects from the database.
#[cfg(not(feature = "publisher"))] #[cfg(not(feature = "publisher"))]
pub fn get_tales_summary(&self, categories: &[String]) -> Result<Vec<FrontMatter>> { pub async fn get_tales_summary(&self, categories: &[String])
-> Result<Vec<FrontMatter>>
{
let mut tales = Vec::new();
// Start a read-only transaction.
let mut tx = self.pool.begin().await?;
// Dynamically build the query.
let mut query = String::from( let mut query = String::from(
"SELECT "SELECT
t.title, t.title,
@ -187,55 +259,60 @@ impl Database
t.publish_date, t.publish_date,
GROUP_CONCAT(tg.name, ',') AS tags GROUP_CONCAT(tg.name, ',') AS tags
FROM tales AS t FROM tales AS t
JOIN tale_tags AS tt ON t.slug = tt.tale_slug LEFT JOIN tale_tags AS tt ON t.slug = tt.tale_slug
JOIN tags AS tg ON tt.tag_id = tg.id" LEFT JOIN tags AS tg ON tt.tag_id = tg.id"
); );
// If categories are provided, filter the results if !categories.is_empty()
if !categories.is_empty() { {
query.push_str(" WHERE tg.name IN ("); query.push_str(" WHERE tg.name IN (");
// Add a placeholder for each category let placeholders: Vec<_> =
let placeholders: Vec<_> = (0..categories.len()).map(|_| "?").collect(); (0..categories.len()).map(|_| "?").collect();
query.push_str(&placeholders.join(", ")); query.push_str(&placeholders.join(", "));
query.push_str(")"); query.push(')');
} }
// Group by tale to get all tags for each tale query.push_str(" GROUP BY t.slug ORDER BY t.publish_date DESC");
query.push_str(" GROUP BY t.slug");
query.push_str(" ORDER BY t.publish_date DESC"); let mut q = sqlx::query(&query);
for cat in categories
let mut stmt: Statement = self.connection.prepare(&query)?; {
q = q.bind(cat);
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 rows = q.fetch_all(&mut *tx).await?;
let tags: Vec<String> = tags_str.split(',').map(|s| s.to_string()).collect();
Ok(FrontMatter { for row in rows
title: row.get(0)?, {
slug: row.get(1)?, let tags_str: Option<String> = row.try_get("tags")?;
summary: row.get(2)?, let tags = tags_str.map(|s| s.split(',').map(String::from).collect())
author: row.get(3)?, .unwrap_or_default();
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 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()))?;
let summaries: Result<Vec<_>, _> = tales_iter.collect(); tales.push(FrontMatter { title: row.try_get("title")?,
summaries 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)
} }
/// Fetches a single tale from the database by its slug.
/// This function is only available in the 'reader' build.
#[cfg(not(feature = "publisher"))] #[cfg(not(feature = "publisher"))]
pub fn get_tale_by_slug(&self, slug: &str) -> Result<Option<Tale>> { pub async fn get_tale_by_slug(&self, slug: &str) -> Result<Option<Tale>>
let query = String::from( {
let mut tx = self.pool.begin().await?;
let tale_row = sqlx::query(
"SELECT "SELECT
t.title, t.title, t.slug, t.summary, t.author, t.publish_date,
t.slug,
t.summary,
t.author,
t.publish_date,
t.content, t.content,
GROUP_CONCAT(tg.name, ',') AS tags GROUP_CONCAT(tg.name, ',') AS tags
FROM tales AS t FROM tales AS t
@ -243,67 +320,60 @@ impl Database
LEFT JOIN tags AS tg ON tt.tag_id = tg.id LEFT JOIN tags AS tg ON tt.tag_id = tg.id
WHERE t.slug = ?1 WHERE t.slug = ?1
GROUP BY t.slug" GROUP BY t.slug"
); ).bind(slug)
.fetch_optional(&mut *tx) // Use transaction here
.await?;
// Prepare the statement and execute a query for a single row. tx.commit().await?;
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();
// Parse the date string into a NaiveDateTime. if let Some(row) = tale_row
let date_str: String = row.get(4)?; {
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") let publish_date = chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%dT%H:%M:%S")
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(e.into()))?; .map_err(|e| Error::Decode(e.into()))?;
// Construct the FrontMatter and the full Tale struct. let front_matter = FrontMatter { title: row.try_get("title")?,
Ok(Tale { slug: row.try_get("slug")?,
front_matter: FrontMatter { summary: row.try_get("summary")?,
title: row.get(0)?, author: row.try_get("author")?,
slug: row.get(1)?,
summary: row.get(2)?,
author: row.get(3)?,
publish_date, publish_date,
tags, tags };
},
content: row.get(5)?,
})
}
);
// Handle the result: if no rows were returned, return `None`. Ok(Some(Tale { front_matter,
match result { content: row.try_get("content")? }))
Ok(tale) => Ok(Some(tale)), }
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), else
Err(e) => Err(e), {
Ok(None)
} }
} }
/// Fetches a single adventurer from the database by their handle.
/// This function is only available in the 'reader' build.
#[cfg(not(feature = "publisher"))] #[cfg(not(feature = "publisher"))]
pub fn get_adventurer(&self, handle: &str) -> Result<Option<Adventurer>> { pub async fn get_adventurer(&self, handle: &str)
let result = self.connection.query_row( -> Result<Option<Adventurer>>
"SELECT handle, name, profile, image, blurb FROM adventurers WHERE handle = ?1", {
params![handle], let mut tx = self.pool.begin().await?;
|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 { let adventurer = sqlx::query_as!(
Ok(adventurer) => Ok(Some(adventurer)), Adventurer,
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), "SELECT
Err(e) => Err(e), 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);
} }