synd_persistence/sqlite/feed_registry/feed/
mod.rs1use 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
16pub(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
99struct 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
116struct 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;