Skip to content
This repository was archived by the owner on Oct 12, 2026. It is now read-only.

Commit dec3706

Browse files
committed
feat(core): QuerySet support
1 parent ac2927e commit dec3706

8 files changed

Lines changed: 224 additions & 60 deletions

File tree

‎src/domain/src/common/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pub mod query_set;

‎src/domain/src/common/query_set.rs‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
use std::ops::Deref;
2+
3+
#[derive(Debug, Default, Clone)]
4+
pub struct QuerySetMeta {
5+
pub page: u32,
6+
pub per_page: u32,
7+
pub total_pages: u32,
8+
}
9+
10+
/// Represents the result from a database query along with metadata
11+
#[derive(Debug, Default, Clone)]
12+
pub struct QuerySet<T: Clone> {
13+
records: Vec<T>,
14+
count: u32,
15+
metadata: QuerySetMeta,
16+
}
17+
18+
impl<T: Clone> Deref for QuerySet<T> {
19+
type Target = Vec<T>;
20+
21+
fn deref(&self) -> &Self::Target {
22+
&self.records
23+
}
24+
}
25+
26+
impl<T: Clone, E: Clone> QuerySet<Result<T, E>> {
27+
pub fn transmute(self) -> Result<QuerySet<T>, E> {
28+
let records = self.records.into_iter().collect::<Result<Vec<T>, E>>()?;
29+
Ok(QuerySet {
30+
records,
31+
count: self.count,
32+
metadata: self.metadata,
33+
})
34+
}
35+
}
36+
37+
impl<T: Clone> QuerySet<T> {
38+
/// Creates a new instance of `QuerySet` with it's records and `count`.
39+
///
40+
/// The `count` value is used to determine the amount of records found
41+
/// in the query, that may or may not be the total amount of records in
42+
/// this `QuerySet` instance.
43+
pub fn new(records: Vec<T>, metadata: QuerySetMeta) -> Self {
44+
Self {
45+
count: records.len() as u32,
46+
records,
47+
metadata,
48+
}
49+
}
50+
51+
/// Subset of records matched by the database query and also included as
52+
/// part of the pagination process
53+
#[inline]
54+
pub fn records(&self) -> Vec<T> {
55+
self.records.to_owned()
56+
}
57+
58+
/// Total amount of records in the database matched by the query
59+
#[inline]
60+
pub fn count(&self) -> u32 {
61+
self.count.to_owned()
62+
}
63+
64+
/// Current page of the `QuerySet`
65+
#[inline]
66+
pub fn page(&self) -> u32 {
67+
self.metadata.page
68+
}
69+
70+
/// Amount of records per page
71+
#[inline]
72+
pub fn per_page(&self) -> u32 {
73+
self.metadata.per_page
74+
}
75+
76+
/// Total amount of pages available
77+
#[inline]
78+
pub fn total_pages(&self) -> u32 {
79+
self.metadata.total_pages
80+
}
81+
82+
/// Creates an empty `QuerySet`. This is useful for results where `T`
83+
/// doesn't implement `Default`.
84+
pub fn empty() -> Self {
85+
Self {
86+
records: Vec::new(),
87+
count: 0,
88+
metadata: QuerySetMeta::default(),
89+
}
90+
}
91+
92+
#[inline]
93+
pub fn is_empty(&self) -> bool {
94+
self.count == 0
95+
}
96+
97+
/// Maps records stored internally
98+
pub fn inner_map<B, F>(self, f: F) -> QuerySet<B>
99+
where
100+
B: Clone,
101+
F: FnMut(T) -> B,
102+
{
103+
QuerySet {
104+
records: self.records.into_iter().map(f).collect::<Vec<B>>(),
105+
count: self.count,
106+
metadata: self.metadata,
107+
}
108+
}
109+
110+
/// Maps records stored internally
111+
pub fn inner_map_while<B, F>(self, f: F) -> QuerySet<B>
112+
where
113+
B: Clone,
114+
F: FnMut(T) -> Option<B>,
115+
{
116+
QuerySet {
117+
records: self.records.into_iter().map_while(f).collect::<Vec<B>>(),
118+
count: self.count,
119+
metadata: self.metadata,
120+
}
121+
}
122+
}

‎src/domain/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
pub mod common;
12
pub mod mtg;

‎src/domain/src/mtg/service.rs‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use anyhow::Result;
44
use bytes::Bytes;
55
use uuid::Uuid;
66

7+
use crate::common::query_set::QuerySet;
78
use crate::mtg::model::{Card, Deck};
89

910
#[derive(Clone, Debug, Default)]
@@ -25,8 +26,8 @@ pub struct FindImageFilter {
2526

2627
#[allow(async_fn_in_trait)]
2728
pub trait MtgDataAccessLayer: Clone + Send + Sync {
28-
async fn find_cards(&self, filter: FindCardsFilter) -> Result<Vec<Card>>;
29-
async fn find_decks(&self, filter: FindDecksFilter) -> Result<Vec<Deck>>;
29+
async fn find_cards(&self, filter: FindCardsFilter) -> Result<QuerySet<Card>>;
30+
async fn find_decks(&self, filter: FindDecksFilter) -> Result<QuerySet<Deck>>;
3031
async fn find_image(&self, filter: FindImageFilter) -> Result<Bytes>;
3132
}
3233

@@ -39,12 +40,12 @@ impl<T: MtgDataAccessLayer> MtgService<T> {
3940
MtgService { repo }
4041
}
4142

42-
pub async fn get_cards(&self, filter: FindCardsFilter) -> Result<Vec<Card>> {
43+
pub async fn get_cards(&self, filter: FindCardsFilter) -> Result<QuerySet<Card>> {
4344
let cards = self.repo.find_cards(filter).await?;
4445
Ok(cards)
4546
}
4647

47-
pub async fn get_decks(&self, filter: FindDecksFilter) -> Result<Vec<Deck>> {
48+
pub async fn get_decks(&self, filter: FindDecksFilter) -> Result<QuerySet<Deck>> {
4849
let decks = self.repo.find_decks(filter).await?;
4950
Ok(decks)
5051
}

‎src/server/src/modules/mtg/repository.rs‎

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,25 +7,60 @@ use reqwest::{StatusCode, Url};
77
use sqlx::sqlite::SqliteRow;
88
use sqlx::{QueryBuilder, Row, SqlitePool};
99

10+
use deckmaster_domain::common::query_set::{QuerySet, QuerySetMeta};
1011
use deckmaster_domain::mtg::model::{Card, Deck};
1112
use deckmaster_domain::mtg::service::{FindCardsFilter, FindDecksFilter};
1213
use deckmaster_domain::mtg::service::{FindImageFilter, MtgDataAccessLayer};
1314

15+
#[derive(Clone)]
16+
struct Counters {
17+
cards: u32,
18+
decks: u32,
19+
}
20+
1421
#[derive(Clone)]
1522
pub struct MtgRepository {
1623
db: Arc<SqlitePool>,
1724
storage_url: Url,
25+
counters: Counters,
1826
}
1927

2028
impl MtgRepository {
2129
pub async fn new(db: Arc<SqlitePool>, storage_url: Url) -> Result<Self> {
22-
Ok(MtgRepository { db, storage_url })
30+
let mut repo = MtgRepository {
31+
db,
32+
storage_url,
33+
counters: Counters { cards: 0, decks: 0 },
34+
};
35+
36+
repo.count_tables().await?;
37+
38+
Ok(repo)
39+
}
40+
41+
// The `mtg` table is immutable, so we can cache the count of rows
42+
// to avoid running a COUNT(*) query every time we need it.
43+
async fn count_tables(&mut self) -> Result<()> {
44+
let mut conn = self.db.acquire().await?.detach();
45+
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM cards")
46+
.fetch_one(&mut conn)
47+
.await?;
48+
self.counters.cards = row.0 as u32;
49+
50+
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM decks")
51+
.fetch_one(&mut conn)
52+
.await?;
53+
self.counters.decks = row.0 as u32;
54+
55+
Ok(())
2356
}
2457
}
2558

2659
impl MtgDataAccessLayer for MtgRepository {
27-
async fn find_cards(&self, filter: FindCardsFilter) -> Result<Vec<Card>> {
60+
async fn find_cards(&self, filter: FindCardsFilter) -> Result<QuerySet<Card>> {
2861
let mut conn = self.db.acquire().await?.detach();
62+
let page = filter.page.unwrap_or(1);
63+
let offset = page * 20;
2964
let mut query = QueryBuilder::new(
3065
r#"SELECT
3166
id,
@@ -47,7 +82,6 @@ impl MtgDataAccessLayer for MtgRepository {
4782
}
4883

4984
query.push(" FROM cards");
50-
let offset = (filter.page.unwrap_or(1).saturating_sub(1)) * 20;
5185
query.push(" LIMIT 20 OFFSET ");
5286
query.push_bind(offset as i64);
5387

@@ -86,21 +120,33 @@ impl MtgDataAccessLayer for MtgRepository {
86120
});
87121
}
88122

89-
Ok(cards)
123+
Ok(QuerySet::new(
124+
cards,
125+
QuerySetMeta {
126+
page,
127+
per_page: 20,
128+
total_pages: self.counters.cards.div_ceil(20),
129+
},
130+
))
90131
}
91132

92-
async fn find_decks(&self, _: FindDecksFilter) -> Result<Vec<Deck>> {
133+
async fn find_decks(&self, filter: FindDecksFilter) -> Result<QuerySet<Deck>> {
93134
let mut conn = self.db.acquire().await?.detach();
94-
let rows: Vec<SqliteRow> = sqlx::query(
135+
let page = filter.page.unwrap_or(1);
136+
let offset = page * 20;
137+
let mut query = QueryBuilder::new(
95138
r#"SELECT
96139
id,
97140
name,
98141
code,
99142
release
100143
FROM decks"#,
101-
)
102-
.fetch_all(&mut conn)
103-
.await?;
144+
);
145+
146+
query.push(" LIMIT 20 OFFSET ");
147+
query.push_bind(offset as i64);
148+
149+
let rows: Vec<SqliteRow> = query.build().fetch_all(&mut conn).await?;
104150
let mut decks = Vec::new();
105151

106152
for row in rows {
@@ -119,7 +165,14 @@ impl MtgDataAccessLayer for MtgRepository {
119165
});
120166
}
121167

122-
Ok(decks)
168+
Ok(QuerySet::new(
169+
decks,
170+
QuerySetMeta {
171+
page,
172+
per_page: 20,
173+
total_pages: self.counters.decks.div_ceil(20),
174+
},
175+
))
123176
}
124177

125178
async fn find_image(&self, filter: FindImageFilter) -> Result<Bytes> {
@@ -128,7 +181,6 @@ impl MtgDataAccessLayer for MtgRepository {
128181
"magic-the-gathering/images/cards/{}/{}.jpg",
129182
deck_id, card_id
130183
))?;
131-
println!("{}", image_url);
132184
let response = reqwest::get(image_url).await?;
133185

134186
if response.status() == StatusCode::OK {

‎src/server/src/router/api/v0/mod.rs‎

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
mod mtg;
22

33
use axum::Router;
4-
54
use serde::{Deserialize, Serialize};
65
use utoipa::{IntoParams, OpenApi, ToSchema, schema};
76

7+
use deckmaster_domain::common::query_set::QuerySet;
8+
89
use crate::router::api::v0::mtg::{Card, Deck};
910

1011
#[derive(Debug, Serialize, Deserialize, ToSchema)]
@@ -20,32 +21,36 @@ pub struct ApiError {
2021
#[derive(Debug, Serialize, Deserialize, ToSchema)]
2122
#[serde(rename_all = "camelCase")]
2223
pub struct PaginatedResponse<T> {
23-
pub total: u64,
2424
pub page: u32,
25-
pub limit: u32,
25+
pub total: u32,
2626
pub total_pages: u32,
2727
pub data: Vec<T>,
2828
}
2929

30+
impl<T: Clone> From<QuerySet<T>> for PaginatedResponse<T> {
31+
fn from(qs: QuerySet<T>) -> Self {
32+
let data = qs.records().into_iter().collect();
33+
34+
PaginatedResponse {
35+
total: qs.count(),
36+
page: qs.page(),
37+
total_pages: qs.total_pages(),
38+
data,
39+
}
40+
}
41+
}
42+
3043
#[derive(Default, Debug, Deserialize, IntoParams)]
3144
pub struct PaginationParams {
3245
/// Page number (starts from 1)
3346
#[param(example = 1, minimum = 1)]
3447
pub(self) page: Option<u32>,
35-
/// Number of items per page
36-
#[param(example = 20, minimum = 1, maximum = 100)]
37-
pub(self) limit: Option<u32>,
3848
}
3949

4050
impl PaginationParams {
4151
#[inline]
4252
pub fn page(&self) -> u32 {
43-
self.page.unwrap_or(1).min(1)
44-
}
45-
46-
#[inline]
47-
pub fn limit(&self) -> u32 {
48-
self.limit.unwrap_or(20).max(100)
53+
self.page.unwrap_or(1)
4954
}
5055
}
5156

‎src/server/src/router/api/v0/mtg/retrieve_cards.rs‎

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,7 @@ pub async fn handler(
2424
Query(pagination): Query<PaginationParams>,
2525
) -> Result<Json<PaginatedResponse<Card>>, StatusCode> {
2626
let page = pagination.page();
27-
let limit = pagination.limit();
28-
let cards: Vec<Card> = services
27+
let cards_qs = services
2928
.mtg
3029
.get_cards(FindCardsFilter {
3130
deck_id: None,
@@ -35,19 +34,10 @@ pub async fn handler(
3534
.map_err(|err| {
3635
tracing::error!("Failed to retrieve cards: {:?}", err);
3736
StatusCode::INTERNAL_SERVER_ERROR
38-
})?
39-
.into_iter()
40-
.map(Card::from)
41-
.collect();
42-
let total = cards.len() as u64;
43-
let total_pages = (total as f64 / limit as f64).ceil() as u32;
44-
let paginated_response = PaginatedResponse {
45-
data: cards,
46-
total,
47-
page,
48-
limit,
49-
total_pages,
50-
};
37+
})?;
38+
39+
let cards_qs = cards_qs.inner_map(Card::from);
40+
let paginated_response = PaginatedResponse::from(cards_qs);
5141

5242
Ok(Json(paginated_response))
5343
}

0 commit comments

Comments
 (0)