places/storage/history/
actions.rs

1/*
2 * This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5 */
6
7//! Structs to handle actions that mutate the History DB
8//!
9//! Places DB operations are complex and often involve several layers of changes which makes them
10//! hard to test.  For example, a function that deletes visits in a date range needs to:
11//!   - Calculate which visits to delete
12//!   - Delete the visits
13//!   - Insert visit tombstones
14//!   - Update the frecency for non-orphaned affected pages
15//!   - Delete orphaned pages
16//!   - Insert page tombstones for deleted synced pages
17//!
18//! Test all of this functionality at once leads to ugly tests that are hard to reason about and
19//! hard to change.  This is especially true since many steps have multiple branches which
20//! multiplies the complexity.
21//!
22//! This module is intended to split up operations to make testing simpler.  It defines an enum
23//! whose variants encapsulate particular actions.  We can use that enum to split operations into
24//! multiple parts, each which can be tested separately: code that calculates which actions to run
25//! and the code to run each action.
26//!
27//! Right now, only a couple function use this system, but hopefully we can use it more in the
28//! future.
29
30use super::{cleanup_pages, PageToClean};
31use crate::error::Result;
32use crate::{PlacesDb, RowId};
33use rusqlite::Row;
34use sql_support::ConnExt;
35use std::collections::HashSet;
36
37/// Enum whose variants describe a particular action on the DB
38#[derive(Debug, PartialEq, Eq)]
39pub(super) enum DbAction {
40    /// Delete visit rows from the DB.
41    DeleteVisitRows { visit_ids: HashSet<RowId> },
42    /// Recalculate the moz_places data, including frecency, after changes to their visits.  This
43    /// also deletes orphaned pages (pages whose visits have all been deleted).
44    RecalcPages { page_ids: HashSet<RowId> },
45    /// Delete rows in pending temp tables.  This should be done after any changes to the
46    /// moz_places table.
47    ///
48    /// Deleting from these tables triggers changes to the `moz_origins` table. See
49    /// `sql/create_shared_temp_tables.sql` and `sql/create_shared_triggers.sql` for details.
50    DeleteFromPendingTempTables,
51}
52
53impl DbAction {
54    pub(super) fn apply(self, db: &PlacesDb) -> Result<()> {
55        match self {
56            Self::DeleteVisitRows { visit_ids } => Self::delete_visit_rows(db, visit_ids),
57            Self::RecalcPages { page_ids } => Self::recalc_pages(db, page_ids),
58            Self::DeleteFromPendingTempTables => Self::delete_from_pending_temp_tables(db),
59        }
60    }
61
62    pub(super) fn apply_all(db: &PlacesDb, actions: Vec<Self>) -> Result<()> {
63        for action in actions {
64            action.apply(db)?;
65        }
66        Ok(())
67    }
68
69    fn delete_visit_rows(db: &PlacesDb, visit_ids: HashSet<RowId>) -> Result<()> {
70        sql_support::each_chunk(&Vec::from_iter(visit_ids), |chunk, _| -> Result<()> {
71            let var_repeat = sql_support::repeat_sql_vars(chunk.len());
72            let params = rusqlite::params_from_iter(chunk);
73            db.execute_cached(
74                &format!(
75                    "
76                    INSERT OR IGNORE INTO moz_historyvisit_tombstones(place_id, visit_date)
77                    SELECT place_id, visit_date
78                    FROM moz_historyvisits
79                    WHERE id IN ({})
80                    ",
81                    var_repeat,
82                ),
83                params.clone(),
84            )?;
85
86            db.execute_cached(
87                &format!("DELETE FROM moz_historyvisits WHERE id IN ({})", var_repeat),
88                params,
89            )?;
90            Ok(())
91        })?;
92        Ok(())
93    }
94
95    fn recalc_pages(db: &PlacesDb, page_ids: HashSet<RowId>) -> Result<()> {
96        let mut pages_to_clean: Vec<PageToClean> = vec![];
97        sql_support::each_chunk(&Vec::from_iter(page_ids), |chunk, _| -> Result<()> {
98            pages_to_clean.append(&mut db.query_rows_and_then_cached(
99                &format!(
100                    "SELECT
101                    id,
102                    (foreign_count != 0) AS has_foreign,
103                    ((last_visit_date_local + last_visit_date_remote) != 0) as has_visits,
104                    sync_status
105                FROM moz_places
106                WHERE id IN ({})",
107                    sql_support::repeat_sql_vars(chunk.len())
108                ),
109                rusqlite::params_from_iter(chunk),
110                PageToClean::from_row,
111            )?);
112            Ok(())
113        })?;
114        cleanup_pages(db, &pages_to_clean)?;
115        Ok(())
116    }
117
118    fn delete_from_pending_temp_tables(db: &PlacesDb) -> Result<()> {
119        crate::storage::delete_pending_temp_tables(db)
120    }
121}
122
123/// Stores a visit that we want to delete
124///
125/// We build a Vec of these from queries against the `moz_historyvisits` table, then transform that
126/// into a `Vec<DbAction>`.
127#[derive(Debug, PartialEq, Eq, Hash)]
128pub(super) struct VisitToDelete {
129    pub(super) visit_id: RowId,
130    pub(super) page_id: RowId,
131}
132
133impl VisitToDelete {
134    /// Create a VisitToDelete from a query row
135    ///
136    /// The query must that includes the `id` and `place_id` columns from `moz_historyvisits`.
137    pub(super) fn from_row(row: &Row<'_>) -> Result<Self> {
138        Ok(Self {
139            visit_id: row.get("id")?,
140            page_id: row.get("place_id")?,
141        })
142    }
143}
144
145/// Create a Vec<DbAction> from a Vec<VisitToDelete>
146pub(super) fn db_actions_from_visits_to_delete(
147    visits_to_delete: Vec<VisitToDelete>,
148) -> Vec<DbAction> {
149    let mut visit_ids = HashSet::<RowId>::new();
150    let mut page_ids = HashSet::<RowId>::new();
151    for visit_to_delete in visits_to_delete.into_iter() {
152        visit_ids.insert(visit_to_delete.visit_id);
153        page_ids.insert(visit_to_delete.page_id);
154    }
155    vec![
156        DbAction::DeleteVisitRows { visit_ids },
157        DbAction::RecalcPages { page_ids },
158        DbAction::DeleteFromPendingTempTables,
159    ]
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::observation::VisitObservation;
166    use crate::storage::bookmarks::*;
167    use crate::storage::history::apply_observation;
168    use crate::types::VisitType;
169    use crate::{frecency, ConnectionType, SyncStatus};
170    use rusqlite::params;
171    use rusqlite::types::{FromSql, ToSql};
172    use std::time::Duration;
173    use sync_guid::Guid;
174    use types::Timestamp;
175    use url::Url;
176
177    fn query_vec<T: FromSql>(conn: &PlacesDb, sql: &str, params: &[&dyn ToSql]) -> Vec<T> {
178        conn.prepare(sql)
179            .unwrap()
180            .query_map(params, |row| row.get(0))
181            .unwrap()
182            .collect::<rusqlite::Result<Vec<T>>>()
183            .unwrap()
184    }
185
186    fn query_vec_pairs<T: FromSql, V: FromSql>(
187        conn: &PlacesDb,
188        sql: &str,
189        params: &[&dyn ToSql],
190    ) -> Vec<(T, V)> {
191        conn.prepare(sql)
192            .unwrap()
193            .query_map(params, |row| Ok((row.get(0)?, row.get(1)?)))
194            .unwrap()
195            .collect::<rusqlite::Result<Vec<(T, V)>>>()
196            .unwrap()
197    }
198
199    fn query_visit_ids(conn: &PlacesDb) -> Vec<RowId> {
200        query_vec(conn, "SELECT id FROM moz_historyvisits ORDER BY id", &[])
201    }
202
203    fn query_visit_tombstones(conn: &PlacesDb) -> Vec<(RowId, Timestamp)> {
204        query_vec_pairs(
205            conn,
206            "
207            SELECT place_id, visit_date
208            FROM moz_historyvisit_tombstones
209            ORDER BY place_id, visit_date
210            ",
211            &[],
212        )
213    }
214
215    fn query_page_ids(conn: &PlacesDb) -> Vec<RowId> {
216        query_vec(conn, "SELECT id FROM moz_places ORDER BY id", &[])
217    }
218
219    fn query_page_tombstones(conn: &PlacesDb) -> Vec<Guid> {
220        query_vec(
221            conn,
222            "SELECT guid FROM moz_places_tombstones ORDER BY guid",
223            &[],
224        )
225    }
226
227    struct TestPage {
228        id: RowId,
229        guid: Guid,
230        url: Url,
231        visit_ids: Vec<RowId>,
232        visit_dates: Vec<Timestamp>,
233    }
234
235    impl TestPage {
236        fn new(conn: &mut PlacesDb, url: &str, visit_dates: &[Timestamp]) -> Self {
237            let url = Url::parse(url).unwrap();
238            let mut visit_ids = vec![];
239
240            for date in visit_dates {
241                visit_ids.push(
242                    apply_observation(
243                        conn,
244                        VisitObservation::new(url.clone())
245                            .with_visit_type(VisitType::Link)
246                            .with_at(*date),
247                    )
248                    .unwrap()
249                    .unwrap(),
250                );
251            }
252
253            let (id, guid) = conn
254                .query_row(
255                    "
256                SELECT p.id, p.guid
257                FROM moz_places p
258                JOIN moz_historyvisits v ON p.id = v.place_id
259                WHERE v.id = ?",
260                    [visit_ids[0]],
261                    |row| Ok((row.get(0)?, row.get(1)?)),
262                )
263                .unwrap();
264
265            Self {
266                id,
267                guid,
268                visit_ids,
269                url,
270                visit_dates: Vec::from_iter(visit_dates.iter().cloned()),
271            }
272        }
273
274        fn set_sync_status(&self, conn: &PlacesDb, sync_status: SyncStatus) {
275            conn.execute(
276                "UPDATE moz_places SET sync_status = ? WHERE id = ?",
277                params! {sync_status, self.id },
278            )
279            .unwrap();
280        }
281
282        fn query_frecency(&self, conn: &PlacesDb) -> i32 {
283            conn.query_row(
284                "SELECT frecency FROM moz_places WHERE id = ?",
285                [self.id],
286                |row| row.get::<usize, i32>(0),
287            )
288            .unwrap()
289        }
290
291        fn calculate_frecency(&self, conn: &PlacesDb) -> i32 {
292            frecency::calculate_frecency(
293                conn,
294                &frecency::DEFAULT_FRECENCY_SETTINGS,
295                self.id.0,
296                None,
297            )
298            .unwrap()
299        }
300
301        fn bookmark(&self, conn: &PlacesDb, title: &str) {
302            insert_bookmark(
303                conn,
304                InsertableBookmark {
305                    parent_guid: BookmarkRootGuid::Unfiled.into(),
306                    position: BookmarkPosition::Append,
307                    date_added: None,
308                    last_modified: None,
309                    guid: None,
310                    url: self.url.clone(),
311                    title: Some(title.to_owned()),
312                }
313                .into(),
314            )
315            .unwrap();
316        }
317    }
318
319    #[test]
320    fn test_delete_visit_rows() {
321        let mut conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).unwrap();
322        let yesterday = Timestamp::now()
323            .checked_sub(Duration::from_secs(60 * 60 * 24))
324            .unwrap();
325        let page = TestPage::new(
326            &mut conn,
327            "http://example.com/",
328            &[
329                Timestamp(yesterday.0 + 100),
330                Timestamp(yesterday.0 + 200),
331                Timestamp(yesterday.0 + 300),
332            ],
333        );
334
335        DbAction::DeleteVisitRows {
336            visit_ids: HashSet::from_iter([page.visit_ids[0], page.visit_ids[1]]),
337        }
338        .apply(&conn)
339        .unwrap();
340
341        assert_eq!(query_visit_ids(&conn), vec![page.visit_ids[2]]);
342        assert_eq!(
343            query_visit_tombstones(&conn),
344            vec![
345                (page.id, page.visit_dates[0]),
346                (page.id, page.visit_dates[1]),
347            ]
348        );
349    }
350
351    #[test]
352    fn test_recalc_pages() {
353        let mut conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).unwrap();
354        let yesterday = Timestamp::now()
355            .checked_sub(Duration::from_secs(60 * 60 * 24))
356            .unwrap();
357        let page_with_visits_left = TestPage::new(
358            &mut conn,
359            "http://example.com/1",
360            &[Timestamp(yesterday.0 + 100), Timestamp(yesterday.0 + 200)],
361        );
362        let page_with_no_visits_unsynced = TestPage::new(
363            &mut conn,
364            "http://example.com/2",
365            &[Timestamp(yesterday.0 + 300)],
366        );
367        let page_with_no_visits_synced = TestPage::new(
368            &mut conn,
369            "http://example.com/2",
370            &[Timestamp(yesterday.0 + 400)],
371        );
372        let page_with_no_visits_bookmarked = TestPage::new(
373            &mut conn,
374            "http://example.com/3",
375            &[Timestamp(yesterday.0 + 500)],
376        );
377
378        page_with_no_visits_synced.set_sync_status(&conn, SyncStatus::Normal);
379        page_with_no_visits_bookmarked.bookmark(&conn, "My Bookmark");
380
381        DbAction::DeleteVisitRows {
382            visit_ids: HashSet::from_iter([
383                page_with_visits_left.visit_ids[0],
384                page_with_no_visits_unsynced.visit_ids[0],
385                page_with_no_visits_synced.visit_ids[0],
386                page_with_no_visits_bookmarked.visit_ids[0],
387            ]),
388        }
389        .apply(&conn)
390        .unwrap();
391
392        DbAction::RecalcPages {
393            page_ids: HashSet::from_iter([
394                page_with_visits_left.id,
395                page_with_no_visits_unsynced.id,
396                page_with_no_visits_synced.id,
397                page_with_no_visits_bookmarked.id,
398            ]),
399        }
400        .apply(&conn)
401        .unwrap();
402
403        assert_eq!(
404            query_page_ids(&conn),
405            [page_with_visits_left.id, page_with_no_visits_bookmarked.id]
406        );
407        assert_eq!(
408            query_page_tombstones(&conn),
409            [page_with_no_visits_synced.guid]
410        );
411        assert_eq!(
412            page_with_visits_left.query_frecency(&conn),
413            page_with_visits_left.calculate_frecency(&conn)
414        );
415        assert_eq!(
416            page_with_no_visits_bookmarked.query_frecency(&conn),
417            page_with_no_visits_bookmarked.calculate_frecency(&conn)
418        );
419    }
420
421    #[test]
422    fn test_delete_from_pending_temp_tables() {
423        let mut conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).unwrap();
424        let yesterday = Timestamp::now()
425            .checked_sub(Duration::from_secs(60 * 60 * 24))
426            .unwrap();
427        let test_page = TestPage::new(
428            &mut conn,
429            "http://example.com/",
430            &[
431                Timestamp(yesterday.0 + 100),
432                Timestamp(yesterday.0 + 200),
433                Timestamp(yesterday.0 + 300),
434            ],
435        );
436        DbAction::DeleteVisitRows {
437            visit_ids: HashSet::from_iter([test_page.visit_ids[0]]),
438        }
439        .apply(&conn)
440        .unwrap();
441        DbAction::RecalcPages {
442            page_ids: HashSet::from_iter([test_page.id]),
443        }
444        .apply(&conn)
445        .unwrap();
446        DbAction::DeleteFromPendingTempTables.apply(&conn).unwrap();
447        assert_eq!(
448            conn.conn_ext_query_one::<u32>("SELECT COUNT(*) FROM moz_updateoriginsinsert_temp")
449                .unwrap(),
450            0
451        );
452        assert_eq!(
453            conn.conn_ext_query_one::<u32>("SELECT COUNT(*) FROM moz_updateoriginsupdate_temp")
454                .unwrap(),
455            0
456        );
457        assert_eq!(
458            conn.conn_ext_query_one::<u32>("SELECT COUNT(*) FROM moz_updateoriginsdelete_temp")
459                .unwrap(),
460            0
461        );
462    }
463
464    #[test]
465    fn test_db_actions_from_visits_to_delete() {
466        assert_eq!(
467            db_actions_from_visits_to_delete(vec![
468                VisitToDelete {
469                    visit_id: RowId(1),
470                    page_id: RowId(1),
471                },
472                VisitToDelete {
473                    visit_id: RowId(2),
474                    page_id: RowId(2),
475                },
476                VisitToDelete {
477                    visit_id: RowId(3),
478                    page_id: RowId(2),
479                },
480            ]),
481            vec![
482                DbAction::DeleteVisitRows {
483                    visit_ids: HashSet::from_iter([RowId(1), RowId(2), RowId(3)])
484                },
485                DbAction::RecalcPages {
486                    page_ids: HashSet::from_iter([RowId(1), RowId(2)])
487                },
488                DbAction::DeleteFromPendingTempTables,
489            ],
490        )
491    }
492}