From fc1a61ff32112a6f7e76ee8da2896f3184bd6a34 Mon Sep 17 00:00:00 2001 From: Dylan Knutson Date: Wed, 27 Aug 2025 23:38:10 +0000 Subject: [PATCH] feat: implement type-safe ID system and enum-based listing architecture - Add UserId and ListingId newtype wrappers with SQLx integration - Prevents mixing up different ID types at compile time - Custom Encode/Decode traits for seamless database operations - Zero-cost abstractions with full type safety - Refactor Listing to enum-based architecture - BasicAuction: Traditional time-based auction with bidding - MultiSlotAuction: Auction with multiple winners/slots - FixedPriceListing: Fixed price sale with no bidding - BlindAuction: Blind auction where seller chooses winner - ListingBase: Common fields shared by all listing types - ListingRow: Flat database row structure for SQLx compatibility - Update ListingType enum variants - Standard -> BasicAuction - MultiSlot -> MultiSlotAuction - FixedPrice -> FixedPriceListing - Blind -> BlindAuction - Add comprehensive conversion traits and helper methods - Maintain full database compatibility with existing schema - Remove unused helper methods and make base() private for cleaner API --- src/db/listing_id.rs | 71 ++++++ src/db/mod.rs | 4 + src/db/models/listing.rs | 422 ++++++++++++++++++++++++++++++++-- src/db/models/listing_type.rs | 10 +- src/db/user_id.rs | 71 ++++++ 5 files changed, 549 insertions(+), 29 deletions(-) create mode 100644 src/db/listing_id.rs create mode 100644 src/db/user_id.rs diff --git a/src/db/listing_id.rs b/src/db/listing_id.rs new file mode 100644 index 0000000..4df3bca --- /dev/null +++ b/src/db/listing_id.rs @@ -0,0 +1,71 @@ +//! ListingId newtype for type-safe listing identification +//! +//! This newtype prevents accidentally mixing up listing IDs with other ID types +//! while maintaining compatibility with the database layer through SQLx traits. + +use serde::{Deserialize, Serialize}; +use sqlx::{ + encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type, +}; +use std::fmt; + +/// Type-safe wrapper for listing IDs +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct ListingId(i64); + +impl ListingId { + /// Create a new ListingId from an i64 + pub fn new(id: i64) -> Self { + Self(id) + } + + /// Get the inner i64 value + pub fn get(&self) -> i64 { + self.0 + } +} + +impl From for ListingId { + fn from(id: i64) -> Self { + Self(id) + } +} + +impl From for i64 { + fn from(listing_id: ListingId) -> Self { + listing_id.0 + } +} + +impl fmt::Display for ListingId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +// SQLx implementations for database compatibility +impl Type for ListingId { + fn type_info() -> SqliteTypeInfo { + >::type_info() + } + + fn compatible(ty: &SqliteTypeInfo) -> bool { + >::compatible(ty) + } +} + +impl<'q> Encode<'q, Sqlite> for ListingId { + fn encode_by_ref( + &self, + args: &mut Vec>, + ) -> Result { + >::encode_by_ref(&self.0, args) + } +} + +impl<'r> Decode<'r, Sqlite> for ListingId { + fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result { + let id = >::decode(value)?; + Ok(Self(id)) + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 089e9d4..9cdfe2f 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,10 +1,14 @@ pub mod currency_type; +pub mod listing_id; pub mod models; pub mod money_amount; +pub mod user_id; pub mod users; // Re-export all models for easy access pub use currency_type::*; +pub use listing_id::*; pub use models::*; pub use money_amount::*; +pub use user_id::*; pub use users::UserRepository; diff --git a/src/db/models/listing.rs b/src/db/models/listing.rs index b946342..956d5a0 100644 --- a/src/db/models/listing.rs +++ b/src/db/models/listing.rs @@ -1,48 +1,422 @@ +//! Listing models with type-safe enum-based structure +//! +//! This module provides a type-safe representation of different listing types: +//! - `BasicAuction`: Traditional time-based auction with bidding +//! - `MultiSlotAuction`: Auction with multiple winners/slots available +//! - `FixedPriceListing`: Fixed price sale with no bidding +//! - `BlindAuction`: Blind auction where seller chooses winner +//! +//! The main `Listing` enum ensures that only valid fields are accessible for each type. +//! Database mapping is handled through `ListingRow` with conversion traits. + use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; use super::listing_type::ListingType; -use crate::db::money_amount::MoneyAmount; +use crate::db::{listing_id::ListingId, money_amount::MoneyAmount, user_id::UserId}; -/// Main listing/auction entity -#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] -pub struct Listing { - pub id: i64, - pub seller_id: i64, +/// Common fields shared by all listing types +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListingBase { + pub id: ListingId, + pub seller_id: UserId, + pub title: String, + pub description: Option, + pub starts_at: DateTime, + pub ends_at: DateTime, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Traditional time-based auction with bidding +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BasicAuction { + #[serde(flatten)] + pub base: ListingBase, + pub starting_bid: Option, + pub buy_now_price: Option, + pub min_increment: MoneyAmount, + pub anti_snipe_minutes: i32, +} + +/// Auction with multiple winners/slots available +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MultiSlotAuction { + #[serde(flatten)] + pub base: ListingBase, + pub starting_bid: Option, + pub buy_now_price: Option, + pub min_increment: MoneyAmount, + pub slots_available: i32, + pub anti_snipe_minutes: i32, +} + +/// Fixed price sale with no bidding +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FixedPriceListing { + #[serde(flatten)] + pub base: ListingBase, + pub buy_now_price: MoneyAmount, + pub slots_available: i32, +} + +/// Blind auction where seller chooses winner +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlindAuction { + #[serde(flatten)] + pub base: ListingBase, +} + +/// Main listing/auction entity enum +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "listing_type")] +pub enum Listing { + #[serde(rename = "basic_auction")] + BasicAuction(BasicAuction), + #[serde(rename = "multi_slot_auction")] + MultiSlotAuction(MultiSlotAuction), + #[serde(rename = "fixed_price_listing")] + FixedPriceListing(FixedPriceListing), + #[serde(rename = "blind_auction")] + BlindAuction(BlindAuction), +} + +/// New listing data for insertion +#[derive(Debug, Clone)] +pub enum NewListing { + BasicAuction { + seller_id: UserId, + title: String, + description: Option, + starting_bid: Option, + buy_now_price: Option, + min_increment: MoneyAmount, + starts_at: DateTime, + ends_at: DateTime, + anti_snipe_minutes: i32, + }, + MultiSlotAuction { + seller_id: UserId, + title: String, + description: Option, + starting_bid: Option, + buy_now_price: Option, + min_increment: MoneyAmount, + slots_available: i32, + starts_at: DateTime, + ends_at: DateTime, + anti_snipe_minutes: i32, + }, + FixedPriceListing { + seller_id: UserId, + title: String, + description: Option, + buy_now_price: MoneyAmount, + slots_available: i32, + starts_at: DateTime, + ends_at: DateTime, + }, + BlindAuction { + seller_id: UserId, + title: String, + description: Option, + starts_at: DateTime, + ends_at: DateTime, + }, +} + +impl Listing { + /// Get the base information common to all listing types + fn base(&self) -> &ListingBase { + match self { + Listing::BasicAuction(auction) => &auction.base, + Listing::MultiSlotAuction(auction) => &auction.base, + Listing::FixedPriceListing(listing) => &listing.base, + Listing::BlindAuction(auction) => &auction.base, + } + } + + /// Get the listing type as an enum value + pub fn listing_type(&self) -> ListingType { + match self { + Listing::BasicAuction(_) => ListingType::BasicAuction, + Listing::MultiSlotAuction(_) => ListingType::MultiSlotAuction, + Listing::FixedPriceListing(_) => ListingType::FixedPriceListing, + Listing::BlindAuction(_) => ListingType::BlindAuction, + } + } + + /// Get the seller ID + pub fn seller_id(&self) -> UserId { + self.base().seller_id + } + + /// Get the listing title + pub fn title(&self) -> &str { + &self.base().title + } + + /// Get the listing description + pub fn description(&self) -> Option<&str> { + self.base().description.as_deref() + } +} + +impl NewListing { + /// Get the listing type for this new listing + pub fn listing_type(&self) -> ListingType { + match self { + NewListing::BasicAuction { .. } => ListingType::BasicAuction, + NewListing::MultiSlotAuction { .. } => ListingType::MultiSlotAuction, + NewListing::FixedPriceListing { .. } => ListingType::FixedPriceListing, + NewListing::BlindAuction { .. } => ListingType::BlindAuction, + } + } + + /// Get the seller ID for this new listing + pub fn seller_id(&self) -> UserId { + match self { + NewListing::BasicAuction { seller_id, .. } => *seller_id, + NewListing::MultiSlotAuction { seller_id, .. } => *seller_id, + NewListing::FixedPriceListing { seller_id, .. } => *seller_id, + NewListing::BlindAuction { seller_id, .. } => *seller_id, + } + } + + /// Get the title for this new listing + pub fn title(&self) -> &str { + match self { + NewListing::BasicAuction { title, .. } => title, + NewListing::MultiSlotAuction { title, .. } => title, + NewListing::FixedPriceListing { title, .. } => title, + NewListing::BlindAuction { title, .. } => title, + } + } + + /// Create a new basic auction listing + pub fn new_basic_auction( + seller_id: UserId, + title: String, + description: Option, + starting_bid: Option, + buy_now_price: Option, + min_increment: MoneyAmount, + starts_at: DateTime, + ends_at: DateTime, + anti_snipe_minutes: i32, + ) -> Self { + NewListing::BasicAuction { + seller_id, + title, + description, + starting_bid, + buy_now_price, + min_increment, + starts_at, + ends_at, + anti_snipe_minutes, + } + } + + /// Create a new multi-slot auction listing + pub fn new_multi_slot_auction( + seller_id: UserId, + title: String, + description: Option, + starting_bid: Option, + buy_now_price: Option, + min_increment: MoneyAmount, + slots_available: i32, + starts_at: DateTime, + ends_at: DateTime, + anti_snipe_minutes: i32, + ) -> Self { + NewListing::MultiSlotAuction { + seller_id, + title, + description, + starting_bid, + buy_now_price, + min_increment, + slots_available, + starts_at, + ends_at, + anti_snipe_minutes, + } + } + + /// Create a new fixed price listing + pub fn new_fixed_price_listing( + seller_id: UserId, + title: String, + description: Option, + buy_now_price: MoneyAmount, + slots_available: i32, + starts_at: DateTime, + ends_at: DateTime, + ) -> Self { + NewListing::FixedPriceListing { + seller_id, + title, + description, + buy_now_price, + slots_available, + starts_at, + ends_at, + } + } + + /// Create a new blind auction listing + pub fn new_blind_auction( + seller_id: UserId, + title: String, + description: Option, + starts_at: DateTime, + ends_at: DateTime, + ) -> Self { + NewListing::BlindAuction { + seller_id, + title, + description, + starts_at, + ends_at, + } + } +} + +/// Flat database row structure for SQLx FromRow compatibility +#[derive(Debug, Clone, FromRow)] +pub struct ListingRow { + pub id: ListingId, + pub seller_id: UserId, pub listing_type: ListingType, pub title: String, pub description: Option, - // Pricing fields + // Pricing fields - nullable for types that don't use them pub starting_bid: Option, pub buy_now_price: Option, - pub min_increment: MoneyAmount, + pub min_increment: Option, // Multi-slot/fixed price - pub slots_available: i32, + pub slots_available: Option, // Timing pub starts_at: DateTime, pub ends_at: DateTime, - pub anti_snipe_minutes: i32, + pub anti_snipe_minutes: Option, pub created_at: DateTime, pub updated_at: DateTime, } -/// New listing data for insertion -#[derive(Debug, Clone)] -pub struct NewListing { - pub seller_id: i64, - pub listing_type: ListingType, - pub title: String, - pub description: Option, - pub starting_bid: Option, - pub buy_now_price: Option, - pub min_increment: MoneyAmount, - pub slots_available: i32, - pub starts_at: DateTime, - pub ends_at: DateTime, - pub anti_snipe_minutes: i32, +impl From for Listing { + fn from(row: ListingRow) -> Self { + let base = ListingBase { + id: row.id, + seller_id: row.seller_id, + title: row.title, + description: row.description, + starts_at: row.starts_at, + ends_at: row.ends_at, + created_at: row.created_at, + updated_at: row.updated_at, + }; + + match row.listing_type { + ListingType::BasicAuction => Listing::BasicAuction(BasicAuction { + base, + starting_bid: row.starting_bid, + buy_now_price: row.buy_now_price, + min_increment: row.min_increment.unwrap_or_else(MoneyAmount::zero), + anti_snipe_minutes: row.anti_snipe_minutes.unwrap_or(5), + }), + ListingType::MultiSlotAuction => Listing::MultiSlotAuction(MultiSlotAuction { + base, + starting_bid: row.starting_bid, + buy_now_price: row.buy_now_price, + min_increment: row.min_increment.unwrap_or_else(MoneyAmount::zero), + slots_available: row.slots_available.unwrap_or(1), + anti_snipe_minutes: row.anti_snipe_minutes.unwrap_or(5), + }), + ListingType::FixedPriceListing => Listing::FixedPriceListing(FixedPriceListing { + base, + buy_now_price: row.buy_now_price.unwrap_or_else(MoneyAmount::zero), + slots_available: row.slots_available.unwrap_or(1), + }), + ListingType::BlindAuction => Listing::BlindAuction(BlindAuction { base }), + } + } +} + +impl From for ListingRow { + fn from(listing: Listing) -> Self { + match listing { + Listing::BasicAuction(auction) => ListingRow { + id: auction.base.id, + seller_id: auction.base.seller_id, + listing_type: ListingType::BasicAuction, + title: auction.base.title, + description: auction.base.description, + starting_bid: auction.starting_bid, + buy_now_price: auction.buy_now_price, + min_increment: Some(auction.min_increment), + slots_available: Some(1), + starts_at: auction.base.starts_at, + ends_at: auction.base.ends_at, + anti_snipe_minutes: Some(auction.anti_snipe_minutes), + created_at: auction.base.created_at, + updated_at: auction.base.updated_at, + }, + Listing::MultiSlotAuction(auction) => ListingRow { + id: auction.base.id, + seller_id: auction.base.seller_id, + listing_type: ListingType::MultiSlotAuction, + title: auction.base.title, + description: auction.base.description, + starting_bid: auction.starting_bid, + buy_now_price: auction.buy_now_price, + min_increment: Some(auction.min_increment), + slots_available: Some(auction.slots_available), + starts_at: auction.base.starts_at, + ends_at: auction.base.ends_at, + anti_snipe_minutes: Some(auction.anti_snipe_minutes), + created_at: auction.base.created_at, + updated_at: auction.base.updated_at, + }, + Listing::FixedPriceListing(listing) => ListingRow { + id: listing.base.id, + seller_id: listing.base.seller_id, + listing_type: ListingType::FixedPriceListing, + title: listing.base.title, + description: listing.base.description, + starting_bid: None, + buy_now_price: Some(listing.buy_now_price), + min_increment: None, + slots_available: Some(listing.slots_available), + starts_at: listing.base.starts_at, + ends_at: listing.base.ends_at, + anti_snipe_minutes: None, + created_at: listing.base.created_at, + updated_at: listing.base.updated_at, + }, + Listing::BlindAuction(auction) => ListingRow { + id: auction.base.id, + seller_id: auction.base.seller_id, + listing_type: ListingType::BlindAuction, + title: auction.base.title, + description: auction.base.description, + starting_bid: None, + buy_now_price: None, + min_increment: None, + slots_available: Some(1), + starts_at: auction.base.starts_at, + ends_at: auction.base.ends_at, + anti_snipe_minutes: None, + created_at: auction.base.created_at, + updated_at: auction.base.updated_at, + }, + } + } } diff --git a/src/db/models/listing_type.rs b/src/db/models/listing_type.rs index 192ebb2..a75a0c8 100644 --- a/src/db/models/listing_type.rs +++ b/src/db/models/listing_type.rs @@ -3,14 +3,14 @@ use serde::{Deserialize, Serialize}; /// Types of listings supported by the platform #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] #[sqlx(type_name = "TEXT")] -#[sqlx(rename_all = "lowercase")] +#[sqlx(rename_all = "snake_case")] pub enum ListingType { /// Traditional time-based auction with bidding - Standard, + BasicAuction, /// Auction with multiple winners/slots available - MultiSlot, + MultiSlotAuction, /// Fixed price sale with no bidding - FixedPrice, + FixedPriceListing, /// Blind auction where seller chooses winner - Blind, + BlindAuction, } diff --git a/src/db/user_id.rs b/src/db/user_id.rs new file mode 100644 index 0000000..81aee66 --- /dev/null +++ b/src/db/user_id.rs @@ -0,0 +1,71 @@ +//! UserId newtype for type-safe user identification +//! +//! This newtype prevents accidentally mixing up user IDs with other ID types +//! while maintaining compatibility with the database layer through SQLx traits. + +use serde::{Deserialize, Serialize}; +use sqlx::{ + encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type, +}; +use std::fmt; + +/// Type-safe wrapper for user IDs +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct UserId(i64); + +impl UserId { + /// Create a new UserId from an i64 + pub fn new(id: i64) -> Self { + Self(id) + } + + /// Get the inner i64 value + pub fn get(&self) -> i64 { + self.0 + } +} + +impl From for UserId { + fn from(id: i64) -> Self { + Self(id) + } +} + +impl From for i64 { + fn from(user_id: UserId) -> Self { + user_id.0 + } +} + +impl fmt::Display for UserId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +// SQLx implementations for database compatibility +impl Type for UserId { + fn type_info() -> SqliteTypeInfo { + >::type_info() + } + + fn compatible(ty: &SqliteTypeInfo) -> bool { + >::compatible(ty) + } +} + +impl<'q> Encode<'q, Sqlite> for UserId { + fn encode_by_ref( + &self, + args: &mut Vec>, + ) -> Result { + >::encode_by_ref(&self.0, args) + } +} + +impl<'r> Decode<'r, Sqlite> for UserId { + fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result { + let id = >::decode(value)?; + Ok(Self(id)) + } +}