[#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

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::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)
}
}