OXYGENHUB
# 8a3334b7a364 oxygenhub / crates / hub / src / db / projects.rs
9.9 KiB · Rust · last changed Write transactions begin IMMEDIATE, so a concurrent writer is waited for (busy timeout) instead of failing at once with database is locked; a test of 16 concurrent log appends fails without it 1 month ago source blame history raw
1
//! Projects, their columns and cards (hub spec §8.2).
2
3
use super::{now_ms, Pool};
4
use sqlx::FromRow;
5
6
#[derive(Clone, Debug, FromRow)]
7
pub struct Project {
8
    pub id: i64,
9
    pub owner: String,
10
    pub number: i64,
11
    pub title: String,
12
    pub description: String,
13
    pub public: bool,
14
    pub closed: bool,
15
    pub created_ms: i64,
16
    pub updated_ms: i64,
17
}
18
19
#[derive(Clone, Debug, FromRow)]
20
pub struct Column {
21
    pub id: i64,
22
    pub project_id: i64,
23
    pub name: String,
24
    pub position: i64,
25
}
26
27
#[derive(Clone, Debug, FromRow)]
28
pub struct Card {
29
    pub id: i64,
30
    pub project_id: i64,
31
    pub column_id: i64,
32
    pub position: i64,
33
    pub kind: String,
34
    pub repo_id: Option<i64>,
35
    pub number: Option<i64>,
36
    pub note: String,
37
    pub added_by: String,
38
    pub created_ms: i64,
39
}
40
41
pub const DEFAULT_COLUMNS: [&str; 3] = ["Backlog", "Doing", "Done"];
42
43
pub async fn create(pool: &Pool, owner: &str, title: &str, description: &str, public: bool) -> sqlx::Result<Project> {
44
    let mut tx = super::begin_write(pool).await?;
45
    let number: i64 =
46
        sqlx::query_scalar("SELECT COALESCE(MAX(number), 0) + 1 FROM projects WHERE owner = ?").bind(owner).fetch_one(&mut *tx).await?;
47
    let now = now_ms();
48
    let p: Project = sqlx::query_as(
49
        "INSERT INTO projects (owner, number, title, description, public, created_ms, updated_ms) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *",
50
    )
51
    .bind(owner)
52
    .bind(number)
53
    .bind(title)
54
    .bind(description)
55
    .bind(public)
56
    .bind(now)
57
    .bind(now)
58
    .fetch_one(&mut *tx)
59
    .await?;
60
    for (i, name) in DEFAULT_COLUMNS.iter().enumerate() {
61
        sqlx::query("INSERT INTO project_columns (project_id, name, position) VALUES (?, ?, ?)")
62
            .bind(p.id)
63
            .bind(name)
64
            .bind(i as i64)
65
            .execute(&mut *tx)
66
            .await?;
67
    }
68
    tx.commit().await?;
69
    Ok(p)
70
}
71
72
pub async fn of_owner(pool: &Pool, owner: &str) -> sqlx::Result<Vec<Project>> {
73
    sqlx::query_as("SELECT * FROM projects WHERE owner = ? ORDER BY closed, updated_ms DESC").bind(owner).fetch_all(pool).await
74
}
75
76
pub async fn get(pool: &Pool, owner: &str, number: i64) -> sqlx::Result<Option<Project>> {
77
    sqlx::query_as("SELECT * FROM projects WHERE owner = ? AND number = ?").bind(owner).bind(number).fetch_optional(pool).await
78
}
79
80
pub async fn set(pool: &Pool, id: i64, title: &str, description: &str, public: bool, closed: bool) -> sqlx::Result<()> {
81
    sqlx::query("UPDATE projects SET title = ?, description = ?, public = ?, closed = ?, updated_ms = ? WHERE id = ?")
82
        .bind(title)
83
        .bind(description)
84
        .bind(public)
85
        .bind(closed)
86
        .bind(now_ms())
87
        .bind(id)
88
        .execute(pool)
89
        .await?;
90
    Ok(())
91
}
92
93
pub async fn delete(pool: &Pool, id: i64) -> sqlx::Result<()> {
94
    sqlx::query("DELETE FROM projects WHERE id = ?").bind(id).execute(pool).await?;
95
    Ok(())
96
}
97
98
async fn touch(pool: &Pool, id: i64) -> sqlx::Result<()> {
99
    sqlx::query("UPDATE projects SET updated_ms = ? WHERE id = ?").bind(now_ms()).bind(id).execute(pool).await?;
100
    Ok(())
101
}
102
103
pub async fn columns(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Column>> {
104
    sqlx::query_as("SELECT * FROM project_columns WHERE project_id = ? ORDER BY position, id").bind(project_id).fetch_all(pool).await
105
}
106
107
pub async fn cards(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Card>> {
108
    sqlx::query_as("SELECT * FROM project_cards WHERE project_id = ? ORDER BY position, id").bind(project_id).fetch_all(pool).await
109
}
110
111
pub async fn add_column(pool: &Pool, project_id: i64, name: &str) -> sqlx::Result<()> {
112
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_columns WHERE project_id = ?")
113
        .bind(project_id)
114
        .fetch_one(pool)
115
        .await?;
116
    sqlx::query("INSERT INTO project_columns (project_id, name, position) VALUES (?, ?, ?)")
117
        .bind(project_id)
118
        .bind(name)
119
        .bind(pos)
120
        .execute(pool)
121
        .await?;
122
    touch(pool, project_id).await
123
}
124
125
pub async fn rename_column(pool: &Pool, project_id: i64, column_id: i64, name: &str) -> sqlx::Result<()> {
126
    sqlx::query("UPDATE project_columns SET name = ? WHERE id = ? AND project_id = ?")
127
        .bind(name)
128
        .bind(column_id)
129
        .bind(project_id)
130
        .execute(pool)
131
        .await?;
132
    touch(pool, project_id).await
133
}
134
135
/// Swap a column with its neighbour (`-1` left, `1` right).
136
pub async fn shift_column(pool: &Pool, project_id: i64, column_id: i64, by: i64) -> sqlx::Result<()> {
137
    let cols = columns(pool, project_id).await?;
138
    let Some(i) = cols.iter().position(|c| c.id == column_id) else { return Ok(()) };
139
    let j = i as i64 + by;
140
    if j < 0 || j as usize >= cols.len() {
141
        return Ok(());
142
    }
143
    let mut ids: Vec<i64> = cols.iter().map(|c| c.id).collect();
144
    ids.swap(i, j as usize);
145
    for (pos, id) in ids.iter().enumerate() {
146
        sqlx::query("UPDATE project_columns SET position = ? WHERE id = ?").bind(pos as i64).bind(id).execute(pool).await?;
147
    }
148
    touch(pool, project_id).await
149
}
150
151
/// Remove a column; its cards go to the first remaining column. The last column stays.
152
pub async fn remove_column(pool: &Pool, project_id: i64, column_id: i64) -> sqlx::Result<bool> {
153
    let cols = columns(pool, project_id).await?;
154
    let Some(to) = cols.iter().find(|c| c.id != column_id) else { return Ok(false) };
155
    if !cols.iter().any(|c| c.id == column_id) {
156
        return Ok(false);
157
    }
158
    let mut tx = super::begin_write(pool).await?;
159
    let base: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = ?")
160
        .bind(to.id)
161
        .fetch_one(&mut *tx)
162
        .await?;
163
    sqlx::query("UPDATE project_cards SET column_id = ?, position = position + ? WHERE column_id = ?")
164
        .bind(to.id)
165
        .bind(base)
166
        .bind(column_id)
167
        .execute(&mut *tx)
168
        .await?;
169
    sqlx::query("DELETE FROM project_columns WHERE id = ?").bind(column_id).execute(&mut *tx).await?;
170
    tx.commit().await?;
171
    touch(pool, project_id).await?;
172
    Ok(true)
173
}
174
175
pub struct NewCard<'a> {
176
    pub column_id: i64,
177
    pub kind: &'a str,
178
    pub repo_id: Option<i64>,
179
    pub number: Option<i64>,
180
    pub note: &'a str,
181
    pub added_by: &'a str,
182
}
183
184
/// Add a card at the bottom of a column; `Ok(false)` when that issue or
185
/// proposal is already on the project.
186
pub async fn add_card(pool: &Pool, project_id: i64, c: NewCard<'_>) -> sqlx::Result<bool> {
187
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = ?")
188
        .bind(c.column_id)
189
        .fetch_one(pool)
190
        .await?;
191
    let r = sqlx::query(
192
        "INSERT OR IGNORE INTO project_cards (project_id, column_id, position, kind, repo_id, number, note, added_by, created_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
193
    )
194
    .bind(project_id)
195
    .bind(c.column_id)
196
    .bind(pos)
197
    .bind(c.kind)
198
    .bind(c.repo_id)
199
    .bind(c.number)
200
    .bind(c.note)
201
    .bind(c.added_by)
202
    .bind(now_ms())
203
    .execute(pool)
204
    .await?;
205
    touch(pool, project_id).await?;
206
    Ok(r.rows_affected() == 1)
207
}
208
209
/// Move a card to the bottom of another column of the same project.
210
pub async fn move_card(pool: &Pool, project_id: i64, card_id: i64, column_id: i64) -> sqlx::Result<()> {
211
    let ok: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM project_columns WHERE id = ? AND project_id = ?")
212
        .bind(column_id)
213
        .bind(project_id)
214
        .fetch_one(pool)
215
        .await?;
216
    if ok == 0 {
217
        return Ok(());
218
    }
219
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = ?")
220
        .bind(column_id)
221
        .fetch_one(pool)
222
        .await?;
223
    sqlx::query("UPDATE project_cards SET column_id = ?, position = ? WHERE id = ? AND project_id = ?")
224
        .bind(column_id)
225
        .bind(pos)
226
        .bind(card_id)
227
        .bind(project_id)
228
        .execute(pool)
229
        .await?;
230
    touch(pool, project_id).await
231
}
232
233
/// Swap a card with its neighbour in its column (`-1` up, `1` down).
234
pub async fn shift_card(pool: &Pool, project_id: i64, card_id: i64, by: i64) -> sqlx::Result<()> {
235
    let Some(card): Option<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE id = ? AND project_id = ?")
236
        .bind(card_id)
237
        .bind(project_id)
238
        .fetch_optional(pool)
239
        .await?
240
    else {
241
        return Ok(());
242
    };
243
    let col: Vec<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE column_id = ? ORDER BY position, id")
244
        .bind(card.column_id)
245
        .fetch_all(pool)
246
        .await?;
247
    let Some(i) = col.iter().position(|c| c.id == card_id) else { return Ok(()) };
248
    let j = i as i64 + by;
249
    if j < 0 || j as usize >= col.len() {
250
        return Ok(());
251
    }
252
    let mut ids: Vec<i64> = col.iter().map(|c| c.id).collect();
253
    ids.swap(i, j as usize);
254
    for (pos, id) in ids.iter().enumerate() {
255
        sqlx::query("UPDATE project_cards SET position = ? WHERE id = ?").bind(pos as i64).bind(id).execute(pool).await?;
256
    }
257
    touch(pool, project_id).await
258
}
259
260
pub async fn remove_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<()> {
261
    sqlx::query("DELETE FROM project_cards WHERE id = ? AND project_id = ?").bind(card_id).bind(project_id).execute(pool).await?;
262
    touch(pool, project_id).await
263
}
264
265
/// The projects (and column names) an issue or proposal is on.
266
pub async fn placements(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<Vec<(Project, String)>> {
267
    let rows: Vec<(i64, String)> = sqlx::query_as(
268
        "SELECT c.project_id, col.name FROM project_cards c JOIN project_columns col ON col.id = c.column_id WHERE c.repo_id = ? AND c.number = ? AND c.kind <> 'note'",
269
    )
270
    .bind(repo_id)
271
    .bind(number)
272
    .fetch_all(pool)
273
    .await?;
274
    let mut out = Vec::new();
275
    for (pid, col) in rows {
276
        if let Some(p) = sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = ?").bind(pid).fetch_optional(pool).await? {
277
            out.push((p, col));
278
        }
279
    }
280
    Ok(out)
281
}