Postgres, step 2b: flags compared as TRUE and FALSE, counted with CASE ea14c4ec5c3f
Boolean columns are compared and set with TRUE and FALSE rather than 1 and 0, and counted with SUM(CASE WHEN ...) rather than SUM(flag = 0), so the same SQL runs where they are real booleans (Postgres) and where they are integers (SQLite, which reads TRUE and FALSE as 1 and 0). A test now checks the inbox thread's unread count, which an inverted CASE in this change got wrong without any test noticing (fixed before commit). Refs #211
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-16 11:37 UTC
- Signature
- ✔ signed by Claude Code
- Hash
ea14c4ec5c3fcbba8505639ca2ce632f5b892a0396dd98b65bbf029b788e0bff- Depends on
- 18e381ea88c2 19bb31ff1f5c 25ab8c73514a 2a0c09c09716 34beb1a7e376 3f4b0ea6eabf 42fa95540468 4a4400c5812d 5d90e0e045e2 cb71e18eb6f9 df5c69cbd047 df71c7892d27 eb0c44a9ad37
- Depended on by
- 3b1bc27fe805 97b18869acf5
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 26 atoms, 0 tree operations, 7 files
Browse the tree at this change
modified crates/hub/src/db/accounts.rs +4 −4
| … | 434 unchanged lines |
||
| 435 | 435 | /// accounts are left out. |
|
| 436 | 436 | pub async fn assignable(pool: &Pool, repo_id: i64, owner: &str) -> sqlx::Result<Vec<String>> { |
|
| 437 | 437 | sqlx::query_scalar( |
|
| 438 | - | "SELECT username FROM accounts WHERE banned = 0 AND disabled = 0 AND id IN (\ |
|
| 438 | + | "SELECT username FROM accounts WHERE banned = FALSE AND disabled = FALSE AND id IN (\ |
|
| 439 | 439 | SELECT id FROM accounts WHERE username = $1 \ |
|
| 440 | 440 | UNION SELECT account_id FROM collaborators WHERE repo_id = $2 AND account_id IS NOT NULL \ |
|
| 441 | 441 | UNION SELECT tm.account_id FROM team_members tm JOIN collaborators c ON c.team_id = tm.team_id WHERE c.repo_id = $2 \ |
|
| … | 206 unchanged lines |
||
| 648 | 648 | /// was no longer pending. |
|
| 649 | 649 | pub async fn approve_device(pool: &Pool, id: i64, account_id: i64, scopes: &str) -> sqlx::Result<bool> { |
|
| 650 | 650 | let r = sqlx::query( |
|
| 651 | - | "UPDATE device_codes SET account_id = $1, approved_scopes = $2 WHERE id = $3 AND account_id IS NULL AND denied = 0 AND expires_ms >= $4", |
|
| 651 | + | "UPDATE device_codes SET account_id = $1, approved_scopes = $2 WHERE id = $3 AND account_id IS NULL AND denied = FALSE AND expires_ms >= $4", |
|
| 652 | 652 | ) |
|
| 653 | 653 | .bind(account_id) |
|
| 654 | 654 | .bind(scopes) |
|
| … | 5 unchanged lines |
||
| 660 | 660 | } |
|
| 661 | 661 | ||
| 662 | 662 | pub async fn deny_device(pool: &Pool, id: i64) -> sqlx::Result<()> { |
|
| 663 | - | sqlx::query("UPDATE device_codes SET denied = 1 WHERE id = $1 AND account_id IS NULL").bind(id).execute(pool).await?; |
|
| 663 | + | sqlx::query("UPDATE device_codes SET denied = TRUE WHERE id = $1 AND account_id IS NULL").bind(id).execute(pool).await?; |
|
| 664 | 664 | Ok(()) |
|
| 665 | 665 | } |
|
| 666 | 666 | ||
| … | 276 unchanged lines |
||
| 943 | 943 | ||
| 944 | 944 | /// The account with this verified email (case-insensitive), if exactly one. |
|
| 945 | 945 | pub async fn account_by_verified_email(pool: &Pool, email: &str) -> sqlx::Result<Option<Account>> { |
|
| 946 | - | let found: Vec<Account> = sqlx::query_as("SELECT * FROM accounts WHERE email_verified = 1 AND lower(email) = lower($1) LIMIT 2") |
|
| 946 | + | let found: Vec<Account> = sqlx::query_as("SELECT * FROM accounts WHERE email_verified = TRUE AND lower(email) = lower($1) LIMIT 2") |
|
| 947 | 947 | .bind(email) |
|
| 948 | 948 | .fetch_all(pool) |
|
| 949 | 949 | .await?; |
|
| … | 9 unchanged lines |
modified crates/hub/src/db/activity.rs +3 −3
| … | 48 unchanged lines |
||
| 49 | 49 | pub async fn feed_by(pool: &Pool, repo_ids: &[i64], actor: Option<&str>, limit: i64) -> sqlx::Result<Vec<Activity>> { |
|
| 50 | 50 | sqlx::query_as( |
|
| 51 | 51 | "WITH seen(id) AS (SELECT value FROM json_each($1)), ev AS ( \ |
|
| 52 | - | SELECT repo_id, at_ms, pusher AS actor, 'push' AS kind, reference AS event, '' AS title, 0 AS number FROM pushes WHERE ok = 1 \ |
|
| 52 | + | SELECT repo_id, at_ms, pusher AS actor, 'push' AS kind, reference AS event, '' AS title, 0 AS number FROM pushes WHERE ok = TRUE \ |
|
| 53 | 53 | UNION ALL SELECT repo_id, created_ms, author, 'release', 'published', tag, 0 FROM releases \ |
|
| 54 | 54 | UNION ALL SELECT p.repo_id, e.at_ms, e.actor, 'proposal', e.event, p.title, p.number FROM proposal_events e JOIN proposals p ON p.id = e.proposal_id \ |
|
| 55 | 55 | UNION ALL SELECT i.repo_id, e.at_ms, e.actor, 'issue', e.event, i.title, i.number FROM issue_events e JOIN issues i ON i.id = e.issue_id) \ |
|
| … | 95 unchanged lines |
||
| 151 | 151 | pub async fn sky(pool: &Pool, repo_ids: &[i64], since_ms: i64, limit: i64) -> sqlx::Result<Vec<SkyEvent>> { |
|
| 152 | 152 | sqlx::query_as( |
|
| 153 | 153 | "WITH seen(id) AS (SELECT value FROM json_each($1)), ev AS ( \ |
|
| 154 | - | SELECT repo_id, at_ms, 0 AS kind, MAX(changes, 1) AS n FROM pushes WHERE ok = 1 \ |
|
| 154 | + | SELECT repo_id, at_ms, 0 AS kind, MAX(changes, 1) AS n FROM pushes WHERE ok = TRUE \ |
|
| 155 | 155 | UNION ALL SELECT p.repo_id, e.at_ms, 1, 1 FROM proposal_events e JOIN proposals p ON p.id = e.proposal_id \ |
|
| 156 | 156 | UNION ALL SELECT i.repo_id, e.at_ms, 2, 1 FROM issue_events e JOIN issues i ON i.id = e.issue_id \ |
|
| 157 | 157 | UNION ALL SELECT repo_id, created_ms, 3, 1 FROM releases \ |
|
| … | 16 unchanged lines |
||
| 174 | 174 | let ids = serde_json::to_string(repo_ids).unwrap_or_else(|_| "[]".into()); |
|
| 175 | 175 | sqlx::query_as( |
|
| 176 | 176 | "SELECT repo_id, at_ms, changes, via FROM pushes \ |
|
| 177 | - | WHERE account_id = $1 AND ok = 1 AND changes > 0 AND at_ms >= $2 AND reason NOT LIKE 'landed%' \ |
|
| 177 | + | WHERE account_id = $1 AND ok = TRUE AND changes > 0 AND at_ms >= $2 AND reason NOT LIKE 'landed%' \ |
|
| 178 | 178 | AND repo_id IN (SELECT value FROM json_each($3)) ORDER BY at_ms", |
|
| 179 | 179 | ) |
|
| 180 | 180 | .bind(account_id) |
|
| … | 20 unchanged lines |
modified crates/hub/src/db/notify.rs +9 −9
| … | 54 unchanged lines |
||
| 55 | 55 | pub async fn add(pool: &Pool, n: New<'_>) -> sqlx::Result<()> { |
|
| 56 | 56 | let New { account_id, repo_id, reason, kind, title, url, actor } = n; |
|
| 57 | 57 | // A new event brings a thread that was set aside back to the inbox. |
|
| 58 | - | sqlx::query("UPDATE notifications SET done = 0 WHERE account_id = $1 AND thread = $2 AND done = 1") |
|
| 58 | + | sqlx::query("UPDATE notifications SET done = FALSE WHERE account_id = $1 AND thread = $2 AND done = TRUE") |
|
| 59 | 59 | .bind(account_id) |
|
| 60 | 60 | .bind(thread_of(url)) |
|
| 61 | 61 | .execute(pool) |
|
| … | 49 unchanged lines |
||
| 111 | 111 | let mut sql = String::from( |
|
| 112 | 112 | "SELECT n.id, MAX(n.at_ms) AS at_ms, n.kind, n.title, n.url, n.actor, n.thread, n.repo_id, \ |
|
| 113 | 113 | (SELECT r.owner || '/' || r.name FROM repositories r WHERE r.id = n.repo_id) AS repo, \ |
|
| 114 | - | COUNT(*) AS count, SUM(n.read = 0) AS unread, SUM(n.reason = 'participating') > 0 AS participating, n.done \ |
|
| 114 | + | COUNT(*) AS count, SUM(CASE WHEN n.read THEN 0 ELSE 1 END) AS unread, SUM(CASE WHEN n.reason = 'participating' THEN 1 ELSE 0 END) > 0 AS participating, n.done \ |
|
| 115 | 115 | FROM notifications n WHERE n.account_id = $1 AND n.done = $2", |
|
| 116 | 116 | ); |
|
| 117 | 117 | // Placeholders numbered as they are added: $1 and $2 above. |
|
| … | 8 unchanged lines |
||
| 126 | 126 | } |
|
| 127 | 127 | sql.push_str(" GROUP BY n.thread HAVING 1"); |
|
| 128 | 128 | if f.unread_only { |
|
| 129 | - | sql.push_str(" AND SUM(n.read = 0) > 0"); |
|
| 129 | + | sql.push_str(" AND SUM(CASE WHEN n.read THEN 0 ELSE 1 END) > 0"); |
|
| 130 | 130 | } |
|
| 131 | 131 | match f.reason.as_deref() { |
|
| 132 | 132 | Some("participating") => sql.push_str(" AND SUM(n.reason = 'participating') > 0"), |
|
| … | 15 unchanged lines |
||
| 148 | 148 | pub async fn set_thread(pool: &Pool, account_id: i64, thread: &str, read: Option<bool>, done: Option<bool>) -> sqlx::Result<()> { |
|
| 149 | 149 | if let Some(r) = read { |
|
| 150 | 150 | if r { |
|
| 151 | - | sqlx::query("UPDATE notifications SET read = 1 WHERE account_id = $1 AND thread = $2") |
|
| 151 | + | sqlx::query("UPDATE notifications SET read = TRUE WHERE account_id = $1 AND thread = $2") |
|
| 152 | 152 | .bind(account_id) |
|
| 153 | 153 | .bind(thread) |
|
| 154 | 154 | .execute(pool) |
|
| 155 | 155 | .await?; |
|
| 156 | 156 | } else { |
|
| 157 | 157 | // Unread: the newest notification of the thread only. |
|
| 158 | - | sqlx::query("UPDATE notifications SET read = 0 WHERE id = (SELECT id FROM notifications WHERE account_id = $1 AND thread = $2 ORDER BY at_ms DESC, id DESC LIMIT 1)") |
|
| 158 | + | sqlx::query("UPDATE notifications SET read = FALSE WHERE id = (SELECT id FROM notifications WHERE account_id = $1 AND thread = $2 ORDER BY at_ms DESC, id DESC LIMIT 1)") |
|
| 159 | 159 | .bind(account_id) |
|
| 160 | 160 | .bind(thread) |
|
| 161 | 161 | .execute(pool) |
|
| … | 1 unchanged lines |
||
| 163 | 163 | } |
|
| 164 | 164 | } |
|
| 165 | 165 | if let Some(d) = done { |
|
| 166 | - | let read_too = if d { ", read = 1" } else { "" }; |
|
| 166 | + | let read_too = if d { ", read = TRUE" } else { "" }; |
|
| 167 | 167 | sqlx::query(&format!("UPDATE notifications SET done = $1{read_too} WHERE account_id = $2 AND thread = $3")) |
|
| 168 | 168 | .bind(d) |
|
| 169 | 169 | .bind(account_id) |
|
| … | 18 unchanged lines |
||
| 188 | 188 | } |
|
| 189 | 189 | ||
| 190 | 190 | pub async fn unread(pool: &Pool, account_id: i64) -> sqlx::Result<i64> { |
|
| 191 | - | sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE account_id = $1 AND read = 0 AND done = 0") |
|
| 191 | + | sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE account_id = $1 AND read = FALSE AND done = FALSE") |
|
| 192 | 192 | .bind(account_id) |
|
| 193 | 193 | .fetch_one(pool) |
|
| 194 | 194 | .await |
|
| … | 2 unchanged lines |
||
| 197 | 197 | pub async fn mark_read(pool: &Pool, account_id: i64, id: Option<i64>) -> sqlx::Result<()> { |
|
| 198 | 198 | match id { |
|
| 199 | 199 | Some(id) => { |
|
| 200 | - | sqlx::query("UPDATE notifications SET read = 1 WHERE account_id = $1 AND id = $2") |
|
| 200 | + | sqlx::query("UPDATE notifications SET read = TRUE WHERE account_id = $1 AND id = $2") |
|
| 201 | 201 | .bind(account_id) |
|
| 202 | 202 | .bind(id) |
|
| 203 | 203 | .execute(pool) |
|
| 204 | 204 | .await? |
|
| 205 | 205 | } |
|
| 206 | - | None => sqlx::query("UPDATE notifications SET read = 1 WHERE account_id = $1").bind(account_id).execute(pool).await?, |
|
| 206 | + | None => sqlx::query("UPDATE notifications SET read = TRUE WHERE account_id = $1").bind(account_id).execute(pool).await?, |
|
| 207 | 207 | }; |
|
| 208 | 208 | Ok(()) |
|
| 209 | 209 | } |
|
| … | 136 unchanged lines |
modified crates/hub/src/db/releases.rs +1 −1
| … | 37 unchanged lines |
||
| 38 | 38 | ||
| 39 | 39 | /// The newest release that is not a pre-release: the "latest" marker. |
|
| 40 | 40 | pub async fn latest(pool: &Pool, repo_id: i64) -> sqlx::Result<Option<Release>> { |
|
| 41 | - | sqlx::query_as("SELECT * FROM releases WHERE repo_id = $1 AND prerelease = 0 ORDER BY created_ms DESC, id DESC LIMIT 1") |
|
| 41 | + | sqlx::query_as("SELECT * FROM releases WHERE repo_id = $1 AND prerelease = FALSE ORDER BY created_ms DESC, id DESC LIMIT 1") |
|
| 42 | 42 | .bind(repo_id) |
|
| 43 | 43 | .fetch_optional(pool) |
|
| 44 | 44 | .await |
|
| … | 82 unchanged lines |
modified crates/hub/src/flow.rs +1 −1
| … | 2092 unchanged lines |
||
| 2093 | 2093 | } |
|
| 2094 | 2094 | tracing::warn!("flow: runner {} drained after {n} errors in a row (last: {last})", r.name); |
|
| 2095 | 2095 | let Some(me) = flow_account(&app.db).await else { return }; |
|
| 2096 | - | let admins: Vec<i64> = sqlx::query_scalar("SELECT id FROM accounts WHERE is_admin = 1").fetch_all(&app.db).await.unwrap_or_default(); |
|
| 2096 | + | let admins: Vec<i64> = sqlx::query_scalar("SELECT id FROM accounts WHERE is_admin = TRUE").fetch_all(&app.db).await.unwrap_or_default(); |
|
| 2097 | 2097 | crate::notify::deliver( |
|
| 2098 | 2098 | app, |
|
| 2099 | 2099 | crate::notify::Event { |
|
| … | 389 unchanged lines |
modified crates/hub/src/web/inbox.rs +1 −1
| … | 218 unchanged lines |
||
| 219 | 219 | /// The one-click link in every email: no session needed, the key is the proof. |
|
| 220 | 220 | pub async fn unsubscribe(State(app): State<Shared>, Query(q): Query<UnsubQuery>) -> Response { |
|
| 221 | 221 | if let Some(key) = q.unsubscribe.filter(|k| k.len() >= 16) { |
|
| 222 | - | sqlx::query("UPDATE accounts SET email_notifications = 0 WHERE unsubscribe_key = $1").bind(key).execute(&app.db).await.ok(); |
|
| 222 | + | sqlx::query("UPDATE accounts SET email_notifications = FALSE WHERE unsubscribe_key = $1").bind(key).execute(&app.db).await.ok(); |
|
| 223 | 223 | return (axum::http::StatusCode::OK, "Email notifications are off for that account.").into_response(); |
|
| 224 | 224 | } |
|
| 225 | 225 | Redirect::to("/notifications").into_response() |
|
| … | 77 unchanged lines |
modified crates/hub/tests/issues.rs +8 −0
| … | 148 unchanged lines |
||
| 149 | 149 | assert!(page.body.contains("#999") && !page.body.contains("issue/999"), "unknown numbers are not linked"); |
|
| 150 | 150 | let unread = db::notify::unread(&app.db, db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id).await.unwrap(); |
|
| 151 | 151 | assert_eq!(unread, 1, "one notification, not one per reason"); |
|
| 152 | + | // The inbox's thread counts it unread (the SQL is portable now: |
|
| 153 | + | // oxygenhub#211). |
|
| 154 | + | let bob_id = db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id; |
|
| 155 | + | let filter = db::notify::Filter { done: false, unread_only: false, reason: None, kind: None, repo_id: None }; |
|
| 156 | + | let t = db::notify::threads(&app.db, bob_id, &filter, 10).await.unwrap(); |
|
| 157 | + | assert_eq!((t[0].count, t[0].unread), (1, 1)); |
|
| 158 | + | let unread_only = db::notify::Filter { unread_only: true, ..filter.clone() }; |
|
| 159 | + | assert_eq!(db::notify::threads(&app.db, bob_id, &unread_only, 10).await.unwrap().len(), 1); |
|
| 152 | 160 | let inbox = get(app, "/notifications", Some(&bob)).await; |
|
| 153 | 161 | assert!(inbox.body.contains("andy/demo#1: Crash on start"), "{}", inbox.body); |
|
| 154 | 162 | assert!(get(app, "/andy/demo", Some(&bob)).await.body.contains("class=\"count\">1<"), "bell shows the count"); |
|
| … | 1029 unchanged lines |