Major refactor

This commit is contained in:
2026-04-03 23:04:51 -04:00
parent e7f337c735
commit 35d07e8df1
124 changed files with 4929 additions and 2429 deletions

View File

@@ -0,0 +1,21 @@
[package]
name = "siren-core"
edition.workspace = true
version.workspace = true
rust-version.workspace = true
authors.workspace = true
[dependencies]
tokio = { workspace = true }
log = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
chrono = { workspace = true }
reqwest = { workspace = true }
uuid = { workspace = true }
redis = { workspace = true }
rand = { workspace = true }
rand_chacha = { workspace = true }
regex = { workspace = true }
lazy_static = { workspace = true }

View File

@@ -0,0 +1,81 @@
use crate::error::Result;
use std::env;
pub struct EnvironmentConfiguration {
pub rust_log: String,
pub discord_token: String,
pub discord_secret: String,
pub jwt_secret: String,
pub postgres_user: String,
pub postgres_password: String,
pub postgres_database: String,
pub postgres_host: String,
pub postgres_port: u16,
pub api_base_url: String,
pub api_port: u16,
pub api_session_ttl: u64,
pub valkey_host: String,
pub valkey_port: u16,
pub minio_root_user: String,
pub minio_root_password: String,
pub minio_host: String,
pub minio_port: u16,
pub minio_port_internal: u16,
pub data_dir_path: Option<String>,
pub force_register: bool,
pub default_api_key: String,
pub default_server: Option<String>,
pub default_user: Option<String>,
}
impl EnvironmentConfiguration {
pub fn load() -> Result<Self> {
Ok(Self {
rust_log: env::var("RUST_LOG").unwrap_or_else(|_| "warn,siren=info".to_string()),
discord_token: env::var("DISCORD_BOT_TOKEN")?,
discord_secret: env::var("DISCORD_CLIENT_SECRET")?,
jwt_secret: env::var("JWT_SECRET")?,
postgres_user: env::var("POSTGRES_USER")?,
postgres_password: env::var("POSTGRES_PASSWORD")?,
postgres_database: env::var("POSTGRES_DB")?,
postgres_host: env::var("POSTGRES_HOST")?,
postgres_port: env::var("POSTGRES_PORT")
.unwrap_or_else(|_| "5432".to_string())
.parse()
.unwrap_or(5432),
api_base_url: env::var("API_BASE_URL")?,
api_port: env::var("API_PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.unwrap_or(3000),
api_session_ttl: env::var("API_SESSION_TTL")
.unwrap_or_else(|_| "86400".to_string())
.parse()
.unwrap_or(86400),
valkey_host: env::var("VALKEY_HOST").unwrap_or_else(|_| "localhost".to_string()),
valkey_port: env::var("VALKEY_PORT")
.unwrap_or_else(|_| "6379".to_string())
.parse()
.unwrap_or(6379),
minio_root_user: env::var("MINIO_ROOT_USER")?,
minio_root_password: env::var("MINIO_ROOT_PASSWORD")?,
minio_host: env::var("MINIO_HOST").unwrap_or_else(|_| "localhost".to_string()),
minio_port: env::var("MINIO_PORT")
.unwrap_or_else(|_| "9000".to_string())
.parse()
.unwrap_or(9000),
minio_port_internal: env::var("MINIO_PORT_INTERNAL")
.unwrap_or_else(|_| "9001".to_string())
.parse()
.unwrap_or(9001),
data_dir_path: env::var("DATA_DIR_PATH").ok().filter(|s| !s.is_empty()),
force_register: env::var("FORCE_REGISTER")
.ok()
.map(|v| v.to_lowercase() == "true")
.unwrap_or(false),
default_api_key: env::var("DEFAULT_API_KEY").unwrap_or_default(),
default_server: env::var("DEFAULT_SERVER").ok().filter(|s| !s.is_empty()),
default_user: env::var("DEFAULT_USER").ok().filter(|s| !s.is_empty()),
})
}
}

View File

@@ -0,0 +1,243 @@
use crate::data::Value;
pub enum Condition {
Simple(String, Vec<Value>),
And(Box<Condition>, Box<Condition>),
Or(Box<Condition>, Box<Condition>),
Group(Box<Condition>),
}
impl Condition {
pub fn new(condition: &str) -> Self {
Condition::Simple(condition.to_string(), vec![])
}
pub fn and(self, other: Self) -> Self {
Condition::And(Box::new(self), Box::new(other))
}
pub fn or(self, other: Self) -> Self {
Condition::Or(Box::new(self), Box::new(other))
}
pub fn group(self) -> Self {
Condition::Group(Box::new(self))
}
pub fn is_equal(left: &str, right: impl Into<Value> + Clone) -> Self {
let value = right.clone().into();
match Self::from_optional_value(left, value) {
Some(condition) => condition,
None => Condition::Simple(format!("{} = ?", left), vec![right.into()]),
}
}
pub fn not_equal(left: &str, right: impl Into<Value> + Clone) -> Self {
let value = right.clone().into();
match Self::from_optional_value(left, value) {
Some(condition) => condition,
None => Condition::Simple(format!("{} != ?", left), vec![right.into()]),
}
}
pub fn is_null(value: &str) -> Self {
Condition::Simple(format!("{} IS NULL", value), vec![])
}
pub fn not_null(value: &str) -> Self {
Condition::Simple(format!("{} IS NOT NULL", value), vec![])
}
pub fn is_in(left: &str, right: Vec<Value>) -> Self {
// Use helper function to handle special cases
if let Some(condition) = Self::handle_empty_or_all_none(left, &right, true) {
return condition;
}
let right_list = right
.iter()
.map(|_v| "'?'".to_string())
.collect::<Vec<_>>()
.join(", ");
Condition::Simple(format!("{} IN ({})", left, right_list), right)
}
pub fn not_in(left: &str, right: Vec<Value>) -> Self {
// Use helper function to handle special cases
if let Some(condition) = Self::handle_empty_or_all_none(left, &right, true) {
return condition;
}
let right_list = right
.iter()
.map(|_v| "'?'".to_string())
.collect::<Vec<_>>()
.join(", ");
Condition::Simple(format!("{} NOT IN ({})", left, right_list), right)
}
pub fn like(left: &str, right: impl Into<Value> + Clone) -> Self {
let value = right.clone().into();
match Self::from_optional_value(left, value) {
Some(condition) => condition,
None => Condition::Simple(format!("{} LIKE '?'", left), vec![right.into()]),
}
}
pub fn not_like(left: &str, right: impl Into<Value> + Clone) -> Self {
let value = right.clone().into();
match Self::from_optional_value(left, value) {
Some(condition) => condition,
None => Condition::Simple(format!("{} NOT LIKE '?'", left), vec![right.into()]),
}
}
pub fn i_like(left: &str, right: impl Into<Value> + Clone) -> Self {
let value = right.clone().into();
match Self::from_optional_value(left, value) {
Some(condition) => condition,
None => Condition::Simple(format!("{} ILIKE '?'", left), vec![right.into()]),
}
}
pub fn not_i_like(left: &str, right: impl Into<Value> + Clone) -> Self {
let value = right.clone().into();
match Self::from_optional_value(left, value) {
Some(condition) => condition,
None => Condition::Simple(format!("{} NOT ILIKE '?'", left), vec![right.into()]),
}
}
pub fn gt(left: &str, right: impl Into<Value> + Clone) -> Self {
let value = right.clone().into();
match Self::from_optional_value(left, value) {
Some(condition) => condition,
None => Condition::Simple(format!("{} > ?", left), vec![right.into()]),
}
}
pub fn gte(left: &str, right: impl Into<Value> + Clone) -> Self {
let value = right.clone().into();
match Self::from_optional_value(left, value) {
Some(condition) => condition,
None => Condition::Simple(format!("{} >= ?", left), vec![right.into()]),
}
}
pub fn lt(left: &str, right: impl Into<Value> + Clone) -> Self {
let value = right.clone().into();
match Self::from_optional_value(left, value) {
Some(condition) => condition,
None => Condition::Simple(format!("{} < ?", left), vec![right.into()]),
}
}
pub fn lte(left: &str, right: impl Into<Value> + Clone) -> Self {
let value = right.clone().into();
match Self::from_optional_value(left, value) {
Some(condition) => condition,
None => Condition::Simple(format!("{} <= ?", left), vec![right.into()]),
}
}
// Private helper function to handle optional values
fn from_optional_value(left: &str, value: Value) -> Option<Self> {
match value {
Value::OptionalInt(None) => Some(Condition::is_null(left)),
Value::OptionalBigInt(None) => Some(Condition::is_null(left)),
Value::OptionalFloat(None) => Some(Condition::is_null(left)),
Value::OptionalDouble(None) => Some(Condition::is_null(left)),
Value::OptionalBool(None) => Some(Condition::is_null(left)),
Value::OptionalText(None) => Some(Condition::is_null(left)),
Value::OptionalDateTime(None) => Some(Condition::is_null(left)),
_ => None, // For non-optional or Some(value), let the primary method handle it
}
}
// Private helper to handle `empty` or `all-None` lists
fn handle_empty_or_all_none(left: &str, right: &[Value], negate: bool) -> Option<Self> {
if right.is_empty() {
// For an empty list, return an always-false condition
// NOT IN with empty list is always TRUE, but we're defaulting to SQL-SAFE result (FALSE)
return Some(Condition::Simple(
if negate {
"TRUE".to_string()
} else {
"FALSE".to_string()
},
vec![],
));
}
// Check if all elements in the `right` vector are `None` (Optional*)
if right.iter().all(|v| {
matches!(
v,
Value::OptionalInt(None)
| Value::OptionalBigInt(None)
| Value::OptionalFloat(None)
| Value::OptionalDouble(None)
| Value::OptionalBool(None)
| Value::OptionalText(None)
| Value::OptionalDateTime(None)
)
}) {
// If all values are None, handle as NULL or NOT NULL
return Some(if negate {
Condition::not_null(left)
} else {
Condition::is_null(left)
});
}
// Otherwise, this is not an empty or all-none case
None
}
pub fn to_sql(&self, counter: &mut usize) -> (String, Vec<Value>) {
let mut sql = String::new();
let mut binds = Vec::new();
match self {
Condition::Simple(condition, values) => {
// Replace each instance of '?' with increasing numbered binds
let mut numbered_condition = String::new();
let mut chars = condition.chars().peekable();
while let Some(c) = chars.next() {
if c == '?' {
// Increment the counter and replace `?` with a numbered bind
*counter += 1;
numbered_condition.push_str(&format!("${}", *counter));
} else {
numbered_condition.push(c);
}
}
sql.push_str(&numbered_condition);
binds.extend(values.clone());
}
Condition::And(left, right) => {
let (left_sql, left_binds) = left.to_sql(counter);
let (right_sql, right_binds) = right.to_sql(counter);
sql.push_str(&format!("{} AND {}", left_sql, right_sql));
binds.extend(left_binds);
binds.extend(right_binds);
}
Condition::Or(left, right) => {
let (left_sql, left_binds) = left.to_sql(counter);
let (right_sql, right_binds) = right.to_sql(counter);
sql.push_str(&format!("{} OR {}", left_sql, right_sql));
binds.extend(left_binds);
binds.extend(right_binds);
}
Condition::Group(inner) => {
let (inner_sql, inner_binds) = inner.to_sql(counter);
sql.push_str(&format!("({})", inner_sql));
binds.extend(inner_binds);
}
};
(sql, binds)
}
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,3 @@
mod model;
pub use model::*;

View File

@@ -0,0 +1,46 @@
use std::str::FromStr;
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
pub enum AbilityType {
#[serde(rename = "strength")]
Strength,
#[serde(rename = "dexterity")]
Dexterity,
#[serde(rename = "constitution")]
Constitution,
#[serde(rename = "intelligence")]
Intelligence,
#[serde(rename = "wisdom")]
Wisdom,
#[serde(rename = "charisma")]
Charisma,
}
impl AbilityType {
pub fn to_string(&self) -> String {
match self {
AbilityType::Strength => "Strength".to_string(),
AbilityType::Dexterity => "Dexterity".to_string(),
AbilityType::Constitution => "Constitution".to_string(),
AbilityType::Intelligence => "Intelligence".to_string(),
AbilityType::Wisdom => "Wisdom".to_string(),
AbilityType::Charisma => "Charisma".to_string(),
}
}
}
impl FromStr for AbilityType {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"Strength" => Ok(AbilityType::Strength),
"Dexterity" => Ok(AbilityType::Dexterity),
"Constitution" => Ok(AbilityType::Constitution),
"Intelligence" => Ok(AbilityType::Intelligence),
"Wisdom" => Ok(AbilityType::Wisdom),
"Charisma" => Ok(AbilityType::Charisma),
_ => Err(()),
}
}
}

View File

@@ -0,0 +1,82 @@
use std::str::FromStr;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub enum ConditionType {
#[serde(rename = "blinded")]
Blinded,
#[serde(rename = "charmed")]
Charmed,
#[serde(rename = "deafened")]
Deafened,
#[serde(rename = "exhaustion")]
Exhaustion,
#[serde(rename = "frightened")]
Frightened,
#[serde(rename = "grappled")]
Grappled,
#[serde(rename = "incapacitated")]
Incapacitated,
#[serde(rename = "invisible")]
Invisible,
#[serde(rename = "paralyzed")]
Paralyzed,
#[serde(rename = "petrified")]
Petrified,
#[serde(rename = "poisoned")]
Poisoned,
#[serde(rename = "prone")]
Prone,
#[serde(rename = "restrained")]
Restrained,
#[serde(rename = "stunned")]
Stunned,
#[serde(rename = "unconscious")]
Unconscious,
}
impl ConditionType {
pub fn to_string(&self) -> String {
match self {
ConditionType::Blinded => "Blinded".to_string(),
ConditionType::Charmed => "Charmed".to_string(),
ConditionType::Deafened => "Deafened".to_string(),
ConditionType::Exhaustion => "Exhaustion".to_string(),
ConditionType::Frightened => "Frightened".to_string(),
ConditionType::Grappled => "Grappled".to_string(),
ConditionType::Incapacitated => "Incapacitated".to_string(),
ConditionType::Invisible => "Invisible".to_string(),
ConditionType::Paralyzed => "Paralyzed".to_string(),
ConditionType::Petrified => "Petrified".to_string(),
ConditionType::Poisoned => "Poisoned".to_string(),
ConditionType::Prone => "Prone".to_string(),
ConditionType::Restrained => "Restrained".to_string(),
ConditionType::Stunned => "Stunned".to_string(),
ConditionType::Unconscious => "Unconscious".to_string(),
}
}
}
impl FromStr for ConditionType {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"Blinded" => Ok(ConditionType::Blinded),
"Charmed" => Ok(ConditionType::Charmed),
"Deafened" => Ok(ConditionType::Deafened),
"Exhaustion" => Ok(ConditionType::Exhaustion),
"Frightened" => Ok(ConditionType::Frightened),
"Grappled" => Ok(ConditionType::Grappled),
"Incapacitated" => Ok(ConditionType::Incapacitated),
"Invisible" => Ok(ConditionType::Invisible),
"Paralyzed" => Ok(ConditionType::Paralyzed),
"Petrified" => Ok(ConditionType::Petrified),
"Poisoned" => Ok(ConditionType::Poisoned),
"Prone" => Ok(ConditionType::Prone),
"Restrained" => Ok(ConditionType::Restrained),
"Stunned" => Ok(ConditionType::Stunned),
"Unconscious" => Ok(ConditionType::Unconscious),
_ => Err(()),
}
}
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,13 @@
pub mod backgrounds;
pub mod bestiary;
pub mod classes;
pub mod conditions;
pub mod feats;
pub mod items;
pub mod options;
pub mod races;
pub mod spells;
pub fn load_data(data_dir_path: &str) {
spells::load_data(data_dir_path);
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,62 @@
mod model;
mod types;
use std::{
fs::{metadata, File, read_dir},
path::Path,
io::BufReader,
};
pub use model::*;
pub use types::*;
pub fn load_data(data_dir_path: &str) {
if Path::new(data_dir_path).exists() {
let meta = metadata(data_dir_path).unwrap();
if meta.is_dir() {
let spells_dir_path = format!("{}/spells", data_dir_path);
if Path::new(&spells_dir_path).exists() {
let meta = metadata(&spells_dir_path).unwrap();
if meta.is_dir() {
for entry in read_dir(&spells_dir_path).unwrap() {
let entry = entry.unwrap();
let path = entry.path();
if path.is_file() {
let file = File::open(path).unwrap();
let reader = BufReader::new(file);
let result: Result<Vec<Spell>, serde_json::Error> = serde_json::from_reader(reader);
match result {
Ok(spells) => {
for spell in spells {
// let mut filters = QueryFilters::default();
// filters.by_name = Some(spell.name.clone());
// match QuerySpell::get_all(&filters, 100, 1) {
// Ok(spells) => {
// if spells.len() > 0 {
// trace!("Spell '{}' already exists", spell.name);
// continue;
// }
// }
// Err(err) => {
// warn!("Error checking if spell '{}' exists: {}", spell.name, err);
// continue;
// }
// };
// let spell = InsertSpell::insert(spell.into()).unwrap();
// trace!("Inserted spell: {}", spell.name);
}
}
Err(err) => log::warn!("Error reading spells from file: {}", err),
};
}
}
}
}
}
} else {
log::warn!(
"Data path '{}' does not exist, no data imported",
data_dir_path
);
}
}

View File

@@ -0,0 +1,184 @@
use serde::{Deserialize, Serialize};
use crate::dnd::{classes::AbilityType, conditions::ConditionType};
use super::{
SchoolType, CastingTime, SpellAttackType, SpellDamageType, Range, Area, Components, Duration,
Source, Description, DurationType, Effect,
};
#[derive(Debug, Serialize, Deserialize)]
pub struct QuerySpell {
pub id: i32,
pub name: String,
pub school: String,
pub level: i32,
pub ritual: bool,
pub concentration: bool,
pub classes: Vec<String>,
pub damage_inflict: Vec<String>,
pub damage_resist: Vec<String>,
pub conditions: Vec<String>,
pub saving_throw: Vec<String>,
pub attack_type: Option<String>,
pub data: serde_json::Value,
}
#[derive(Debug)]
pub struct InsertSpell {
pub name: String,
pub school: String,
pub level: i32,
pub ritual: bool,
pub concentration: bool,
pub classes: Vec<String>,
pub damage_inflict: Vec<String>,
pub damage_resist: Vec<String>,
pub conditions: Vec<String>,
pub saving_throw: Vec<String>,
pub attack_type: Option<String>,
pub data: serde_json::Value,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Spell {
pub id: Option<i32>,
pub name: String,
pub school: SchoolType,
pub level: i32,
pub ritual: bool,
pub casting_time: CastingTime,
#[serde(skip_serializing_if = "Option::is_none")]
pub effect: Option<Effect>,
#[serde(skip_serializing_if = "Option::is_none")]
pub saving_throw: Option<Vec<AbilityType>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attack_type: Option<SpellAttackType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub damage_inflict: Option<Vec<SpellDamageType>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub damage_resist: Option<Vec<SpellDamageType>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conditions: Option<Vec<ConditionType>>,
pub range: Range,
#[serde(skip_serializing_if = "Option::is_none")]
pub area: Option<Area>,
pub components: Components,
pub durations: Vec<Duration>,
pub classes: Vec<String>,
pub sources: Vec<Source>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<Description>,
}
impl From<QuerySpell> for Spell {
fn from(query: QuerySpell) -> Self {
return match serde_json::from_value(query.data) {
Ok(data) => data,
Err(err) => {
log::error!("Failed to parse spell: {}", err);
Self {
id: None,
name: "".to_string(),
school: SchoolType::Abjuration,
level: 0,
ritual: false,
casting_time: CastingTime {
value: 0,
casting_type: "".to_string(),
note: None,
},
effect: None,
saving_throw: None,
attack_type: None,
damage_inflict: None,
damage_resist: None,
conditions: None,
range: Range {
range_type: "".to_string(),
value: None,
unit: None,
},
area: None,
components: Components {
verbal: false,
somatic: false,
material: false,
materials_needed: None,
materials_cost: None,
materials_consumed: None,
},
durations: vec![],
classes: vec![],
sources: vec![],
tags: None,
description: None,
}
}
};
}
}
impl Into<InsertSpell> for Spell {
fn into(self) -> InsertSpell {
return InsertSpell {
name: self.name.to_string(),
school: self.school.to_string(),
level: self.level,
ritual: self.ritual,
concentration: self
.durations
.iter()
.any(|duration| match duration.duration_type {
DurationType::Concentration => true,
_ => false,
}),
classes: self
.classes
.iter()
.map(|class| class.to_string())
.collect::<Vec<String>>(),
damage_inflict: match &self.damage_inflict {
Some(damage_inflict) => damage_inflict
.iter()
.map(|damage_inflict| damage_inflict.to_string())
.collect(),
None => vec![],
},
damage_resist: match &self.damage_resist {
Some(damage_resist) => damage_resist
.iter()
.map(|damage_resist| damage_resist.to_string())
.collect(),
None => vec![],
},
conditions: match &self.conditions {
Some(conditions) => conditions
.iter()
.map(|condition| condition.to_string())
.collect(),
None => vec![],
},
saving_throw: match &self.saving_throw {
Some(saving_throw) => saving_throw
.iter()
.map(|saving_throw| saving_throw.to_string())
.collect(),
None => vec![],
},
attack_type: self
.attack_type
.as_ref()
.map(|attack_type| attack_type.to_string()),
data: match serde_json::to_value(&self) {
Ok(data) => data,
Err(err) => {
log::error!("Failed to serialize spell: {}", err);
serde_json::Value::Null
}
},
};
}
}

View File

@@ -0,0 +1,366 @@
use std::str::FromStr;
use serde::{Deserialize, Serialize, ser::SerializeMap};
#[derive(Debug, Serialize, Deserialize)]
pub enum SchoolType {
#[serde(rename = "abjuration")]
Abjuration,
#[serde(rename = "conjuration")]
Conjuration,
#[serde(rename = "divination")]
Divination,
#[serde(rename = "enchantment")]
Enchantment,
#[serde(rename = "evocation")]
Evocation,
#[serde(rename = "illusion")]
Illusion,
#[serde(rename = "necromancy")]
Necromancy,
#[serde(rename = "transmutation")]
Transmutation,
}
impl SchoolType {
pub fn to_string(&self) -> String {
match self {
SchoolType::Abjuration => "abjuration".to_string(),
SchoolType::Conjuration => "conjuration".to_string(),
SchoolType::Divination => "divination".to_string(),
SchoolType::Enchantment => "enchantment".to_string(),
SchoolType::Evocation => "evocation".to_string(),
SchoolType::Illusion => "illusion".to_string(),
SchoolType::Necromancy => "necromancy".to_string(),
SchoolType::Transmutation => "transmutation".to_string(),
}
}
}
impl FromStr for SchoolType {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"abjuration" => Ok(SchoolType::Abjuration),
"conjuration" => Ok(SchoolType::Conjuration),
"divination" => Ok(SchoolType::Divination),
"enchantment" => Ok(SchoolType::Enchantment),
"evocation" => Ok(SchoolType::Evocation),
"illusion" => Ok(SchoolType::Illusion),
"necromancy" => Ok(SchoolType::Necromancy),
"transmutation" => Ok(SchoolType::Transmutation),
_ => Err(()),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CastingTime {
pub value: i32,
#[serde(rename = "unit")]
pub casting_type: String,
pub note: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum SpellAttackType {
#[serde(rename = "melee")]
Melee,
#[serde(rename = "ranged")]
Ranged,
}
impl SpellAttackType {
pub fn to_string(&self) -> String {
match self {
SpellAttackType::Melee => "melee".to_string(),
SpellAttackType::Ranged => "ranged".to_string(),
}
}
}
impl FromStr for SpellAttackType {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"melee" => Ok(SpellAttackType::Melee),
"ranged" => Ok(SpellAttackType::Ranged),
_ => Err(()),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub enum SpellDamageType {
#[serde(rename = "acid")]
Acid,
#[serde(rename = "bludgeoning")]
Bludgeoning,
#[serde(rename = "cold")]
Cold,
#[serde(rename = "fire")]
Fire,
#[serde(rename = "force")]
Force,
#[serde(rename = "lightning")]
Lightning,
#[serde(rename = "necrotic")]
Necrotic,
#[serde(rename = "piercing")]
Piercing,
#[serde(rename = "poison")]
Poison,
#[serde(rename = "psychic")]
Psychic,
#[serde(rename = "radiant")]
Radiant,
#[serde(rename = "slashing")]
Slashing,
#[serde(rename = "thunder")]
Thunder,
}
impl SpellDamageType {
pub fn to_string(&self) -> String {
match self {
SpellDamageType::Acid => "acid".to_string(),
SpellDamageType::Bludgeoning => "bludgeoning".to_string(),
SpellDamageType::Cold => "cold".to_string(),
SpellDamageType::Fire => "fire".to_string(),
SpellDamageType::Force => "force".to_string(),
SpellDamageType::Lightning => "lightning".to_string(),
SpellDamageType::Necrotic => "necrotic".to_string(),
SpellDamageType::Piercing => "piercing".to_string(),
SpellDamageType::Poison => "poison".to_string(),
SpellDamageType::Psychic => "psychic".to_string(),
SpellDamageType::Radiant => "radiant".to_string(),
SpellDamageType::Slashing => "slashing".to_string(),
SpellDamageType::Thunder => "thunder".to_string(),
}
}
}
impl FromStr for SpellDamageType {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"acid" => Ok(SpellDamageType::Acid),
"bludgeoning" => Ok(SpellDamageType::Bludgeoning),
"cold" => Ok(SpellDamageType::Cold),
"fire" => Ok(SpellDamageType::Fire),
"force" => Ok(SpellDamageType::Force),
"lightning" => Ok(SpellDamageType::Lightning),
"necrotic" => Ok(SpellDamageType::Necrotic),
"piercing" => Ok(SpellDamageType::Piercing),
"poison" => Ok(SpellDamageType::Poison),
"psychic" => Ok(SpellDamageType::Psychic),
"radiant" => Ok(SpellDamageType::Radiant),
"slashing" => Ok(SpellDamageType::Slashing),
"thunder" => Ok(SpellDamageType::Thunder),
_ => Err(()),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Range {
#[serde(rename = "type")]
pub range_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Area {
#[serde(rename = "type")]
pub area_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Duration {
#[serde(rename = "type")]
pub duration_type: DurationType,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum DurationType {
#[serde(rename = "concentration")]
Concentration,
#[serde(rename = "instantaneous")]
Instantaneous,
#[serde(rename = "timed")]
Timed,
#[serde(rename = "dispelled")]
UntilDispelled,
#[serde(rename = "special")]
Special,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Source {
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub page: Option<i32>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Description {
pub entries: Vec<Entry>,
}
#[derive(Debug)]
pub struct Entry {
pub text: Option<String>,
pub list: Option<Vec<String>>,
pub table: Option<EntryTable>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EntryTable {
pub headers: Vec<String>,
pub rows: Vec<Vec<String>>,
}
impl<'de> Deserialize<'de> for Entry {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
match value {
serde_json::Value::String(s) => Ok(Entry {
text: Some(s),
list: None,
table: None,
}),
serde_json::Value::Object(o) => {
let text = match o.get("text") {
Some(t) => match t.as_str() {
Some(s) => Some(s.to_string()),
None => return Err(serde::de::Error::custom("Invalid entry text")),
},
None => None,
};
let list = match o.get("list") {
Some(i) => match i.as_array() {
Some(a) => {
let mut list = Vec::new();
for item in a {
match item.as_str() {
Some(s) => list.push(s.to_string()),
None => return Err(serde::de::Error::custom("Invalid entry list item")),
}
}
Some(list)
}
None => return Err(serde::de::Error::custom("Invalid entry list items")),
},
None => None,
};
let table = match o.get("table") {
Some(t) => match t.as_object() {
Some(o) => {
let mut headers = Vec::new();
let mut rows = Vec::new();
match o.get("headers") {
Some(c) => match c.as_array() {
Some(a) => {
for item in a {
match item.as_str() {
Some(s) => headers.push(s.to_string()),
None => return Err(serde::de::Error::custom("Invalid entry table header")),
}
}
}
None => return Err(serde::de::Error::custom("Invalid entry table headers")),
},
None => return Err(serde::de::Error::custom("Missing entry table headers")),
};
match o.get("rows") {
Some(r) => match r.as_array() {
Some(a) => {
for row in a {
match row.as_array() {
Some(a) => {
let mut row = Vec::new();
for item in a {
match item.as_str() {
Some(s) => row.push(s.to_string()),
None => {
return Err(serde::de::Error::custom(
"Invalid entry table row item",
))
}
}
}
rows.push(row);
}
None => return Err(serde::de::Error::custom("Invalid entry table row")),
}
}
}
None => return Err(serde::de::Error::custom("Invalid entry table rows")),
},
None => return Err(serde::de::Error::custom("Missing entry table rows")),
};
Some(EntryTable { headers, rows })
}
None => return Err(serde::de::Error::custom("Invalid entry table")),
},
None => None,
};
Ok(Entry { text, list, table })
}
_ => Err(serde::de::Error::custom("Invalid entry")),
}
}
}
impl Serialize for Entry {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut map = serializer.serialize_map(Some(1))?;
if let Some(text) = &self.text {
map.serialize_entry("text", text)?;
}
if let Some(list) = &self.list {
map.serialize_entry("list", list)?;
}
if let Some(table) = &self.table {
map.serialize_entry("table", table)?;
}
map.end()
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Components {
pub verbal: bool,
pub somatic: bool,
pub material: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub materials_needed: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub materials_cost: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub materials_consumed: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Effect {
pub effect_type: Option<String>,
}

View File

@@ -0,0 +1,3 @@
mod model;
pub use model::*;

View File

@@ -0,0 +1,57 @@
use crate::error::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
const TABLE_NAME: &str = "events";
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct Event {
pub id: Uuid,
pub guild_id: i64,
pub author_id: i64,
pub title: String,
pub date_time: DateTime<Utc>,
pub description: Option<String>,
pub rsvp: Vec<i64>,
}
impl Event {
pub async fn insert(&self) -> Result<()> {
let pool = crate::data::pool();
sqlx::query(&format!(
"INSERT INTO {} (
id,
guild_id,
author_id,
title,
date_time,
description,
rsvp
) VALUES (
$1, $2, $3, $4, $5, $6, $7
)",
TABLE_NAME
))
.bind(self.id)
.bind(self.guild_id)
.bind(self.author_id)
.bind(&self.title)
.bind(self.date_time)
.bind(&self.description)
.bind(&self.rsvp)
.execute(pool)
.await?;
Ok(())
}
pub async fn get_by_id(id: i64) -> Result<Option<Self>> {
let pool = crate::data::pool();
let item = sqlx::query_as::<_, Self>(&format!("SELECT * FROM {} WHERE id = $1", TABLE_NAME))
.bind(id)
.fetch_optional(pool)
.await?;
Ok(item)
}
}

View File

@@ -0,0 +1,106 @@
use crate::data::Value;
use sqlx::{FromRow, Postgres};
#[allow(async_fn_in_trait)]
pub trait ExecutableQuery {
fn build(&self) -> (String, Vec<Value>);
async fn execute(&self) -> Result<sqlx::postgres::PgQueryResult, sqlx::Error> {
// Build the SQL query and its values
let (query_string, values) = self.build();
// Start constructing the query
let mut query = sqlx::query(&query_string);
// Bind each value to its respective placeholder
for value in values {
match value {
Value::Int(n) => query = query.bind(n),
Value::OptionalInt(n) => query = query.bind(n),
Value::BigInt(n) => query = query.bind(n),
Value::OptionalBigInt(n) => query = query.bind(n),
Value::Float(n) => query = query.bind(n),
Value::OptionalFloat(n) => query = query.bind(n),
Value::Double(n) => query = query.bind(n),
Value::OptionalDouble(n) => query = query.bind(n),
Value::Bool(n) => query = query.bind(n),
Value::OptionalBool(n) => query = query.bind(n),
Value::Text(n) => query = query.bind(n),
Value::OptionalText(n) => query = query.bind(n),
Value::DateTime(n) => query = query.bind(n),
Value::OptionalDateTime(n) => query = query.bind(n),
}
}
let pool = crate::data::pool();
query.execute(pool).await
}
async fn fetch_optional<
T: Send + Unpin + for<'r> FromRow<'r, <Postgres as sqlx::Database>::Row>,
>(
&self,
) -> Option<T> {
let (query_string, values) = self.build();
let mut query_as = sqlx::query_as(&query_string);
for value in values {
match value {
Value::Int(n) => query_as = query_as.bind(n),
Value::OptionalInt(n) => query_as = query_as.bind(n),
Value::BigInt(n) => query_as = query_as.bind(n),
Value::OptionalBigInt(n) => query_as = query_as.bind(n),
Value::Float(n) => query_as = query_as.bind(n),
Value::OptionalFloat(n) => query_as = query_as.bind(n),
Value::Double(n) => query_as = query_as.bind(n),
Value::OptionalDouble(n) => query_as = query_as.bind(n),
Value::Bool(n) => query_as = query_as.bind(n),
Value::OptionalBool(n) => query_as = query_as.bind(n),
Value::Text(n) => query_as = query_as.bind(n),
Value::OptionalText(n) => query_as = query_as.bind(n),
Value::DateTime(n) => query_as = query_as.bind(n),
Value::OptionalDateTime(n) => query_as = query_as.bind(n),
}
}
let pool = crate::data::pool();
query_as.fetch_optional(pool).await.unwrap_or_else(|err| {
log::error!(
"Unable to fetch optional on query '{}': {}",
query_string,
err
);
None
})
}
async fn fetch_all<T: Send + Unpin + for<'r> FromRow<'r, <Postgres as sqlx::Database>::Row>>(
&self,
) -> Vec<T> {
let (query_string, values) = self.build();
let mut query_as = sqlx::query_as(&query_string);
for value in values {
match value {
Value::Int(n) => query_as = query_as.bind(n),
Value::OptionalInt(n) => query_as = query_as.bind(n),
Value::BigInt(n) => query_as = query_as.bind(n),
Value::OptionalBigInt(n) => query_as = query_as.bind(n),
Value::Float(n) => query_as = query_as.bind(n),
Value::OptionalFloat(n) => query_as = query_as.bind(n),
Value::Double(n) => query_as = query_as.bind(n),
Value::OptionalDouble(n) => query_as = query_as.bind(n),
Value::Bool(n) => query_as = query_as.bind(n),
Value::OptionalBool(n) => query_as = query_as.bind(n),
Value::Text(n) => query_as = query_as.bind(n),
Value::OptionalText(n) => query_as = query_as.bind(n),
Value::DateTime(n) => query_as = query_as.bind(n),
Value::OptionalDateTime(n) => query_as = query_as.bind(n),
}
}
let pool = crate::data::pool();
query_as.fetch_all(pool).await.unwrap_or_else(|err| {
log::error!("Unable to fetch all on query '{}': {}", query_string, err);
vec![]
})
}
}

View File

@@ -0,0 +1,3 @@
mod model;
pub use model::*;

View File

@@ -0,0 +1,53 @@
use crate::{
data::{
Value,
condition::Condition,
executable_query::ExecutableQuery,
insert::InsertBuilder,
query::QueryBuilder,
update::UpdateBuilder,
},
error::Result,
};
use serde::{Deserialize, Serialize};
const TABLE_NAME: &str = "guilds";
#[derive(Serialize, Deserialize, sqlx::FromRow, Debug)]
pub struct GuildCache {
pub id: i64,
pub name: Option<String>,
pub owner_id: Option<i64>,
pub volume: i32,
}
impl GuildCache {
pub async fn insert(&self) -> Result<()> {
InsertBuilder::new(TABLE_NAME)
.column("id", Value::BigInt(self.id))
.column("name", Value::OptionalText(self.name.clone()))
.column("owner_id", Value::OptionalBigInt(self.owner_id))
.column("volume", Value::Int(self.volume))
.execute()
.await?;
Ok(())
}
pub async fn find_by_id(id: i64) -> Option<Self> {
QueryBuilder::new(TABLE_NAME)
.where_condition(Condition::is_equal("id", Value::BigInt(id)))
.fetch_optional()
.await
}
pub async fn update(&self) -> Result<()> {
UpdateBuilder::new(TABLE_NAME)
.column("name", Value::OptionalText(self.name.clone()))
.column("owner_id", Value::OptionalBigInt(self.owner_id))
.column("volume", Value::Int(self.volume))
.where_condition(Condition::is_equal("id", Value::BigInt(self.id)))
.execute()
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,60 @@
use crate::data::{Value, executable_query::ExecutableQuery};
pub struct InsertBuilder {
table: String,
columns: Vec<String>,
returning: Vec<String>,
values: Vec<Value>,
}
impl InsertBuilder {
pub fn new(table: &str) -> Self {
Self {
table: table.to_string(),
columns: Vec::new(),
returning: Vec::new(),
values: Vec::new(),
}
}
pub fn column(mut self, column: &str, value: Value) -> Self {
self.columns.push(column.to_string());
self.values.push(value);
self
}
pub fn returning(mut self, columns: &[&str]) -> Self {
self.returning = columns.iter().map(|s| s.to_string()).collect();
self
}
}
impl ExecutableQuery for InsertBuilder {
fn build(&self) -> (String, Vec<Value>) {
if self.columns.is_empty() || self.values.is_empty() {
panic!("Cannot build insert query without columns and values");
}
// Create the list of column names
let columns = self.columns.join(", ");
// Generate placeholders for values ($1, $2, etc.)
let placeholders = (1..=self.values.len())
.map(|i| format!("${}", i))
.collect::<Vec<_>>()
.join(", ");
// Create the basic INSERT statement
let mut query = format!(
"INSERT INTO {} ({}) VALUES ({})",
self.table, columns, placeholders
);
// Add RETURNING clause if specified
if !self.returning.is_empty() {
query.push_str(&format!(" RETURNING {}", self.returning.join(", ")));
}
(query, self.values.clone())
}
}

View File

@@ -0,0 +1,3 @@
mod model;
pub use model::*;

View File

@@ -0,0 +1,74 @@
use crate::error::Result;
use serde::{Deserialize, Serialize};
const TABLE_NAME: &str = "messages";
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageCache {
pub id: String,
pub guild_id: i64,
pub channel_id: i64,
pub author_id: i64,
pub created: i64,
pub model: String,
pub request: String,
pub response: String,
pub request_tags: Vec<String>,
pub response_tags: Vec<String>,
}
impl MessageCache {
pub async fn insert(&self) -> Result<()> {
let pool = crate::data::pool();
sqlx::query(&format!(
"INSERT INTO {} (
id,
guild_id,
channel_id,
author_id,
created,
model,
request,
response,
request_tags,
response_tags
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
)",
TABLE_NAME
))
.bind(&self.id)
.bind(self.guild_id)
.bind(self.channel_id)
.bind(self.author_id)
.bind(self.created)
.bind(&self.model)
.bind(&self.request)
.bind(&self.response)
.bind(&self.request_tags)
.bind(&self.response_tags)
.execute(pool)
.await?;
Ok(())
}
pub async fn find(
guild_id: i64,
channel_id: i64,
author_id: i64,
limit: i64,
) -> Result<Vec<MessageCache>> {
let pool = crate::data::pool();
let messages = sqlx::query_as::<_, MessageCache>(&format!(
"SELECT * FROM {} WHERE guild_id = $1 AND channel_id = $2 AND author_id = $3 ORDER BY created ASC LIMIT $4",
TABLE_NAME
))
.bind(guild_id)
.bind(channel_id)
.bind(author_id)
.bind(limit)
.fetch_all(pool)
.await?;
Ok(messages)
}
}

View File

@@ -0,0 +1,137 @@
use crate::error::Result;
use chrono::{DateTime, Utc};
use redis::{Client as RedisClient, RedisResult, aio::MultiplexedConnection as RedisConnection};
use sqlx::{Pool, Postgres, postgres::PgPoolOptions};
use std::{fmt, fmt::Display, sync::OnceLock, time::Duration};
pub mod condition;
pub mod events;
mod executable_query;
pub mod guilds;
pub mod insert;
pub mod messages;
pub mod query;
pub mod update;
use crate::config::EnvironmentConfiguration;
pub use executable_query::ExecutableQuery;
static POOL: OnceLock<Pool<Postgres>> = OnceLock::new();
static REDIS: OnceLock<RedisClient> = OnceLock::new();
pub async fn initialize(config: &EnvironmentConfiguration) -> Result<()> {
log::info!("Initializing database...");
// Setup Postgres pool connection
let pool = PgPoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(30))
.connect(&format!(
"postgres://{}:{}@{}:{}/{}",
config.postgres_user,
config.postgres_password,
config.postgres_host,
config.postgres_port,
config.postgres_database
))
.await?;
match POOL.set(pool) {
Ok(_) => {}
Err(_) => {
log::warn!("Database pool already initialized");
}
}
// Setup Redis connection
let redis = {
let host = std::env::var("VALKEY_HOST").unwrap_or("localhost".to_string());
let port = std::env::var("VALKEY_PORT").unwrap_or("6379".to_string());
let url = format!("redis://{}:{}", host, port);
RedisClient::open(url).expect("Failed to create valkey client")
};
match REDIS.set(redis) {
Ok(_) => {}
Err(_) => {
log::warn!("Valkey client already initialized");
}
}
// Run migrations
match run_migrations().await {
Ok(_) => log::debug!("Successfully ran migrations"),
Err(e) => log::error!("Failed to run migrations: {}", e),
}
log::info!("Database initialized");
Ok(())
}
pub fn pool() -> &'static Pool<Postgres> {
POOL.get().unwrap()
}
fn redis() -> &'static RedisClient {
REDIS.get().unwrap()
}
pub fn redis_connection() -> RedisResult<redis::Connection> {
let conn = redis().get_connection()?;
Ok(conn)
}
pub async fn redis_async_connection() -> RedisResult<RedisConnection> {
let conn = redis().get_multiplexed_async_connection().await?;
Ok(conn)
}
async fn run_migrations() -> Result<()> {
log::debug!("Running migrations");
let pool = pool();
sqlx::migrate!("../../migrations").run(pool).await?;
Ok(())
}
#[derive(Debug, Clone)]
pub enum Value {
Int(i32),
OptionalInt(Option<i32>),
BigInt(i64),
OptionalBigInt(Option<i64>),
Float(f32),
OptionalFloat(Option<f32>),
Double(f64),
OptionalDouble(Option<f64>),
Bool(bool),
OptionalBool(Option<bool>),
Text(String),
OptionalText(Option<String>),
DateTime(DateTime<Utc>),
OptionalDateTime(Option<DateTime<Utc>>),
}
impl Display for Value {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Value::Int(n) => write!(f, "{}", n),
Value::OptionalInt(Some(n)) => write!(f, "{}", n),
Value::OptionalInt(None) => write!(f, "NULL"),
Value::BigInt(n) => write!(f, "{}", n),
Value::OptionalBigInt(Some(n)) => write!(f, "{}", n),
Value::OptionalBigInt(None) => write!(f, "NULL"),
Value::Float(n) => write!(f, "{}", n),
Value::OptionalFloat(Some(n)) => write!(f, "{}", n),
Value::OptionalFloat(None) => write!(f, "NULL"),
Value::Double(n) => write!(f, "{}", n),
Value::OptionalDouble(Some(n)) => write!(f, "{}", n),
Value::OptionalDouble(None) => write!(f, "NULL"),
Value::Bool(n) => write!(f, "{}", n),
Value::OptionalBool(Some(n)) => write!(f, "{}", n),
Value::OptionalBool(None) => write!(f, "NULL"),
Value::Text(s) => write!(f, "'{}'", s.replace("'", "''")),
Value::OptionalText(Some(s)) => write!(f, "'{}'", s.replace("'", "''")),
Value::OptionalText(None) => write!(f, "NULL"),
Value::DateTime(n) => write!(f, "{}", n),
Value::OptionalDateTime(Some(n)) => write!(f, "{}", n),
Value::OptionalDateTime(None) => write!(f, "NULL"),
}
}
}

View File

@@ -0,0 +1,110 @@
use crate::data::{Value, condition::Condition, executable_query::ExecutableQuery};
pub struct QueryBuilder<'a> {
table: &'a str,
columns: Vec<&'a str>,
distinct_on: Option<Vec<String>>,
condition: Option<Condition>,
order_by: Vec<String>,
limit: Option<usize>,
offset: Option<usize>,
}
impl<'a> QueryBuilder<'a> {
pub fn new(table: &'a str) -> Self {
QueryBuilder {
table,
columns: Vec::new(),
distinct_on: None,
condition: None,
order_by: Vec::new(),
limit: None,
offset: None,
}
}
pub fn select(mut self, columns: &[&'a str]) -> Self {
self.columns.extend(columns);
self
}
pub fn distinct_on(mut self, columns: &[&str]) -> Self {
self.distinct_on = Some(columns.iter().map(|s| s.to_string()).collect());
self
}
pub fn where_condition(mut self, condition: Condition) -> Self {
self.condition = Some(condition);
self
}
pub fn order_by(mut self, column: &str, direction: Option<OrderDirection>) -> Self {
match direction {
Some(order) => self
.order_by
.push(format!("{} {}", column, order.to_string())),
None => self.order_by.push(column.to_string()),
}
self
}
pub fn limit(mut self, limit: usize) -> Self {
self.limit = Some(limit);
self
}
}
pub enum OrderDirection {
Asc,
Desc,
}
impl std::fmt::Display for OrderDirection {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let direction_str = match self {
OrderDirection::Asc => "ASC",
OrderDirection::Desc => "DESC",
};
write!(f, "{}", direction_str)
}
}
impl<'a> ExecutableQuery for QueryBuilder<'a> {
fn build(&self) -> (String, Vec<Value>) {
let columns = if self.columns.is_empty() {
"*".to_string()
} else {
self.columns.join(",")
};
let mut query = if let Some(distinct_columns) = &self.distinct_on {
let distinct_on_clause = distinct_columns.join(",");
format!("SELECT DISTINCT ON ({}) {}", distinct_on_clause, columns)
} else {
format!("SELECT {}", columns)
};
query.push_str(format!(" FROM {}", self.table).as_str());
let mut values: Vec<Value> = Vec::new();
if let Some(condition) = &self.condition {
let where_condition = condition.to_sql(&mut 0);
query.push_str(&format!(" WHERE {}", where_condition.0));
values = where_condition.1;
}
if !self.order_by.is_empty() {
query.push_str(&format!(" ORDER BY {}", self.order_by.join(" ORDER BY")));
}
if let Some(limit) = self.limit {
query.push_str(&format!(" LIMIT {}", limit));
}
if let Some(offset) = self.offset {
query.push_str(&format!(" OFFSET {}", offset));
}
(query, values)
}
}

View File

@@ -0,0 +1,60 @@
use crate::data::{Value, condition::Condition, executable_query::ExecutableQuery};
pub struct UpdateBuilder {
table: String,
columns: Vec<String>,
values: Vec<Value>,
condition: Option<Condition>,
}
impl UpdateBuilder {
pub fn new(table: &str) -> Self {
Self {
table: table.to_string(),
columns: Vec::new(),
values: Vec::new(),
condition: None,
}
}
pub fn column(mut self, column: &str, value: Value) -> Self {
self.columns.push(column.to_string());
self.values.push(value);
self
}
pub fn where_condition(mut self, condition: Condition) -> Self {
self.condition = Some(condition);
self
}
}
impl ExecutableQuery for UpdateBuilder {
fn build(&self) -> (String, Vec<Value>) {
if self.columns.is_empty() {
panic!("Cannot build update query without columns to set");
}
// Generate the SET clause
let set_clause = self
.columns
.iter()
.enumerate()
.map(|(i, col)| format!("{} = ${}", col, i + 1))
.collect::<Vec<_>>()
.join(", ");
let mut query = format!("UPDATE {} SET {}", self.table, set_clause);
let mut counter = self.values.len();
let mut values: Vec<Value> = self.values.clone();
// Build where clause
if let Some(condition) = &self.condition {
let where_condition = condition.to_sql(&mut counter);
query.push_str(&format!(" WHERE {}", where_condition.0));
values.extend(where_condition.1);
}
(query, values)
}
}

View File

@@ -0,0 +1,108 @@
use serde::{Deserialize, Serialize};
use std::fmt;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Deserialize, Serialize)]
pub struct Error {
pub status: u16,
pub details: String,
}
impl Error {
pub fn new(status: u16, details: String) -> Self {
Self { status, details }
}
pub fn not_found(details: String) -> Self {
Self::new(404, details)
}
pub fn internal_server_error(details: String) -> Self {
Self::new(500, details)
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(self.details.as_str())
}
}
impl std::error::Error for Error {
fn description(&self) -> &str {
&self.details
}
}
impl From<std::io::Error> for Error {
fn from(error: std::io::Error) -> Self {
Self::new(500, format!("IO error: {}", error))
}
}
impl From<std::string::FromUtf8Error> for Error {
fn from(error: std::string::FromUtf8Error) -> Self {
Self::new(500, format!("UTF-8 error: {}", error))
}
}
impl From<std::env::VarError> for Error {
fn from(error: std::env::VarError) -> Self {
Self::new(500, format!("Environment variable error: {}", error))
}
}
impl From<sqlx::Error> for Error {
fn from(error: sqlx::Error) -> Self {
match error {
sqlx::Error::RowNotFound => Error::new(404, "Not found".to_string()),
sqlx::Error::ColumnIndexOutOfBounds { .. } => Error::new(422, error.to_string()),
sqlx::Error::ColumnNotFound { .. } => Error::new(422, error.to_string()),
sqlx::Error::ColumnDecode { .. } => Error::new(422, error.to_string()),
sqlx::Error::Decode(_) => Error::new(422, error.to_string()),
sqlx::Error::PoolTimedOut => Error::new(503, error.to_string()),
sqlx::Error::PoolClosed => Error::new(503, error.to_string()),
sqlx::Error::Database(err) => {
if let Some(code) = err.code() {
match code.trim() {
"23505" => return Error::new(409, err.to_string()),
_ => (),
}
}
Error::new(500, err.to_string())
}
_ => Error::new(500, error.to_string()),
}
}
}
impl From<sqlx::migrate::MigrateError> for Error {
fn from(error: sqlx::migrate::MigrateError) -> Self {
Error::new(500, error.to_string())
}
}
impl From<redis::RedisError> for Error {
fn from(error: redis::RedisError) -> Self {
Self::new(500, format!("Redis error: {}", error))
}
}
impl From<reqwest::Error> for Error {
fn from(error: reqwest::Error) -> Self {
Self::new(500, format!("HTTP client error: {}", error))
}
}
impl From<serde_json::Error> for Error {
fn from(error: serde_json::Error) -> Self {
Self::new(500, format!("JSON error: {}", error))
}
}
impl From<uuid::Error> for Error {
fn from(error: uuid::Error) -> Self {
Self::new(500, format!("UUID error: {}", error))
}
}

View File

@@ -0,0 +1,4 @@
pub mod config;
pub mod data;
pub mod error;
pub mod utils;

View File

@@ -0,0 +1,2 @@
pub mod text_utils;
pub use text_utils::*;

View File

@@ -0,0 +1,62 @@
pub fn a_or_an(word: &str) -> &'static str {
let vowels = ['a', 'e', 'i', 'o', 'u'];
let lowercase_word = word.to_lowercase();
// Special cases where the article should be "a"
let special_cases_a = vec!["one"];
if special_cases_a.contains(&lowercase_word.as_str()) {
return "a";
}
// Special cases where the article should be "an"
let special_cases_an = vec!["hour"];
if special_cases_an.contains(&lowercase_word.as_str()) {
return "an";
}
let first_char = lowercase_word.chars().next();
match first_char {
// If the first character is a vowel, return "an"
Some(c) if vowels.contains(&c) => "an",
// Otherwise, return "a"
_ => "a",
}
}
pub fn number_to_words(n: i32) -> String {
let ones = [
"", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
];
let teens = [
"ten",
"eleven",
"twelve",
"thirteen",
"fourteen",
"fifteen",
"sixteen",
"seventeen",
"eighteen",
"nineteen",
];
let tens = [
"", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety",
];
if n < 10 {
ones[n as usize].to_string()
} else if n < 20 {
teens[(n - 10) as usize].to_string()
} else if n < 100 {
let ten_part = tens[(n / 10) as usize];
let one_part = ones[(n % 10) as usize];
if n % 10 == 0 {
ten_part.to_string() // e.g., 20 → "twenty"
} else {
format!("{}-{}", ten_part, one_part) // e.g., 42 → "forty-two"
}
} else {
"Number out of range".to_string() // Handle numbers >= 100 (or extend the logic)
}
}