OXYGENHUB

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