Posts and Authors can now be inserted and retrieved from the database created. It was decided to use a SQLite database for it high read spead and ease of use/maintenance. A build feature was created to seperate how the library is being used. If you are making the database and storing posts, then use the publisher flag. If you are just reading from a database then do not use the publisher flag. This was also set to change the tale contents from a PathBuf to the String of HTML blog data without having to create a whole new data object. An example and a test were made. Test coverage needs to be increased however.
310 lines
10 KiB
Rust
310 lines
10 KiB
Rust
// 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 rusqlite::{params, Connection, Result};
|
|
#[cfg(not(feature = "publisher"))]
|
|
use rusqlite::Statement;
|
|
|
|
use crate::adventurer::Adventurer;
|
|
#[cfg(feature = "publisher")]
|
|
use crate::converter::Converter;
|
|
#[cfg(not(feature = "publisher"))]
|
|
use crate::tale::FrontMatter;
|
|
use crate::tale::Tale;
|
|
|
|
|
|
|
|
pub struct Database
|
|
{
|
|
connection: Connection
|
|
}
|
|
|
|
|
|
|
|
impl Database
|
|
{
|
|
/// Opens a connection to the SQLite database and creates the necessary
|
|
/// tables.
|
|
pub fn open(db_path: &Path) -> Result<Self>
|
|
{
|
|
let connection = Connection::open(db_path)?;
|
|
|
|
#[cfg(feature = "publisher")]
|
|
Self::create_tables(&connection)?;
|
|
|
|
Ok(Database { connection })
|
|
}
|
|
|
|
/// Creates the 'tales' and 'adventurers' tables if they don't exist.
|
|
#[cfg(feature = "publisher")]
|
|
fn create_tables(conn: &Connection) -> Result<()>
|
|
{
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS adventurers (
|
|
handle TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
profile TEXT,
|
|
image TEXT,
|
|
blurb TEXT
|
|
)",
|
|
[]
|
|
)?;
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS tales (
|
|
slug TEXT PRIMARY KEY,
|
|
title TEXT NOT NULL,
|
|
author TEXT NOT NULL,
|
|
summary TEXT,
|
|
publish_date TEXT NOT NULL,
|
|
content TEXT
|
|
)",
|
|
[]
|
|
)?;
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS tags (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE
|
|
)",
|
|
[]
|
|
)?;
|
|
conn.execute(
|
|
"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 (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
)",
|
|
[],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Inserts a single tale into the database.
|
|
#[cfg(feature = "publisher")]
|
|
pub fn insert_tale(&self, tale: &Tale) -> Result<()>
|
|
{
|
|
// 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 markdown to HTML
|
|
let html_content = Converter::markdown_to_html(&markdown_content);
|
|
|
|
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
|
|
]
|
|
)?;
|
|
|
|
// Insert tags and link them to the tale
|
|
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])?;
|
|
}
|
|
|
|
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<()>
|
|
{
|
|
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
|
|
]
|
|
)?;
|
|
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(())
|
|
}
|
|
|
|
/// 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(")");
|
|
}
|
|
|
|
// 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");
|
|
|
|
let mut stmt: Statement = self.connection.prepare(&query)?;
|
|
|
|
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,
|
|
})
|
|
})?;
|
|
|
|
let summaries: Result<Vec<_>, _> = tales_iter.collect();
|
|
summaries
|
|
}
|
|
|
|
/// 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"
|
|
);
|
|
|
|
// 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();
|
|
|
|
// 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()))?;
|
|
|
|
// 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)?,
|
|
})
|
|
}
|
|
);
|
|
|
|
// 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),
|
|
}
|
|
}
|
|
|
|
/// 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),
|
|
}
|
|
}
|
|
}
|