1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
//! Template star model — per-user favorite marker for marketplace templates.
//!
//! Stars are tracked as rows in `template_stars` (one row per user-template
//! pair). The star **count** is always computed from this table at read time
//! — we intentionally do not denormalize into `templates.stats_json.stars`,
//! because toggling would then take a row lock on the parent template on every
//! click and make popular templates a write-contention hotspot.
//!
//! Read paths use [`TemplateStar::count_for_template`] for a single row or
//! [`TemplateStar::counts_for_templates`] for a batch (one `GROUP BY` query,
//! rather than N round-trips).
#[cfg(feature = "postgres")]
use std::collections::HashMap;
#[cfg(feature = "postgres")]
use sqlx::PgPool;
#[cfg(feature = "postgres")]
use uuid::Uuid;
#[cfg(feature = "postgres")]
pub struct TemplateStar;
#[cfg(feature = "postgres")]
impl TemplateStar {
/// Toggle a star for (template_id, user_id).
///
/// Returns `(now_starred, new_count)` where `now_starred` is `true` when
/// the row was inserted by this call and `false` when it was removed. The
/// count is authoritative — computed in the same transaction as the
/// insert/delete so it never lies about the state the client just set.
pub async fn toggle(
pool: &PgPool,
template_id: Uuid,
user_id: Uuid,
) -> sqlx::Result<(bool, i64)> {
let mut tx = pool.begin().await?;
let already: Option<(i32,)> =
sqlx::query_as("SELECT 1 FROM template_stars WHERE template_id = $1 AND user_id = $2")
.bind(template_id)
.bind(user_id)
.fetch_optional(&mut *tx)
.await?;
let now_starred = if already.is_some() {
sqlx::query("DELETE FROM template_stars WHERE template_id = $1 AND user_id = $2")
.bind(template_id)
.bind(user_id)
.execute(&mut *tx)
.await?;
false
} else {
sqlx::query(
"INSERT INTO template_stars (template_id, user_id) VALUES ($1, $2)
ON CONFLICT (template_id, user_id) DO NOTHING",
)
.bind(template_id)
.bind(user_id)
.execute(&mut *tx)
.await?;
true
};
let (count,): (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM template_stars WHERE template_id = $1")
.bind(template_id)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok((now_starred, count))
}
/// Whether `user_id` has starred `template_id`.
pub async fn is_starred_by(
pool: &PgPool,
template_id: Uuid,
user_id: Uuid,
) -> sqlx::Result<bool> {
let row: Option<(i32,)> =
sqlx::query_as("SELECT 1 FROM template_stars WHERE template_id = $1 AND user_id = $2")
.bind(template_id)
.bind(user_id)
.fetch_optional(pool)
.await?;
Ok(row.is_some())
}
/// Count stars for a single template.
pub async fn count_for_template(pool: &PgPool, template_id: Uuid) -> sqlx::Result<i64> {
let (count,): (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM template_stars WHERE template_id = $1")
.bind(template_id)
.fetch_one(pool)
.await?;
Ok(count)
}
/// Batch-count stars for a list of templates. Templates with zero stars
/// are omitted from the returned map — callers should default to 0.
pub async fn counts_for_templates(
pool: &PgPool,
template_ids: &[Uuid],
) -> sqlx::Result<HashMap<Uuid, i64>> {
if template_ids.is_empty() {
return Ok(HashMap::new());
}
let rows: Vec<(Uuid, i64)> = sqlx::query_as(
"SELECT template_id, COUNT(*)::bigint
FROM template_stars
WHERE template_id = ANY($1)
GROUP BY template_id",
)
.bind(template_ids)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().collect())
}
}