Skip to main content

synd_persistence/sqlite/feed_registry/feed/
mod.rs

1use std::{collections::HashMap, hash::BuildHasher, iter::FromIterator};
2
3use sqlx::{Sqlite, Transaction};
4use synd_feed::{
5    entry::{Entry, EntryId},
6    types::{Feed, FeedMeta, FeedUrl},
7};
8use synd_registry::{RegistryDbResult, db::FeedDb, entry::Entries, feed::FeedUpdate};
9
10use super::{
11    codec::{decode_stored_entry, decode_stored_feed_meta, encode_feed_meta_json},
12    entry,
13    error::{DecodeResultExt, IntoDbResult, SqliteError, SqliteResult},
14};
15
16/// Registers the URL in the feed ledger and returns its pk.
17pub(super) async fn upsert_pk(
18    tx: &mut Transaction<'_, Sqlite>,
19    feed_url: &FeedUrl,
20) -> SqliteResult<i64> {
21    let row = sqlx::query_as::<_, PkRow>(
22        r#"
23            INSERT INTO feed (url)
24            VALUES (?)
25            ON CONFLICT(url) DO UPDATE SET
26                url = excluded.url
27            RETURNING pk
28            "#,
29    )
30    .bind(feed_url.as_str())
31    .fetch_one(&mut **tx)
32    .await?;
33
34    Ok(row.pk)
35}
36
37pub(super) async fn resolve_pk(
38    tx: &mut Transaction<'_, Sqlite>,
39    feed_url: &FeedUrl,
40) -> SqliteResult<i64> {
41    let row = sqlx::query_as::<_, PkRow>(
42        r#"
43            SELECT pk
44            FROM feed
45            WHERE url = ?
46            "#,
47    )
48    .bind(feed_url.as_str())
49    .fetch_optional(&mut **tx)
50    .await?;
51
52    row.map(|row| row.pk)
53        .ok_or_else(|| SqliteError::not_found("feed", feed_url.as_str()))
54}
55
56async fn apply_update(tx: &mut Transaction<'_, Sqlite>, update: &FeedUpdate) -> SqliteResult<()> {
57    let feed_pk = resolve_pk(tx, &update.source().feed_url).await?;
58    upsert_snapshot(tx, feed_pk, update).await?;
59    entry::apply_changes(tx, feed_pk, update.entry_changes()).await?;
60    entry::sync_membership(tx, feed_pk, update.membership()).await?;
61    Ok(())
62}
63
64async fn upsert_snapshot(
65    tx: &mut Transaction<'_, Sqlite>,
66    feed_pk: i64,
67    update: &FeedUpdate,
68) -> SqliteResult<()> {
69    let meta_json = encode_feed_meta_json(update.meta())?;
70    sqlx::query(
71        r#"
72            INSERT INTO feed_snapshot (feed_pk, meta_json, body_blob_pk)
73            VALUES (?, ?, ?)
74            ON CONFLICT(feed_pk) DO UPDATE SET
75                meta_json = excluded.meta_json,
76                body_blob_pk = excluded.body_blob_pk
77            "#,
78    )
79    .bind(feed_pk)
80    .bind(meta_json)
81    .bind(update.source().body_blob.pk())
82    .execute(&mut **tx)
83    .await?;
84    Ok(())
85}
86
87async fn load_feeds(
88    tx: &mut Transaction<'_, Sqlite>,
89    feed_urls: &[FeedUrl],
90) -> SqliteResult<HashMap<FeedUrl, Feed>> {
91    let Some(requested) = RequestedFeeds::encode(feed_urls)? else {
92        return Ok(HashMap::new());
93    };
94    let stored = StoredFeeds::load(tx, &requested).await?;
95    let complete = stored.load_entries(tx, &requested).await?;
96    Ok(complete.into())
97}
98
99/// Stable JSON representation of one batch lookup request.
100struct RequestedFeeds(String);
101
102impl RequestedFeeds {
103    fn encode(feed_urls: &[FeedUrl]) -> SqliteResult<Option<Self>> {
104        if feed_urls.is_empty() {
105            return Ok(None);
106        }
107        let feed_urls = feed_urls.iter().map(FeedUrl::as_str).collect::<Vec<_>>();
108        Ok(Some(Self(serde_json::to_string(&feed_urls)?)))
109    }
110
111    fn as_json(&self) -> &str {
112        &self.0
113    }
114}
115
116/// Feed snapshots being reconstructed from their relational entry membership.
117struct StoredFeeds(HashMap<FeedUrl, StoredFeed>);
118
119impl StoredFeeds {
120    async fn load(
121        tx: &mut Transaction<'_, Sqlite>,
122        requested: &RequestedFeeds,
123    ) -> SqliteResult<Self> {
124        let rows = sqlx::query_as::<_, FeedMetaRow>(
125            r#"
126                WITH requested(feed_url) AS (
127                    SELECT DISTINCT CAST(value AS TEXT)
128                    FROM json_each(?)
129                )
130                SELECT
131                    f.url AS feed_url,
132                    fs.meta_json
133                FROM requested AS r
134                INNER JOIN feed AS f
135                    ON f.url = r.feed_url
136                INNER JOIN feed_snapshot AS fs
137                    ON fs.feed_pk = f.pk
138                ORDER BY f.url
139                "#,
140        )
141        .bind(requested.as_json())
142        .fetch_all(&mut **tx)
143        .await?;
144
145        rows.into_iter()
146            .map(StoredFeed::try_from)
147            .collect::<SqliteResult<Self>>()
148    }
149
150    async fn load_entries(
151        mut self,
152        tx: &mut Transaction<'_, Sqlite>,
153        requested: &RequestedFeeds,
154    ) -> SqliteResult<Self> {
155        let rows = sqlx::query_as::<_, FeedEntryRow>(
156            r#"
157                WITH requested(feed_url) AS (
158                    SELECT DISTINCT CAST(value AS TEXT)
159                    FROM json_each(?)
160                )
161                SELECT
162                    f.url AS feed_url,
163                    e.entry_id,
164                    e.entry_json
165                FROM requested AS r
166                INNER JOIN feed AS f
167                    ON f.url = r.feed_url
168                INNER JOIN feed_entry AS fe
169                    ON fe.feed_pk = f.pk
170                INNER JOIN entry AS e
171                    ON e.feed_pk = fe.feed_pk
172                   AND e.entry_id = fe.entry_id
173                ORDER BY f.url, e.order_time DESC, e.entry_id DESC
174                "#,
175        )
176        .bind(requested.as_json())
177        .fetch_all(&mut **tx)
178        .await?;
179
180        for entry in rows.into_iter().map(StoredFeedEntry::try_from) {
181            self.push(entry?)?;
182        }
183        Ok(self)
184    }
185
186    fn push(&mut self, stored_entry: StoredFeedEntry) -> SqliteResult<()> {
187        let feed = self.0.get_mut(&stored_entry.feed_url).ok_or_else(|| {
188            SqliteError::not_found("feed snapshot", stored_entry.feed_url.as_str())
189        })?;
190        feed.entries.push(stored_entry.entry);
191        Ok(())
192    }
193}
194
195impl FromIterator<StoredFeed> for StoredFeeds {
196    fn from_iter<T>(feeds: T) -> Self
197    where
198        T: IntoIterator<Item = StoredFeed>,
199    {
200        Self(
201            feeds
202                .into_iter()
203                .map(|feed| (feed.meta.url().clone(), feed))
204                .collect(),
205        )
206    }
207}
208
209impl<S> From<StoredFeeds> for HashMap<FeedUrl, Feed, S>
210where
211    S: BuildHasher + Default,
212{
213    fn from(stored: StoredFeeds) -> Self {
214        stored
215            .0
216            .into_iter()
217            .map(|(feed_url, feed)| (feed_url, Feed::from(feed)))
218            .collect()
219    }
220}
221
222#[derive(sqlx::FromRow)]
223struct PkRow {
224    pk: i64,
225}
226
227#[derive(sqlx::FromRow)]
228struct FeedMetaRow {
229    feed_url: String,
230    meta_json: String,
231}
232
233#[derive(Debug)]
234struct StoredFeed {
235    meta: FeedMeta,
236    entries: Vec<Entry>,
237}
238
239impl From<StoredFeed> for Feed {
240    fn from(stored: StoredFeed) -> Self {
241        Self::new(stored.meta, stored.entries)
242    }
243}
244
245impl TryFrom<FeedMetaRow> for StoredFeed {
246    type Error = SqliteError;
247
248    fn try_from(row: FeedMetaRow) -> Result<Self, Self::Error> {
249        Ok(Self {
250            meta: decode_stored_feed_meta(&row.feed_url, &row.meta_json)?,
251            entries: Vec::new(),
252        })
253    }
254}
255
256#[derive(sqlx::FromRow)]
257struct FeedEntryRow {
258    feed_url: String,
259    entry_id: String,
260    entry_json: String,
261}
262
263struct StoredFeedEntry {
264    feed_url: FeedUrl,
265    entry: Entry,
266}
267
268impl TryFrom<FeedEntryRow> for StoredFeedEntry {
269    type Error = SqliteError;
270
271    fn try_from(row: FeedEntryRow) -> Result<Self, Self::Error> {
272        Ok(Self {
273            feed_url: FeedUrl::parse(&row.feed_url).decode()?,
274            entry: decode_stored_entry(&row.entry_id, &row.entry_json)?,
275        })
276    }
277}
278
279impl FeedDb for super::SqliteRegistryTx<'_> {
280    async fn load_entries(&mut self, entry_ids: &[EntryId]) -> RegistryDbResult<Entries> {
281        entry::load(&mut self.tx, entry_ids).await.db()
282    }
283
284    async fn apply_feed_update(&mut self, update: &FeedUpdate) -> RegistryDbResult<()> {
285        apply_update(&mut self.tx, update).await.db()
286    }
287
288    async fn load_feeds(
289        &mut self,
290        feed_urls: &[FeedUrl],
291    ) -> RegistryDbResult<HashMap<FeedUrl, Feed>> {
292        load_feeds(&mut self.tx, feed_urls).await.db()
293    }
294}
295
296#[cfg(test)]
297mod tests;