SeaORM: subscriptions and pins

Twelve subscription functions move to SeaORM. Lookups use the entity API; the
joins, counts and upserts are SQL written to run on both databases: $n
parameters, ON CONFLICT DO NOTHING in place of INSERT OR IGNORE, and
CASE WHEN on the yes/no column itself rather than comparing it to 1, which
Postgres would refuse for a boolean. INSERT ... SELECT ... ON CONFLICT gets a
WHERE true, which SQLite needs to tell the two apart.

Checked with a daemon on a copy of production: the feed list, read through the
new code, comes back with every feed and its settings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 18:23:37 +00:00
parent 4927677e66
commit d7f8f2df1d
3 changed files with 219 additions and 206 deletions

347
src/db.rs
View File

@@ -1,10 +1,13 @@
//! SQLite state. Replaces the per-feed .ipxd plists, history.dat and qmcache.dat.
use anyhow::{Context, Result};
use crate::entity::{sessions, users};
use crate::entity::{sessions, subscriptions, users};
use rusqlite::{Connection, OptionalExtension, params};
use sea_orm::sea_query::{Expr, Func};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
use sea_orm::{
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
Statement,
};
use std::path::Path;
use std::sync::Mutex;
@@ -203,6 +206,23 @@ pub struct User {
pub last_login: Option<i64>,
}
/// A subscription's keywords, stored as a JSON array.
fn keywords(json: Option<String>) -> Option<Vec<String>> {
json.and_then(|j| serde_json::from_str(&j).ok())
}
impl From<subscriptions::Model> for Sub {
fn from(s: subscriptions::Model) -> Self {
Sub {
feed_id: s.feed_id,
keywords: keywords(s.keywords),
auto_download: s.auto_download,
allow_explicit: s.allow_explicit,
max_new_per_check: s.max_new_per_check,
}
}
}
impl From<users::Model> for User {
fn from(u: users::Model) -> Self {
User {
@@ -986,212 +1006,205 @@ impl Db {
Ok(())
}
// ---- hand-written SQL ----
//
// For what reads better as SQL than as a query builder: joins, sums, upserts. Written to
// run on SQLite and Postgres alike -- $1-style parameters, ON CONFLICT, CASE WHEN on a
// yes/no column rather than comparing it to 1 -- since both run it (issue #18).
fn stmt(&self, sql: &str, values: Vec<sea_orm::Value>) -> Statement {
Statement::from_sql_and_values(self.orm.get_database_backend(), sql, values)
}
/// Runs a statement; how many rows it changed.
async fn exec(&self, sql: &str, values: Vec<sea_orm::Value>) -> Result<u64> {
Ok(self.orm.execute_raw(self.stmt(sql, values)).await?.rows_affected())
}
async fn rows(&self, sql: &str, values: Vec<sea_orm::Value>) -> Result<Vec<sea_orm::QueryResult>> {
Ok(self.orm.query_all_raw(self.stmt(sql, values)).await?)
}
/// The first admin starts subscribed to the whole catalogue: whoever wrote config.toml meant
/// to read those feeds, and without this a fresh install signs in to an empty sidebar. Runs
/// only while nobody subscribes to anything, so an unsubscribe is never undone.
pub fn adopt_catalogue(&self, user_id: i64, catalogue: &[String]) -> Result<usize> {
let conn = self.conn.lock().unwrap();
let already: i64 =
conn.query_row("SELECT count(*) FROM subscriptions", [], |r| r.get(0))?;
if already > 0 {
pub async fn adopt_catalogue(&self, user_id: i64, catalogue: &[String]) -> Result<usize> {
if subscriptions::Entity::find().count(&self.orm).await? > 0 {
return Ok(0);
}
for id in catalogue {
conn.execute(
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id) VALUES (?1, ?2)",
params![user_id, id],
)?;
self.subscribe(user_id, id).await?;
}
// Feeds that exist only in the database (OPML children) count too.
conn.execute(
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id) SELECT ?1, id FROM feeds",
params![user_id],
)?;
// Feeds that exist only in the database (OPML children) count too. SQLite wants the
// WHERE to tell the SELECT from the ON CONFLICT; Postgres does not mind it.
self.exec(
"INSERT INTO subscriptions (user_id, feed_id) SELECT $1, id FROM feeds WHERE true
ON CONFLICT DO NOTHING",
vec![user_id.into()],
)
.await?;
Ok(catalogue.len())
}
// ---- subscriptions ----
/// What this person wants from a feed. Absent means they do not subscribe at all.
pub fn subscription(&self, user_id: i64, feed_id: &str) -> Result<Option<Sub>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT keywords, auto_download, allow_explicit, max_new_per_check
FROM subscriptions WHERE user_id = ?1 AND feed_id = ?2",
)?;
let mut rows = stmt.query(params![user_id, feed_id])?;
Ok(match rows.next()? {
Some(r) => Some(Sub {
feed_id: feed_id.to_string(),
keywords: r
.get::<_, Option<String>>(0)?
.and_then(|j| serde_json::from_str(&j).ok()),
auto_download: r.get::<_, Option<i64>>(1)?.map(|v| v != 0),
allow_explicit: r.get::<_, Option<i64>>(2)?.map(|v| v != 0),
max_new_per_check: r.get(3)?,
}),
None => None,
})
pub async fn subscription(&self, user_id: i64, feed_id: &str) -> Result<Option<Sub>> {
Ok(subscriptions::Entity::find_by_id((user_id, feed_id.to_owned()))
.one(&self.orm)
.await?
.map(Sub::from))
}
/// Every feed this person subscribes to, with their settings.
pub fn subscriptions_for(&self, user_id: i64) -> Result<Vec<Sub>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT feed_id, keywords, auto_download, allow_explicit, max_new_per_check
FROM subscriptions WHERE user_id = ?1",
)?;
let out = stmt
.query_map([user_id], |r| {
Ok(Sub {
feed_id: r.get(0)?,
keywords: r
.get::<_, Option<String>>(1)?
.and_then(|j| serde_json::from_str(&j).ok()),
auto_download: r.get::<_, Option<i64>>(2)?.map(|v| v != 0),
allow_explicit: r.get::<_, Option<i64>>(3)?.map(|v| v != 0),
max_new_per_check: r.get(4)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(out)
pub async fn subscriptions_for(&self, user_id: i64) -> Result<Vec<Sub>> {
Ok(subscriptions::Entity::find()
.filter(subscriptions::Column::UserId.eq(user_id))
.all(&self.orm)
.await?
.into_iter()
.map(Sub::from)
.collect())
}
/// Everyone's settings for one feed. The scanner merges these into what it fetches
/// and downloads, since one file serves the lot.
/// In a group, whatever someone has not set on the feed itself comes from their
/// subscription to the group, as the group's settings dialog has always said it does.
pub fn subscribers(&self, feed_id: &str, group: Option<&str>) -> Result<Vec<Sub>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT coalesce(c.keywords, p.keywords), coalesce(c.auto_download, p.auto_download),
coalesce(c.allow_explicit, p.allow_explicit),
coalesce(c.max_new_per_check, p.max_new_per_check)
FROM subscriptions c
LEFT JOIN subscriptions p ON p.user_id = c.user_id AND p.feed_id = ?2
WHERE c.feed_id = ?1",
)?;
let out = stmt
.query_map(params![feed_id, group], |r| {
pub async fn subscribers(&self, feed_id: &str, group: Option<&str>) -> Result<Vec<Sub>> {
let rows = self
.rows(
"SELECT coalesce(c.keywords, p.keywords) AS keywords,
coalesce(c.auto_download, p.auto_download) AS auto_download,
coalesce(c.allow_explicit, p.allow_explicit) AS allow_explicit,
coalesce(c.max_new_per_check, p.max_new_per_check) AS max_new_per_check
FROM subscriptions c
LEFT JOIN subscriptions p ON p.user_id = c.user_id AND p.feed_id = $2
WHERE c.feed_id = $1",
vec![feed_id.into(), group.map(str::to_owned).into()],
)
.await?;
rows.iter()
.map(|r| {
Ok(Sub {
feed_id: feed_id.to_string(),
keywords: r
.get::<_, Option<String>>(0)?
.and_then(|j| serde_json::from_str(&j).ok()),
auto_download: r.get::<_, Option<i64>>(1)?.map(|v| v != 0),
allow_explicit: r.get::<_, Option<i64>>(2)?.map(|v| v != 0),
max_new_per_check: r.get(3)?,
feed_id: feed_id.to_owned(),
keywords: keywords(r.try_get("", "keywords")?),
auto_download: r.try_get("", "auto_download")?,
allow_explicit: r.try_get("", "allow_explicit")?,
max_new_per_check: r.try_get("", "max_new_per_check")?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(out)
})
.collect()
}
/// Subscribers per feed, for the whole catalogue in one query -- the feed list would
/// otherwise ask once per feed.
pub fn subscriber_counts(&self) -> Result<std::collections::HashMap<String, i64>> {
let conn = self.conn.lock().unwrap();
let mut stmt =
conn.prepare("SELECT feed_id, count(*) FROM subscriptions GROUP BY feed_id")?;
let out = stmt
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?
.collect::<rusqlite::Result<std::collections::HashMap<_, _>>>()?;
Ok(out)
pub async fn subscriber_counts(&self) -> Result<std::collections::HashMap<String, i64>> {
self.rows("SELECT feed_id, count(*) AS n FROM subscriptions GROUP BY feed_id", vec![])
.await?
.iter()
.map(|r| Ok((r.try_get("", "feed_id")?, r.try_get("", "n")?)))
.collect()
}
/// Feeds with any audio or video enclosure: the Directory's Podcasts, with the rest Blogs.
/// Reaped files keep their rows, so a show whose files have all been purged still counts.
pub fn media_feeds(&self) -> Result<std::collections::HashSet<String>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
pub async fn media_feeds(&self) -> Result<std::collections::HashSet<String>> {
self.rows(
"SELECT DISTINCT feed_id FROM enclosures WHERE mime LIKE 'audio/%' OR mime LIKE 'video/%'",
)?;
let out = stmt.query_map([], |r| r.get(0))?.collect::<rusqlite::Result<_>>()?;
Ok(out)
vec![],
)
.await?
.iter()
.map(|r| Ok(r.try_get("", "feed_id")?))
.collect()
}
/// Who else would miss this file: subscribers other than `user_id` who have starred
/// the item or have not read it yet. Deleting is deleting their copy too.
pub fn others_wanting(&self, enclosure_id: i64, user_id: i64) -> Result<(i64, i64)> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT
sum(CASE WHEN coalesce(st.flagged, 0) = 1 THEN 1 ELSE 0 END),
sum(CASE WHEN coalesce(st.read, 0) = 0 THEN 1 ELSE 0 END)
FROM enclosures e
JOIN subscriptions s ON s.feed_id = e.feed_id AND s.user_id != ?2
LEFT JOIN entry_state st
ON st.user_id = s.user_id AND st.feed_id = e.feed_id AND st.guid = e.guid
WHERE e.id = ?1",
)?;
let (starred, unread) = stmt.query_row(params![enclosure_id, user_id], |r| {
Ok((r.get::<_, Option<i64>>(0)?.unwrap_or(0), r.get::<_, Option<i64>>(1)?.unwrap_or(0)))
})?;
Ok((starred, unread))
pub async fn others_wanting(&self, enclosure_id: i64, user_id: i64) -> Result<(i64, i64)> {
// CASE WHEN on the column itself, not `= 1`: a boolean on Postgres, 0 or 1 on SQLite,
// and NULL, for someone who never opened the item, falls to the ELSE either way.
let r = self
.rows(
"SELECT sum(CASE WHEN st.flagged THEN 1 ELSE 0 END) AS starred,
sum(CASE WHEN st.read THEN 0 ELSE 1 END) AS unread
FROM enclosures e
JOIN subscriptions s ON s.feed_id = e.feed_id AND s.user_id <> $2
LEFT JOIN entry_state st
ON st.user_id = s.user_id AND st.feed_id = e.feed_id AND st.guid = e.guid
WHERE e.id = $1",
vec![enclosure_id.into(), user_id.into()],
)
.await?;
let r = r.first().context("an aggregate always returns a row")?;
Ok((
r.try_get::<Option<i64>>("", "starred")?.unwrap_or(0),
r.try_get::<Option<i64>>("", "unread")?.unwrap_or(0),
))
}
pub fn subscribe(&self, user_id: i64, feed_id: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id) VALUES (?1, ?2)",
params![user_id, feed_id],
)?;
pub async fn subscribe(&self, user_id: i64, feed_id: &str) -> Result<()> {
self.exec(
"INSERT INTO subscriptions (user_id, feed_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
vec![user_id.into(), feed_id.into()],
)
.await?;
Ok(())
}
/// The feeds this person pinned to the top of their list. Kept apart from `Sub`, which is
/// what the scanner merges into its policy, and which a pin has nothing to do with.
pub fn pinned_feeds(&self, user_id: i64) -> Result<std::collections::HashSet<String>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT feed_id FROM subscriptions WHERE user_id = ?1 AND pinned")?;
let ids = stmt.query_map([user_id], |r| r.get(0))?.collect::<rusqlite::Result<_>>()?;
Ok(ids)
pub async fn pinned_feeds(&self, user_id: i64) -> Result<std::collections::HashSet<String>> {
Ok(subscriptions::Entity::find()
.filter(subscriptions::Column::UserId.eq(user_id))
.filter(subscriptions::Column::Pinned.eq(true))
.all(&self.orm)
.await?
.into_iter()
.map(|s| s.feed_id)
.collect())
}
/// False when they do not subscribe to it, since there is then no row in their list to pin.
pub fn set_pinned(&self, user_id: i64, feed_id: &str, on: bool) -> Result<bool> {
let conn = self.conn.lock().unwrap();
Ok(conn.execute(
"UPDATE subscriptions SET pinned = ?3 WHERE user_id = ?1 AND feed_id = ?2",
params![user_id, feed_id, on as i64],
)? > 0)
pub async fn set_pinned(&self, user_id: i64, feed_id: &str, on: bool) -> Result<bool> {
let r = subscriptions::Entity::update_many()
.col_expr(subscriptions::Column::Pinned, Expr::val(on).into())
.filter(subscriptions::Column::UserId.eq(user_id))
.filter(subscriptions::Column::FeedId.eq(feed_id))
.exec(&self.orm)
.await?;
Ok(r.rows_affected > 0)
}
pub fn unsubscribe(&self, user_id: i64, feed_id: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"DELETE FROM subscriptions WHERE user_id = ?1 AND feed_id = ?2",
params![user_id, feed_id],
)?;
pub async fn unsubscribe(&self, user_id: i64, feed_id: &str) -> Result<()> {
subscriptions::Entity::delete_by_id((user_id, feed_id.to_owned())).exec(&self.orm).await?;
Ok(())
}
/// Overwrites one person's settings for a feed. A None field means: follow the feed.
pub fn set_subscription(&self, user_id: i64, sub: &Sub) -> Result<()> {
let conn = self.conn.lock().unwrap();
let kw = sub
.keywords
.as_ref()
.map(|k| serde_json::to_string(k))
.transpose()?;
conn.execute(
/// Names its columns, so the pin, which is not a setting, is left as it was.
pub async fn set_subscription(&self, user_id: i64, sub: &Sub) -> Result<()> {
let kw = sub.keywords.as_ref().map(serde_json::to_string).transpose()?;
self.exec(
"INSERT INTO subscriptions
(user_id, feed_id, keywords, auto_download, allow_explicit, max_new_per_check)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(user_id, feed_id) DO UPDATE SET
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (user_id, feed_id) DO UPDATE SET
keywords = excluded.keywords,
auto_download = excluded.auto_download,
allow_explicit = excluded.allow_explicit,
max_new_per_check = excluded.max_new_per_check",
params![
user_id,
sub.feed_id,
kw,
sub.auto_download.map(|v| v as i64),
sub.allow_explicit.map(|v| v as i64),
sub.max_new_per_check,
vec![
user_id.into(),
sub.feed_id.clone().into(),
kw.into(),
sub.auto_download.into(),
sub.allow_explicit.into(),
sub.max_new_per_check.into(),
],
)?;
)
.await?;
Ok(())
}
@@ -1796,17 +1809,17 @@ mod tests {
.unwrap();
// Nobody has touched it: both others still have it unplayed.
assert_eq!(db.others_wanting(1, 1).unwrap(), (0, 2));
assert_eq!(db.others_wanting(1, 1).await.unwrap(), (0, 2));
// Sam reads it, Kit stars it.
db.set_entry_flag(2, "f", "a", EntryFlag::Read, true).unwrap();
db.set_entry_flag(3, "f", "a", EntryFlag::Flagged, true).unwrap();
assert_eq!(db.others_wanting(1, 1).unwrap(), (1, 1), "one starred it, one has not played it");
assert_eq!(db.others_wanting(1, 1).await.unwrap(), (1, 1), "one starred it, one has not played it");
// Asking as Kit, only Ray and Sam count -- and Kit's own star is not a reason to
// warn Kit.
db.set_entry_flag(1, "f", "a", EntryFlag::Read, true).unwrap();
assert_eq!(db.others_wanting(1, 3).unwrap(), (0, 0));
assert_eq!(db.others_wanting(1, 3).await.unwrap(), (0, 0));
}
#[tokio::test]
@@ -1917,12 +1930,12 @@ mod tests {
let db = Db::memory().await.unwrap();
let ray = db.create_user("rays", None, true).await.unwrap();
db.create_user("sam", None, false).await.unwrap();
db.subscribe(ray, "f").unwrap();
db.subscribe(ray, "f").await.unwrap();
db.rename_user(ray, "rays@sdf1.net").await.unwrap();
assert!(db.user_by_name("rays").await.unwrap().is_none());
let renamed = db.user_by_name("RAYS@sdf1.net").await.unwrap().unwrap();
assert_eq!((renamed.id, renamed.is_admin), (ray, true), "same account, still the admin");
assert_eq!(db.subscriptions_for(ray).unwrap().len(), 1, "and still subscribed");
assert_eq!(db.subscriptions_for(ray).await.unwrap().len(), 1, "and still subscribed");
assert!(db.rename_user(ray, "sam").await.is_err(), "a taken name is refused");
}
@@ -1955,11 +1968,11 @@ mod tests {
db.conn.lock().unwrap().query_row("SELECT count(*) FROM subscriptions", [], |r| r.get(0)).unwrap()
};
let catalogue = ["a".to_string(), "b".to_string()];
assert_eq!(db.adopt_catalogue(1, &catalogue).unwrap(), 2);
assert_eq!(db.adopt_catalogue(1, &catalogue).await.unwrap(), 2);
assert_eq!(subs(), 2);
// Once anyone subscribes to anything it never runs again, so an unsubscribe sticks.
db.unsubscribe(1, "a").unwrap();
assert_eq!(db.adopt_catalogue(1, &catalogue).unwrap(), 0);
db.unsubscribe(1, "a").await.unwrap();
assert_eq!(db.adopt_catalogue(1, &catalogue).await.unwrap(), 0);
assert_eq!(subs(), 1);
}
@@ -2121,14 +2134,14 @@ mod tests {
(1,'group',1),(1,'show',NULL),(2,'group',1),(2,'show',0);",
)
.unwrap();
let explicit = |group| -> Vec<Option<bool>> {
let explicit = async |group| -> Vec<Option<bool>> {
let mut v: Vec<_> =
db.subscribers("show", group).unwrap().into_iter().map(|s| s.allow_explicit).collect();
db.subscribers("show", group).await.unwrap().into_iter().map(|s| s.allow_explicit).collect();
v.sort();
v
};
assert_eq!(explicit(Some("group")), [Some(false), Some(true)], "ray inherits; sam's own choice on the show wins");
assert_eq!(explicit(None), [None, Some(false)], "outside a group nothing is inherited");
assert_eq!(explicit(Some("group")).await, [Some(false), Some(true)], "ray inherits; sam's own choice on the show wins");
assert_eq!(explicit(None).await, [None, Some(false)], "outside a group nothing is inherited");
}
#[tokio::test]
@@ -2155,15 +2168,15 @@ mod tests {
async fn a_pin_survives_saving_the_feeds_settings_and_needs_a_subscription() {
let db = Db::memory().await.unwrap();
let me = db.create_user("pat", None, false).await.unwrap();
assert!(!db.set_pinned(me, "f", true).unwrap(), "not subscribed: nothing to pin");
db.subscribe(me, "f").unwrap();
assert!(db.set_pinned(me, "f", true).unwrap());
assert!(!db.set_pinned(me, "f", true).await.unwrap(), "not subscribed: nothing to pin");
db.subscribe(me, "f").await.unwrap();
assert!(db.set_pinned(me, "f", true).await.unwrap());
// set_subscription writes the rest of the row; it must leave the pin alone.
db.set_subscription(me, &Sub { feed_id: "f".into(), auto_download: Some(false), ..Default::default() })
db.set_subscription(me, &Sub { feed_id: "f".into(), auto_download: Some(false), ..Default::default() }).await
.unwrap();
assert!(db.pinned_feeds(me).unwrap().contains("f"));
db.set_pinned(me, "f", false).unwrap();
assert!(db.pinned_feeds(me).unwrap().is_empty());
assert!(db.pinned_feeds(me).await.unwrap().contains("f"));
db.set_pinned(me, "f", false).await.unwrap();
assert!(db.pinned_feeds(me).await.unwrap().is_empty());
}
#[tokio::test]