places/storage/
history_metadata.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5use crate::db::{PlacesDb, PlacesTransaction};
6use crate::error::*;
7use crate::RowId;
8use error_support::{breadcrumb, redact_url};
9use rusqlite::types::{FromSql, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
10use sql_support::ConnExt;
11use std::vec::Vec;
12use sync_guid::Guid as SyncGuid;
13use types::Timestamp;
14use url::Url;
15
16use lazy_static::lazy_static;
17
18#[derive(Copy, Clone, Debug, PartialEq, Eq)]
19pub enum DocumentType {
20    Regular = 0,
21    Media = 1,
22}
23
24impl FromSql for DocumentType {
25    #[inline]
26    fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
27        Ok(match value.as_i64()? {
28            0 => DocumentType::Regular,
29            1 => DocumentType::Media,
30            other => {
31                // seems safe to ignore?
32                warn!("invalid DocumentType {}", other);
33                DocumentType::Regular
34            }
35        })
36    }
37}
38
39impl ToSql for DocumentType {
40    #[inline]
41    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
42        Ok(ToSqlOutput::from(*self as u32))
43    }
44}
45
46#[derive(Clone)]
47pub struct HistoryHighlightWeights {
48    pub view_time: f64,
49    pub frequency: f64,
50}
51
52#[derive(Clone)]
53pub struct HistoryHighlight {
54    pub score: f64,
55    pub place_id: i32,
56    pub url: String,
57    pub title: Option<String>,
58    pub preview_image_url: Option<String>,
59}
60
61impl HistoryHighlight {
62    pub(crate) fn from_row(row: &rusqlite::Row<'_>) -> Result<Self> {
63        Ok(Self {
64            score: row.get("score")?,
65            place_id: row.get("place_id")?,
66            url: row.get("url")?,
67            title: row.get("title")?,
68            preview_image_url: row.get("preview_image_url")?,
69        })
70    }
71}
72
73#[derive(Clone, Debug, PartialEq, Eq)]
74pub struct HistoryMetadataObservation {
75    pub url: String,
76    pub view_time: Option<i32>,
77    pub search_term: Option<String>,
78    pub document_type: Option<DocumentType>,
79    pub referrer_url: Option<String>,
80    pub title: Option<String>,
81}
82
83#[derive(Clone, Copy, Debug, PartialEq, Eq)]
84pub enum HistoryMetadataPageMissingBehavior {
85    InsertPage,
86    IgnoreObservation,
87}
88
89#[derive(Clone, Debug, PartialEq, Eq)]
90pub struct NoteHistoryMetadataObservationOptions {
91    pub if_page_missing: HistoryMetadataPageMissingBehavior,
92}
93
94impl Default for NoteHistoryMetadataObservationOptions {
95    fn default() -> Self {
96        Self::new()
97    }
98}
99
100impl NoteHistoryMetadataObservationOptions {
101    pub fn new() -> Self {
102        Self {
103            if_page_missing: HistoryMetadataPageMissingBehavior::IgnoreObservation,
104        }
105    }
106
107    pub fn if_page_missing(self, if_page_missing: HistoryMetadataPageMissingBehavior) -> Self {
108        Self { if_page_missing }
109    }
110}
111
112#[derive(Clone, Debug, PartialEq, Eq)]
113pub struct HistoryMetadata {
114    pub url: String,
115    pub title: Option<String>,
116    pub preview_image_url: Option<String>,
117    pub created_at: i64,
118    pub updated_at: i64,
119    pub total_view_time: i32,
120    pub search_term: Option<String>,
121    pub document_type: DocumentType,
122    pub referrer_url: Option<String>,
123}
124
125impl HistoryMetadata {
126    pub(crate) fn from_row(row: &rusqlite::Row<'_>) -> Result<Self> {
127        let created_at: Timestamp = row.get("created_at")?;
128        let updated_at: Timestamp = row.get("updated_at")?;
129
130        // Guard against invalid data in the db.
131        // Certain client bugs allowed accumulating values that are too large to fit into i32,
132        // leading to overflow failures. While this data will expire and will be deleted
133        // by clients via `delete_older_than`, we still want to ensure we won't crash in case of
134        // encountering it.
135        // See `apply_metadata_observation` for where we guard against observing invalid view times.
136        let total_view_time: i64 = row.get("total_view_time")?;
137        let total_view_time = match i32::try_from(total_view_time) {
138            Ok(tvt) => tvt,
139            Err(_) => i32::MAX,
140        };
141
142        Ok(Self {
143            url: row.get("url")?,
144            title: row.get("title")?,
145            preview_image_url: row.get("preview_image_url")?,
146            created_at: created_at.0 as i64,
147            updated_at: updated_at.0 as i64,
148            total_view_time,
149            search_term: row.get("search_term")?,
150            document_type: row.get("document_type")?,
151            referrer_url: row.get("referrer_url")?,
152        })
153    }
154}
155
156enum PlaceEntry {
157    Existing(i64),
158    CreateFor(Url, Option<String>),
159}
160
161trait WhereArg {
162    fn to_where_arg(&self, db_field: &str) -> String;
163}
164
165impl PlaceEntry {
166    fn fetch(url: &str, tx: &PlacesTransaction<'_>, title: Option<String>) -> Result<Self> {
167        let url = Url::parse(url).inspect_err(|_e| {
168            breadcrumb!(
169                "PlaceEntry::fetch -- Error parsing url: {}",
170                redact_url(url)
171            );
172        })?;
173        let place_id = tx.try_query_one(
174            "SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url",
175            &[(":url", &url.as_str())],
176            true,
177        )?;
178
179        Ok(match place_id {
180            Some(id) => PlaceEntry::Existing(id),
181            None => PlaceEntry::CreateFor(url, title),
182        })
183    }
184}
185
186impl WhereArg for PlaceEntry {
187    fn to_where_arg(&self, db_field: &str) -> String {
188        match self {
189            PlaceEntry::Existing(id) => format!("{} = {}", db_field, id),
190            PlaceEntry::CreateFor(_, _) => panic!("WhereArg: place entry must exist"),
191        }
192    }
193}
194
195impl WhereArg for Option<PlaceEntry> {
196    fn to_where_arg(&self, db_field: &str) -> String {
197        match self {
198            Some(entry) => entry.to_where_arg(db_field),
199            None => format!("{} IS NULL", db_field),
200        }
201    }
202}
203
204trait DatabaseId {
205    fn get_or_insert(&self, tx: &PlacesTransaction<'_>) -> Result<i64>;
206}
207
208impl DatabaseId for PlaceEntry {
209    fn get_or_insert(&self, tx: &PlacesTransaction<'_>) -> Result<i64> {
210        Ok(match self {
211            PlaceEntry::Existing(id) => *id,
212            PlaceEntry::CreateFor(url, title) => {
213                let sql = "INSERT INTO moz_places (guid, url, title, url_hash)
214                VALUES (:guid, :url, :title, hash(:url))";
215
216                let guid = SyncGuid::random();
217
218                tx.execute_cached(
219                    sql,
220                    &[
221                        (":guid", &guid as &dyn rusqlite::ToSql),
222                        (":title", &title),
223                        (":url", &url.as_str()),
224                    ],
225                )?;
226                tx.conn().last_insert_rowid()
227            }
228        })
229    }
230}
231
232enum SearchQueryEntry {
233    Existing(i64),
234    CreateFor(String),
235}
236
237impl DatabaseId for SearchQueryEntry {
238    fn get_or_insert(&self, tx: &PlacesTransaction<'_>) -> Result<i64> {
239        Ok(match self {
240            SearchQueryEntry::Existing(id) => *id,
241            SearchQueryEntry::CreateFor(term) => {
242                tx.execute_cached(
243                    "INSERT INTO moz_places_metadata_search_queries(term) VALUES (:term)",
244                    &[(":term", &term)],
245                )?;
246                tx.conn().last_insert_rowid()
247            }
248        })
249    }
250}
251
252impl SearchQueryEntry {
253    fn from(search_term: &str, tx: &PlacesTransaction<'_>) -> Result<Self> {
254        let lowercase_term = search_term.to_lowercase();
255        Ok(
256            match tx.try_query_one(
257                "SELECT id FROM moz_places_metadata_search_queries WHERE term = :term",
258                &[(":term", &lowercase_term)],
259                true,
260            )? {
261                Some(id) => SearchQueryEntry::Existing(id),
262                None => SearchQueryEntry::CreateFor(lowercase_term),
263            },
264        )
265    }
266}
267
268impl WhereArg for SearchQueryEntry {
269    fn to_where_arg(&self, db_field: &str) -> String {
270        match self {
271            SearchQueryEntry::Existing(id) => format!("{} = {}", db_field, id),
272            SearchQueryEntry::CreateFor(_) => panic!("WhereArg: search query entry must exist"),
273        }
274    }
275}
276
277impl WhereArg for Option<SearchQueryEntry> {
278    fn to_where_arg(&self, db_field: &str) -> String {
279        match self {
280            Some(entry) => entry.to_where_arg(db_field),
281            None => format!("{} IS NULL", db_field),
282        }
283    }
284}
285
286struct HistoryMetadataCompoundKey {
287    place_entry: PlaceEntry,
288    referrer_entry: Option<PlaceEntry>,
289    search_query_entry: Option<SearchQueryEntry>,
290}
291
292struct MetadataObservation {
293    document_type: Option<DocumentType>,
294    view_time: Option<i32>,
295}
296
297impl HistoryMetadataCompoundKey {
298    fn can_debounce(&self) -> Option<i64> {
299        match self.place_entry {
300            PlaceEntry::Existing(id) => {
301                if (match self.search_query_entry {
302                    None | Some(SearchQueryEntry::Existing(_)) => true,
303                    Some(SearchQueryEntry::CreateFor(_)) => false,
304                } && match self.referrer_entry {
305                    None | Some(PlaceEntry::Existing(_)) => true,
306                    Some(PlaceEntry::CreateFor(_, _)) => false,
307                }) {
308                    Some(id)
309                } else {
310                    None
311                }
312            }
313            _ => None,
314        }
315    }
316
317    // Looks up matching metadata records, by the compound key and time window.
318    fn lookup(&self, tx: &PlacesTransaction<'_>, newer_than: i64) -> Result<Option<i64>> {
319        Ok(match self.can_debounce() {
320            Some(id) => {
321                let search_query_id = match self.search_query_entry {
322                    None | Some(SearchQueryEntry::CreateFor(_)) => None,
323                    Some(SearchQueryEntry::Existing(id)) => Some(id),
324                };
325
326                let referrer_place_id = match self.referrer_entry {
327                    None | Some(PlaceEntry::CreateFor(_, _)) => None,
328                    Some(PlaceEntry::Existing(id)) => Some(id),
329                };
330
331                tx.try_query_one::<i64, _>(
332                    "SELECT id FROM moz_places_metadata
333                        WHERE
334                            place_id IS :place_id AND
335                            referrer_place_id IS :referrer_place_id AND
336                            search_query_id IS :search_query_id AND
337                            updated_at >= :newer_than
338                        ORDER BY updated_at DESC LIMIT 1",
339                    rusqlite::named_params! {
340                        ":place_id": id,
341                        ":search_query_id": search_query_id,
342                        ":referrer_place_id": referrer_place_id,
343                        ":newer_than": newer_than
344                    },
345                    true,
346                )?
347            }
348            None => None,
349        })
350    }
351}
352
353const DEBOUNCE_WINDOW_MS: i64 = 2 * 60 * 1000; // 2 minutes
354const MAX_QUERY_RESULTS: i32 = 1000;
355
356const COMMON_METADATA_SELECT: &str = "
357SELECT
358    m.id as metadata_id, p.url as url, p.title as title, p.preview_image_url as preview_image_url,
359    m.created_at as created_at, m.updated_at as updated_at, m.total_view_time as total_view_time,
360    m.document_type as document_type, o.url as referrer_url, s.term as search_term
361FROM moz_places_metadata m
362LEFT JOIN moz_places p ON m.place_id = p.id
363LEFT JOIN moz_places_metadata_search_queries s ON m.search_query_id = s.id
364LEFT JOIN moz_places o ON o.id = m.referrer_place_id";
365
366// Highlight query returns moz_places entries ranked by a "highlight score".
367// This score takes into account two factors:
368// 1) frequency of visits to a page,
369// 2) cumulative view time of a page.
370//
371// Eventually, we could consider combining this with `moz_places.frecency` as a basis for (1), that assumes we have a populated moz_historyvisits table.
372// Currently, iOS doesn't use 'places' library to track visits, so iOS clients won't have meaningful frecency scores.
373//
374// Instead, we use moz_places_metadata entries to compute both (1) and (2).
375// This has several nice properties:
376// - it works on clients that only use 'metadata' APIs, not 'places'
377// - since metadata is capped by clients to a certain time window (via `delete_older_than`), the scores will be computed for the same time window
378// - we debounce metadata observations to the same "key" if they're close in time.
379// -- this is an equivalent of saying that if a page was visited multiple times in quick succession, treat that as a single visit while accumulating the view time
380// -- the assumption we're making is that this better matches user perception of their browsing activity
381//
382// The score is computed as a weighted sum of two probabilities:
383// - at any given moment in my browsing sessions for the past X days, how likely am I to be looking at a page?
384// - for any given visit during my browsing sessions for the past X days, how likely am I to visit a page?
385//
386// This kind of scoring is fairly intuitive and simple to reason about at the product level.
387//
388// An alternative way to arrive at the same ranking would be to normalize the values to compare data of different dimensions, time vs frequency.
389// We can normalize view time and frequency into a 0-1 scale before computing weighted scores.
390// (select place_id, (normal_frequency * 1.0 + normal_view_time * 1.0) as score from
391//     (select place_id, cast(count(*) - min_f as REAL) / cast(range_f as REAL) as normal_frequency, cast(sum(total_view_time) - min_v as REAL) / cast(max_v as REAL) as normal_view_time from moz_places_metadata,
392//     (select min(frequency) as min_f, max(frequency) as max_f, max(frequency) - min(frequency) as range_f
393//         from (select count(*) as frequency from moz_places_metadata group by place_id)
394//     ),
395//     (select min(view_time) as min_v, max(view_time) as max_v, max(view_time) - min(view_time) as range_v
396//         from (select sum(total_view_time) as view_time from moz_places_metadata where total_view_time > 0 group by place_id)
397//     ) where total_view_time > 0 group by place_id)) ranked
398//
399// Note that while it's tempting to use built-in window functions such percent_rank, they're not sufficient.
400// The built-in functions concern themselves with absolute ranking, not taking into account magnitudes of differences between values.
401// For example, given two entries we'll know that one is larger than another, but not by how much.
402const HIGHLIGHTS_QUERY: &str = "
403SELECT
404    IFNULL(ranked.score, 0.0) AS score, p.id AS place_id, p.url AS url, p.title AS title, p.preview_image_url AS preview_image_url
405FROM moz_places p
406INNER JOIN
407    (
408        SELECT place_id, :view_time_weight * view_time_prob + :frequency_weight * frequency_prob AS score FROM (
409            SELECT
410                place_id,
411                CAST(count(*) AS REAL) / total_count AS frequency_prob,
412                CAST(sum(total_view_time) AS REAL) / all_view_time AS view_time_prob
413                FROM (
414                    SELECT place_id, count(*) OVER () AS total_count, total_view_time, sum(total_view_time) OVER () AS all_view_time FROM moz_places_metadata
415                )
416            GROUP BY place_id
417        )
418    ) ranked
419ON p.id = ranked.place_id
420ORDER BY ranked.score DESC
421LIMIT :limit";
422
423lazy_static! {
424    static ref GET_LATEST_SQL: String = format!(
425        "{common_select_sql}
426        WHERE p.url_hash = hash(:url) AND p.url = :url
427        ORDER BY updated_at DESC, metadata_id DESC
428        LIMIT 1",
429        common_select_sql = COMMON_METADATA_SELECT
430    );
431    static ref GET_BETWEEN_SQL: String = format!(
432        "{common_select_sql}
433        WHERE updated_at BETWEEN :start AND :end
434        ORDER BY updated_at DESC
435        LIMIT {max_limit}",
436        common_select_sql = COMMON_METADATA_SELECT,
437        max_limit = MAX_QUERY_RESULTS
438    );
439    static ref GET_SINCE_SQL: String = format!(
440        "{common_select_sql}
441        WHERE updated_at >= :start
442        ORDER BY updated_at DESC
443        LIMIT :limit",
444        common_select_sql = COMMON_METADATA_SELECT
445    );
446    static ref QUERY_SQL: String = format!(
447        "{common_select_sql}
448        WHERE
449            p.url LIKE :query OR
450            p.title LIKE :query OR
451            search_term LIKE :query
452        ORDER BY total_view_time DESC
453        LIMIT :limit",
454        common_select_sql = COMMON_METADATA_SELECT
455    );
456}
457
458pub fn get_latest_for_url(db: &PlacesDb, url: &Url) -> Result<Option<HistoryMetadata>> {
459    let metadata = db.try_query_row(
460        GET_LATEST_SQL.as_str(),
461        &[(":url", &url.as_str())],
462        HistoryMetadata::from_row,
463        true,
464    )?;
465    Ok(metadata)
466}
467
468pub fn get_between(db: &PlacesDb, start: i64, end: i64) -> Result<Vec<HistoryMetadata>> {
469    db.query_rows_and_then_cached(
470        GET_BETWEEN_SQL.as_str(),
471        rusqlite::named_params! {
472            ":start": start,
473            ":end": end,
474        },
475        HistoryMetadata::from_row,
476    )
477}
478
479// Returns all history metadata updated on or after `start`, ordered by most recent first,
480// capped at the default `MAX_QUERY_RESULTS`.
481// TODO(Bug 1993213): Once both iOS and Android consumers use `get_most_recent` instead of `get_since`,
482// we should remove `get_since`.
483pub fn get_since(db: &PlacesDb, start: i64) -> Result<Vec<HistoryMetadata>> {
484    db.query_rows_and_then_cached(
485        GET_SINCE_SQL.as_str(),
486        rusqlite::named_params! {
487            ":start": start,
488            ":limit": MAX_QUERY_RESULTS,
489        },
490        HistoryMetadata::from_row,
491    )
492}
493
494// Returns the most recent history metadata entries (newest first),
495// limited by `limit`.
496//
497// Internally this uses [`GET_SINCE_SQL`] with `start = i64::MIN`
498// to include all entries, ordered by descending `updated_at`.
499pub fn get_most_recent(db: &PlacesDb, limit: i32) -> Result<Vec<HistoryMetadata>> {
500    db.query_rows_and_then_cached(
501        GET_SINCE_SQL.as_str(),
502        rusqlite::named_params! {
503            ":start": i64::MIN,
504            ":limit": limit,
505        },
506        HistoryMetadata::from_row,
507    )
508}
509
510pub fn get_highlights(
511    db: &PlacesDb,
512    weights: HistoryHighlightWeights,
513    limit: i32,
514) -> Result<Vec<HistoryHighlight>> {
515    db.query_rows_and_then_cached(
516        HIGHLIGHTS_QUERY,
517        rusqlite::named_params! {
518            ":view_time_weight": weights.view_time,
519            ":frequency_weight": weights.frequency,
520            ":limit": limit
521        },
522        HistoryHighlight::from_row,
523    )
524}
525
526pub fn query(db: &PlacesDb, query: &str, limit: i32) -> Result<Vec<HistoryMetadata>> {
527    db.query_rows_and_then_cached(
528        QUERY_SQL.as_str(),
529        rusqlite::named_params! {
530            ":query": format!("%{}%", query),
531            ":limit": limit
532        },
533        HistoryMetadata::from_row,
534    )
535}
536
537pub fn delete_older_than(db: &PlacesDb, older_than: i64) -> Result<()> {
538    db.execute_cached(
539        "DELETE FROM moz_places_metadata
540         WHERE updated_at < :older_than",
541        &[(":older_than", &older_than)],
542    )?;
543    Ok(())
544}
545
546pub fn delete_between(db: &PlacesDb, start: i64, end: i64) -> Result<()> {
547    db.execute_cached(
548        "DELETE FROM moz_places_metadata
549        WHERE updated_at > :start and updated_at < :end",
550        &[(":start", &start), (":end", &end)],
551    )?;
552    Ok(())
553}
554
555/// Delete all metadata for the specified place id.
556pub fn delete_all_metadata_for_page(db: &PlacesDb, place_id: RowId) -> Result<()> {
557    db.execute_cached(
558        "DELETE FROM moz_places_metadata
559         WHERE place_id = :place_id",
560        &[(":place_id", &place_id)],
561    )?;
562    Ok(())
563}
564
565pub fn delete_metadata(
566    db: &PlacesDb,
567    url: &Url,
568    referrer_url: Option<&Url>,
569    search_term: Option<&str>,
570) -> Result<()> {
571    let tx = db.begin_transaction()?;
572
573    // Only delete entries that exactly match the key (url+referrer+search_term) we were passed-in.
574    // Do nothing if we were asked to delete a key which doesn't match what's in the database.
575    // e.g. referrer_url.is_some(), but a correspodning moz_places entry doesn't exist.
576    // In practice this shouldn't happen, or it may imply API misuse, but in either case we shouldn't
577    // delete things we were not asked to delete.
578    let place_entry = PlaceEntry::fetch(url.as_str(), &tx, None)?;
579    let place_entry = match place_entry {
580        PlaceEntry::Existing(_) => place_entry,
581        PlaceEntry::CreateFor(_, _) => {
582            tx.rollback()?;
583            return Ok(());
584        }
585    };
586    let referrer_entry = match referrer_url {
587        Some(referrer_url) if !referrer_url.as_str().is_empty() => {
588            Some(PlaceEntry::fetch(referrer_url.as_str(), &tx, None)?)
589        }
590        _ => None,
591    };
592    let referrer_entry = match referrer_entry {
593        Some(PlaceEntry::Existing(_)) | None => referrer_entry,
594        Some(PlaceEntry::CreateFor(_, _)) => {
595            tx.rollback()?;
596            return Ok(());
597        }
598    };
599    let search_query_entry = match search_term {
600        Some(search_term) if !search_term.is_empty() => {
601            Some(SearchQueryEntry::from(search_term, &tx)?)
602        }
603        _ => None,
604    };
605    let search_query_entry = match search_query_entry {
606        Some(SearchQueryEntry::Existing(_)) | None => search_query_entry,
607        Some(SearchQueryEntry::CreateFor(_)) => {
608            tx.rollback()?;
609            return Ok(());
610        }
611    };
612
613    let sql = format!(
614        "DELETE FROM moz_places_metadata WHERE {} AND {} AND {}",
615        place_entry.to_where_arg("place_id"),
616        referrer_entry.to_where_arg("referrer_place_id"),
617        search_query_entry.to_where_arg("search_query_id")
618    );
619
620    tx.execute_cached(&sql, [])?;
621    tx.commit()?;
622
623    Ok(())
624}
625
626pub fn apply_metadata_observation(
627    db: &PlacesDb,
628    observation: HistoryMetadataObservation,
629    options: NoteHistoryMetadataObservationOptions,
630) -> Result<()> {
631    if let Some(view_time) = observation.view_time {
632        // Consider any view_time observations that are higher than 24hrs to be invalid.
633        // This guards against clients passing us wildly inaccurate view_time observations,
634        // likely resulting from some measurement bug. If we detect such cases, we fail so
635        // that the client has a chance to discover its mistake.
636        // When recording a view time, we increment the stored value directly in SQL, which
637        // doesn't allow for error detection unless we run an additional SELECT statement to
638        // query current cumulative view time and see if incrementing it will result in an
639        // overflow. This check is a simpler way to achieve the same goal (detect invalid inputs).
640        if view_time > 1000 * 60 * 60 * 24 {
641            return Err(InvalidMetadataObservation::ViewTimeTooLong.into());
642        }
643    }
644
645    // Begin a write transaction. We do this before any other work (e.g. SELECTs) to avoid racing against
646    // other writers. Even though we expect to only have a single application writer, a sync writer
647    // can come in at any time and change data we depend on, such as moz_places
648    // and moz_origins, leaving us in a potentially inconsistent state.
649    let tx = db.begin_transaction()?;
650
651    let place_entry = PlaceEntry::fetch(&observation.url, &tx, observation.title.clone())?;
652    let result = apply_metadata_observation_impl(&tx, place_entry, observation, options);
653
654    // Inserting into moz_places has side-effects (temp tables are populated via triggers and need to be flushed).
655    // This call "finalizes" these side-effects.
656    super::delete_pending_temp_tables(db)?;
657    match result {
658        Ok(_) => tx.commit()?,
659        Err(_) => tx.rollback()?,
660    };
661
662    result
663}
664
665fn apply_metadata_observation_impl(
666    tx: &PlacesTransaction<'_>,
667    place_entry: PlaceEntry,
668    observation: HistoryMetadataObservation,
669    options: NoteHistoryMetadataObservationOptions,
670) -> Result<()> {
671    let referrer_entry = match observation.referrer_url {
672        Some(referrer_url) if !referrer_url.is_empty() => {
673            Some(PlaceEntry::fetch(&referrer_url, tx, None)?)
674        }
675        Some(_) | None => None,
676    };
677    let search_query_entry = match observation.search_term {
678        Some(search_term) if !search_term.is_empty() => {
679            Some(SearchQueryEntry::from(&search_term, tx)?)
680        }
681        Some(_) | None => None,
682    };
683
684    let compound_key = HistoryMetadataCompoundKey {
685        place_entry,
686        referrer_entry,
687        search_query_entry,
688    };
689
690    let observation = MetadataObservation {
691        document_type: observation.document_type,
692        view_time: observation.view_time,
693    };
694
695    let now = Timestamp::now().as_millis() as i64;
696    let newer_than = now - DEBOUNCE_WINDOW_MS;
697    let matching_metadata = compound_key.lookup(tx, newer_than)?;
698
699    // If a matching record exists, update it; otherwise, insert a new one.
700    match matching_metadata {
701        Some(metadata_id) => {
702            // If document_type isn't part of the observation, make sure we don't accidentally erase what's currently set.
703            match observation {
704                MetadataObservation {
705                    document_type: Some(dt),
706                    view_time,
707                } => {
708                    tx.execute_cached(
709                        "UPDATE
710                            moz_places_metadata
711                        SET
712                            document_type = :document_type,
713                            total_view_time = total_view_time + :view_time_delta,
714                            updated_at = :updated_at
715                        WHERE id = :id",
716                        rusqlite::named_params! {
717                            ":id": metadata_id,
718                            ":document_type": dt,
719                            ":view_time_delta": view_time.unwrap_or(0),
720                            ":updated_at": now
721                        },
722                    )?;
723                }
724                MetadataObservation {
725                    document_type: None,
726                    view_time,
727                } => {
728                    tx.execute_cached(
729                        "UPDATE
730                            moz_places_metadata
731                        SET
732                            total_view_time = total_view_time + :view_time_delta,
733                            updated_at = :updated_at
734                        WHERE id = :id",
735                        rusqlite::named_params! {
736                            ":id": metadata_id,
737                            ":view_time_delta": view_time.unwrap_or(0),
738                            ":updated_at": now
739                        },
740                    )?;
741                }
742            }
743            Ok(())
744        }
745        None => insert_metadata_in_tx(tx, compound_key, observation, options),
746    }
747}
748
749fn insert_metadata_in_tx(
750    tx: &PlacesTransaction<'_>,
751    key: HistoryMetadataCompoundKey,
752    observation: MetadataObservation,
753    options: NoteHistoryMetadataObservationOptions,
754) -> Result<()> {
755    let now = Timestamp::now();
756
757    let referrer_place_id = match key.referrer_entry {
758        None => None,
759        Some(entry) => Some(entry.get_or_insert(tx)?),
760    };
761
762    let search_query_id = match key.search_query_entry {
763        None => None,
764        Some(entry) => Some(entry.get_or_insert(tx)?),
765    };
766
767    // Heavy lifting around moz_places inserting (e.g. updating moz_origins, frecency, etc) is performed via triggers.
768    // This lets us simply INSERT here without worrying about the rest.
769    let place_id = match (key.place_entry, options.if_page_missing) {
770        (PlaceEntry::Existing(id), _) => id,
771        (PlaceEntry::CreateFor(_, _), HistoryMetadataPageMissingBehavior::IgnoreObservation) => {
772            return Ok(())
773        }
774        (
775            ref entry @ PlaceEntry::CreateFor(_, _),
776            HistoryMetadataPageMissingBehavior::InsertPage,
777        ) => entry.get_or_insert(tx)?,
778    };
779
780    let sql = "INSERT INTO moz_places_metadata
781        (place_id, created_at, updated_at, total_view_time, search_query_id, document_type, referrer_place_id)
782    VALUES
783        (:place_id, :created_at, :updated_at, :total_view_time, :search_query_id, :document_type, :referrer_place_id)";
784
785    tx.execute_cached(
786        sql,
787        &[
788            (":place_id", &place_id as &dyn rusqlite::ToSql),
789            (":created_at", &now),
790            (":updated_at", &now),
791            (":search_query_id", &search_query_id),
792            (":referrer_place_id", &referrer_place_id),
793            (
794                ":document_type",
795                &observation.document_type.unwrap_or(DocumentType::Regular),
796            ),
797            (":total_view_time", &observation.view_time.unwrap_or(0)),
798        ],
799    )?;
800
801    Ok(())
802}
803
804#[cfg(test)]
805mod tests {
806    use super::*;
807    use crate::api::places_api::ConnectionType;
808    use crate::observation::VisitObservation;
809    use crate::storage::bookmarks::{
810        get_raw_bookmark, insert_bookmark, BookmarkPosition, BookmarkRootGuid, InsertableBookmark,
811        InsertableItem,
812    };
813    use crate::storage::fetch_page_info;
814    use crate::storage::history::{
815        apply_observation, delete_everything, delete_visits_between, delete_visits_for,
816        get_visit_count, url_to_guid,
817    };
818    use crate::types::VisitType;
819    use crate::VisitTransitionSet;
820    use std::{thread, time};
821
822    // NOTE: `updated_at` timestamps have millisecond precision, so multiple observations
823    // written in the same millisecond can in theory share the same value. To avoid flaky
824    // ordering in tests ( since we have `ORDER BY updated_at` in a few queries )
825    // this helper sleeps briefly to avoid having overlapping timestamps.
826    fn bump_clock() {
827        thread::sleep(time::Duration::from_millis(10));
828    }
829
830    macro_rules! assert_table_size {
831        ($conn:expr, $table:expr, $count:expr) => {
832            assert_eq!(
833                $count,
834                $conn
835                    .try_query_one::<i64, _>(
836                        format!("SELECT count(*) FROM {table}", table = $table).as_str(),
837                        [],
838                        true
839                    )
840                    .expect("select works")
841                    .expect("got count")
842            );
843        };
844    }
845
846    macro_rules! assert_history_metadata_record {
847        ($record:expr, url $url:expr, total_time $tvt:expr, search_term $search_term:expr, document_type $document_type:expr, referrer_url $referrer_url:expr, title $title:expr, preview_image_url $preview_image_url:expr) => {
848            assert_eq!(String::from($url), $record.url, "url must match");
849            assert_eq!($tvt, $record.total_view_time, "total_view_time must match");
850            assert_eq!($document_type, $record.document_type, "is_media must match");
851
852            let meta = $record.clone(); // ugh... not sure why this `clone` is necessary.
853
854            match $search_term as Option<&str> {
855                Some(t) => assert_eq!(
856                    String::from(t),
857                    meta.search_term.expect("search_term must be Some"),
858                    "search_term must match"
859                ),
860                None => assert_eq!(
861                    true,
862                    meta.search_term.is_none(),
863                    "search_term expected to be None"
864                ),
865            };
866            match $referrer_url as Option<&str> {
867                Some(t) => assert_eq!(
868                    String::from(t),
869                    meta.referrer_url.expect("referrer_url must be Some"),
870                    "referrer_url must match"
871                ),
872                None => assert_eq!(
873                    true,
874                    meta.referrer_url.is_none(),
875                    "referrer_url expected to be None"
876                ),
877            };
878            match $title as Option<&str> {
879                Some(t) => assert_eq!(
880                    String::from(t),
881                    meta.title.expect("title must be Some"),
882                    "title must match"
883                ),
884                None => assert_eq!(true, meta.title.is_none(), "title expected to be None"),
885            };
886            match $preview_image_url as Option<&str> {
887                Some(t) => assert_eq!(
888                    String::from(t),
889                    meta.preview_image_url
890                        .expect("preview_image_url must be Some"),
891                    "preview_image_url must match"
892                ),
893                None => assert_eq!(
894                    true,
895                    meta.preview_image_url.is_none(),
896                    "preview_image_url expected to be None"
897                ),
898            };
899        };
900    }
901
902    macro_rules! assert_total_after_observation {
903        ($conn:expr, total_records_after $total_records:expr, total_view_time_after $total_view_time:expr, url $url:expr, view_time $view_time:expr, search_term $search_term:expr, document_type $document_type:expr, referrer_url $referrer_url:expr, title $title:expr) => {
904            note_observation!($conn,
905                url $url,
906                view_time $view_time,
907                search_term $search_term,
908                document_type $document_type,
909                referrer_url $referrer_url,
910                title $title
911            );
912
913            assert_table_size!($conn, "moz_places_metadata", $total_records);
914            let updated = get_latest_for_url($conn, &Url::parse($url).unwrap()).unwrap().unwrap();
915            assert_eq!($total_view_time, updated.total_view_time, "total view time must match");
916        }
917    }
918
919    macro_rules! note_observation {
920        ($conn:expr, url $url:expr, view_time $view_time:expr, search_term $search_term:expr, document_type $document_type:expr, referrer_url $referrer_url:expr, title $title:expr) => {
921            note_observation!(
922                $conn,
923                NoteHistoryMetadataObservationOptions::new()
924                    .if_page_missing(HistoryMetadataPageMissingBehavior::InsertPage),
925                url $url,
926                view_time $view_time,
927                search_term $search_term,
928                document_type $document_type,
929                referrer_url $referrer_url,
930                title $title
931            )
932        };
933        ($conn:expr, $options:expr, url $url:expr, view_time $view_time:expr, search_term $search_term:expr, document_type $document_type:expr, referrer_url $referrer_url:expr, title $title:expr) => {
934            apply_metadata_observation(
935                $conn,
936                HistoryMetadataObservation {
937                    url: String::from($url),
938                    view_time: $view_time,
939                    search_term: $search_term.map(|s: &str| s.to_string()),
940                    document_type: $document_type,
941                    referrer_url: $referrer_url.map(|s: &str| s.to_string()),
942                    title: $title.map(|s: &str| s.to_string()),
943                },
944                $options,
945            )
946            .unwrap();
947        };
948    }
949
950    macro_rules! assert_after_observation {
951        ($conn:expr, total_records_after $total_records:expr, total_view_time_after $total_view_time:expr, url $url:expr, view_time $view_time:expr, search_term $search_term:expr, document_type $document_type:expr, referrer_url $referrer_url:expr, title $title:expr, assertion $assertion:expr) => {
952            // can set title on creating a new record
953            assert_total_after_observation!($conn,
954                total_records_after $total_records,
955                total_view_time_after $total_view_time,
956                url $url,
957                view_time $view_time,
958                search_term $search_term,
959                document_type $document_type,
960                referrer_url $referrer_url,
961                title $title
962            );
963
964            let m = get_latest_for_url(
965                $conn,
966                &Url::parse(&String::from($url)).unwrap(),
967            )
968            .unwrap()
969            .unwrap();
970            #[allow(clippy::redundant_closure_call)]
971            $assertion(m);
972        }
973    }
974
975    #[test]
976    fn test_note_observation() {
977        let conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).unwrap();
978
979        assert_table_size!(&conn, "moz_places_metadata", 0);
980
981        assert_total_after_observation!(&conn,
982            total_records_after 1,
983            total_view_time_after 1500,
984            url "http://mozilla.com/",
985            view_time Some(1500),
986            search_term None,
987            document_type Some(DocumentType::Regular),
988            referrer_url None,
989            title None
990        );
991
992        // debounced! total time was updated
993        assert_total_after_observation!(&conn,
994            total_records_after 1,
995            total_view_time_after 2500,
996            url "http://mozilla.com/",
997            view_time Some(1000),
998            search_term None,
999            document_type Some(DocumentType::Regular),
1000            referrer_url None,
1001            title None
1002        );
1003
1004        // different document type, record updated
1005        assert_total_after_observation!(&conn,
1006            total_records_after 1,
1007            total_view_time_after 3500,
1008            url "http://mozilla.com/",
1009            view_time Some(1000),
1010            search_term None,
1011            document_type Some(DocumentType::Media),
1012            referrer_url None,
1013            title None
1014        );
1015
1016        // referrer set
1017        assert_total_after_observation!(&conn,
1018            total_records_after 2,
1019            total_view_time_after 2000,
1020            url "http://mozilla.com/",
1021            view_time Some(2000),
1022            search_term None,
1023            document_type Some(DocumentType::Media),
1024            referrer_url Some("https://news.website"),
1025            title None
1026        );
1027
1028        // search term and referrer are set
1029        assert_total_after_observation!(&conn,
1030            total_records_after 3,
1031            total_view_time_after 1100,
1032            url "http://mozilla.com/",
1033            view_time Some(1100),
1034            search_term Some("firefox"),
1035            document_type Some(DocumentType::Media),
1036            referrer_url Some("https://www.google.com/search?client=firefox-b-d&q=firefox"),
1037            title None
1038        );
1039
1040        // debounce!
1041        assert_total_after_observation!(&conn,
1042            total_records_after 3,
1043            total_view_time_after 6100,
1044            url "http://mozilla.com/",
1045            view_time Some(5000),
1046            search_term Some("firefox"),
1047            document_type Some(DocumentType::Media),
1048            referrer_url Some("https://www.google.com/search?client=firefox-b-d&q=firefox"),
1049            title None
1050        );
1051
1052        // different url now
1053        assert_total_after_observation!(&conn,
1054            total_records_after 4,
1055            total_view_time_after 3000,
1056            url "http://mozilla.com/another",
1057            view_time Some(3000),
1058            search_term None,
1059            document_type Some(DocumentType::Regular),
1060            referrer_url Some("https://news.website/tech"),
1061            title None
1062        );
1063
1064        // shared origin for both url and referrer
1065        assert_total_after_observation!(&conn,
1066            total_records_after 5,
1067            total_view_time_after 100000,
1068            url "https://www.youtube.com/watch?v=tpiyEe_CqB4",
1069            view_time Some(100000),
1070            search_term Some("cute cat"),
1071            document_type Some(DocumentType::Media),
1072            referrer_url Some("https://www.youtube.com/results?search_query=cute+cat"),
1073            title None
1074        );
1075
1076        // empty search term/referrer url are treated the same as None
1077        assert_total_after_observation!(&conn,
1078            total_records_after 6,
1079            total_view_time_after 80000,
1080            url "https://www.youtube.com/watch?v=daff43jif3",
1081            view_time Some(80000),
1082            search_term Some(""),
1083            document_type Some(DocumentType::Media),
1084            referrer_url Some(""),
1085            title None
1086        );
1087
1088        assert_total_after_observation!(&conn,
1089            total_records_after 6,
1090            total_view_time_after 90000,
1091            url "https://www.youtube.com/watch?v=daff43jif3",
1092            view_time Some(10000),
1093            search_term None,
1094            document_type Some(DocumentType::Media),
1095            referrer_url None,
1096            title None
1097        );
1098
1099        // document type recording
1100        assert_total_after_observation!(&conn,
1101            total_records_after 7,
1102            total_view_time_after 0,
1103            url "https://www.youtube.com/watch?v=fds32fds",
1104            view_time None,
1105            search_term None,
1106            document_type Some(DocumentType::Media),
1107            referrer_url None,
1108            title None
1109        );
1110
1111        // now, update the view time as a separate call
1112        assert_total_after_observation!(&conn,
1113            total_records_after 7,
1114            total_view_time_after 1338,
1115            url "https://www.youtube.com/watch?v=fds32fds",
1116            view_time Some(1338),
1117            search_term None,
1118            document_type None,
1119            referrer_url None,
1120            title None
1121        );
1122
1123        // and again, bump the view time
1124        assert_total_after_observation!(&conn,
1125            total_records_after 7,
1126            total_view_time_after 2000,
1127            url "https://www.youtube.com/watch?v=fds32fds",
1128            view_time Some(662),
1129            search_term None,
1130            document_type None,
1131            referrer_url None,
1132            title None
1133        );
1134
1135        // now try the other way - record view time first, document type after.
1136        // and again, bump the view time
1137        assert_after_observation!(&conn,
1138            total_records_after 8,
1139            total_view_time_after 662,
1140            url "https://www.youtube.com/watch?v=dasdg34d",
1141            view_time Some(662),
1142            search_term None,
1143            document_type None,
1144            referrer_url None,
1145            title None,
1146            assertion |m: HistoryMetadata| { assert_eq!(DocumentType::Regular, m.document_type) }
1147        );
1148
1149        assert_after_observation!(&conn,
1150            total_records_after 8,
1151            total_view_time_after 662,
1152            url "https://www.youtube.com/watch?v=dasdg34d",
1153            view_time None,
1154            search_term None,
1155            document_type Some(DocumentType::Media),
1156            referrer_url None,
1157            title None,
1158            assertion |m: HistoryMetadata| { assert_eq!(DocumentType::Media, m.document_type) }
1159        );
1160
1161        // document type not overwritten (e.g. remains 1, not default 0).
1162        assert_after_observation!(&conn,
1163            total_records_after 8,
1164            total_view_time_after 675,
1165            url "https://www.youtube.com/watch?v=dasdg34d",
1166            view_time Some(13),
1167            search_term None,
1168            document_type None,
1169            referrer_url None,
1170            title None,
1171            assertion |m: HistoryMetadata| { assert_eq!(DocumentType::Media, m.document_type) }
1172        );
1173
1174        // can set title on creating a new record
1175        assert_after_observation!(&conn,
1176            total_records_after 9,
1177            total_view_time_after 13,
1178            url "https://www.youtube.com/watch?v=dasdsada",
1179            view_time Some(13),
1180            search_term None,
1181            document_type None,
1182            referrer_url None,
1183            title Some("hello!"),
1184            assertion |m: HistoryMetadata| { assert_eq!(Some(String::from("hello!")), m.title) }
1185        );
1186
1187        // can not update title after
1188        assert_after_observation!(&conn,
1189            total_records_after 9,
1190            total_view_time_after 26,
1191            url "https://www.youtube.com/watch?v=dasdsada",
1192            view_time Some(13),
1193            search_term None,
1194            document_type None,
1195            referrer_url None,
1196            title Some("world!"),
1197            assertion |m: HistoryMetadata| { assert_eq!(Some(String::from("hello!")), m.title) }
1198        );
1199    }
1200
1201    #[test]
1202    fn test_note_observation_invalid_view_time() {
1203        let conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).expect("memory db");
1204
1205        note_observation!(&conn,
1206            url "https://www.mozilla.org/",
1207            view_time None,
1208            search_term None,
1209            document_type Some(DocumentType::Regular),
1210            referrer_url None,
1211            title None
1212        );
1213
1214        // 48 hrs is clearly a bad view to observe.
1215        assert!(apply_metadata_observation(
1216            &conn,
1217            HistoryMetadataObservation {
1218                url: String::from("https://www.mozilla.org"),
1219                view_time: Some(1000 * 60 * 60 * 24 * 2),
1220                search_term: None,
1221                document_type: None,
1222                referrer_url: None,
1223                title: None
1224            },
1225            NoteHistoryMetadataObservationOptions::new(),
1226        )
1227        .is_err());
1228
1229        // 12 hrs is assumed to be "plausible".
1230        assert!(apply_metadata_observation(
1231            &conn,
1232            HistoryMetadataObservation {
1233                url: String::from("https://www.mozilla.org"),
1234                view_time: Some(1000 * 60 * 60 * 12),
1235                search_term: None,
1236                document_type: None,
1237                referrer_url: None,
1238                title: None
1239            },
1240            NoteHistoryMetadataObservationOptions::new(),
1241        )
1242        .is_ok());
1243    }
1244
1245    #[test]
1246    fn test_get_between() {
1247        let conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).expect("memory db");
1248
1249        assert_eq!(0, get_between(&conn, 0, 0).unwrap().len());
1250
1251        let beginning = Timestamp::now().as_millis() as i64;
1252        note_observation!(&conn,
1253            url "http://mozilla.com/another",
1254            view_time Some(3000),
1255            search_term None,
1256            document_type Some(DocumentType::Regular),
1257            referrer_url Some("https://news.website/tech"),
1258            title None
1259        );
1260        let after_meta1 = Timestamp::now().as_millis() as i64;
1261
1262        assert_eq!(0, get_between(&conn, 0, beginning - 1).unwrap().len());
1263        assert_eq!(1, get_between(&conn, 0, after_meta1).unwrap().len());
1264
1265        bump_clock();
1266
1267        note_observation!(&conn,
1268            url "http://mozilla.com/video/",
1269            view_time Some(1000),
1270            search_term None,
1271            document_type Some(DocumentType::Media),
1272            referrer_url None,
1273            title None
1274        );
1275        let after_meta2 = Timestamp::now().as_millis() as i64;
1276
1277        assert_eq!(1, get_between(&conn, beginning, after_meta1).unwrap().len());
1278        assert_eq!(2, get_between(&conn, beginning, after_meta2).unwrap().len());
1279        assert_eq!(
1280            1,
1281            get_between(&conn, after_meta1, after_meta2).unwrap().len()
1282        );
1283        assert_eq!(
1284            0,
1285            get_between(&conn, after_meta2, after_meta2 + 1)
1286                .unwrap()
1287                .len()
1288        );
1289    }
1290
1291    #[test]
1292    fn test_get_since() {
1293        let conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).expect("memory db");
1294
1295        assert_eq!(0, get_since(&conn, 0).unwrap().len());
1296
1297        let beginning = Timestamp::now().as_millis() as i64;
1298        note_observation!(&conn,
1299            url "http://mozilla.com/another",
1300            view_time Some(3000),
1301            search_term None,
1302            document_type Some(DocumentType::Regular),
1303            referrer_url Some("https://news.website/tech"),
1304            title None
1305        );
1306        let after_meta1 = Timestamp::now().as_millis() as i64;
1307
1308        assert_eq!(1, get_since(&conn, 0).unwrap().len());
1309        assert_eq!(1, get_since(&conn, beginning).unwrap().len());
1310        assert_eq!(0, get_since(&conn, after_meta1).unwrap().len());
1311
1312        // thread::sleep(time::Duration::from_millis(50));
1313
1314        note_observation!(&conn,
1315            url "http://mozilla.com/video/",
1316            view_time Some(1000),
1317            search_term None,
1318            document_type Some(DocumentType::Media),
1319            referrer_url None,
1320            title None
1321        );
1322        let after_meta2 = Timestamp::now().as_millis() as i64;
1323        assert_eq!(2, get_since(&conn, beginning).unwrap().len());
1324        assert_eq!(1, get_since(&conn, after_meta1).unwrap().len());
1325        assert_eq!(0, get_since(&conn, after_meta2).unwrap().len());
1326    }
1327
1328    #[test]
1329    fn test_get_most_recent_empty() {
1330        let conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).expect("memory db");
1331        let rows = get_most_recent(&conn, 5).expect("query ok");
1332        assert!(rows.is_empty());
1333    }
1334
1335    #[test]
1336    fn test_get_most_recent_orders_and_limits_same_observation() {
1337        let conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).expect("memory db");
1338
1339        note_observation!(&conn,
1340            url "https://example.com/1",
1341            view_time Some(10),
1342            search_term None,
1343            document_type Some(DocumentType::Regular),
1344            referrer_url None,
1345            title None
1346        );
1347
1348        bump_clock();
1349
1350        note_observation!(&conn,
1351            url "https://example.com/1",
1352            view_time Some(10),
1353            search_term None,
1354            document_type Some(DocumentType::Regular),
1355            referrer_url None,
1356            title None
1357        );
1358
1359        bump_clock();
1360
1361        note_observation!(&conn,
1362            url "https://example.com/1",
1363            view_time Some(10),
1364            search_term None,
1365            document_type Some(DocumentType::Regular),
1366            referrer_url None,
1367            title None
1368        );
1369
1370        // Limiting to 1 should return the most recent entry only.
1371        let most_recents1 = get_most_recent(&conn, 1).expect("query ok");
1372        assert_eq!(most_recents1.len(), 1);
1373        assert_eq!(most_recents1[0].url, "https://example.com/1");
1374
1375        // Limiting to 3 should also return one entry, since we only have one unique URL.
1376        let most_recents2 = get_most_recent(&conn, 3).expect("query ok");
1377        assert_eq!(most_recents2.len(), 1);
1378        assert_eq!(most_recents2[0].url, "https://example.com/1");
1379
1380        // Limiting to 10 should also return one entry, since we only have one unique URL.
1381        let most_recents3 = get_most_recent(&conn, 10).expect("query ok");
1382        assert_eq!(most_recents3.len(), 1);
1383        assert_eq!(most_recents3[0].url, "https://example.com/1");
1384    }
1385
1386    #[test]
1387    fn test_get_most_recent_orders_and_limits_different_observations() {
1388        let conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).expect("memory db");
1389
1390        note_observation!(&conn,
1391            url "https://example.com/1",
1392            view_time Some(10),
1393            search_term None,
1394            document_type Some(DocumentType::Regular),
1395            referrer_url None,
1396            title None
1397        );
1398
1399        bump_clock();
1400
1401        note_observation!(&conn,
1402            url "https://example.com/2",
1403            view_time Some(20),
1404            search_term None,
1405            document_type Some(DocumentType::Regular),
1406            referrer_url None,
1407            title None
1408        );
1409
1410        bump_clock();
1411
1412        note_observation!(&conn,
1413            url "https://example.com/3",
1414            view_time Some(30),
1415            search_term None,
1416            document_type Some(DocumentType::Regular),
1417            referrer_url None,
1418            title None
1419        );
1420
1421        // Limiting to 1 should return the most recent entry only.
1422        let most_recents1 = get_most_recent(&conn, 1).expect("query ok");
1423        assert_eq!(most_recents1.len(), 1);
1424        assert_eq!(most_recents1[0].url, "https://example.com/3");
1425
1426        // Limiting to 2 should return the two most recent entries.
1427        let most_recents2 = get_most_recent(&conn, 2).expect("query ok");
1428        assert_eq!(most_recents2.len(), 2);
1429        assert_eq!(most_recents2[0].url, "https://example.com/3");
1430        assert_eq!(most_recents2[1].url, "https://example.com/2");
1431
1432        // Limiting to 10 should return all three entries, in the correct order.
1433        let most_recents3 = get_most_recent(&conn, 10).expect("query ok");
1434        assert_eq!(most_recents3.len(), 3);
1435        assert_eq!(most_recents3[0].url, "https://example.com/3");
1436        assert_eq!(most_recents3[1].url, "https://example.com/2");
1437        assert_eq!(most_recents3[2].url, "https://example.com/1");
1438    }
1439
1440    #[test]
1441    fn test_get_most_recent_negative_limit() {
1442        let conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).expect("memory db");
1443
1444        note_observation!(&conn,
1445            url "https://example.com/1",
1446            view_time Some(10),
1447            search_term None,
1448            document_type Some(DocumentType::Regular),
1449            referrer_url None,
1450            title None
1451        );
1452
1453        bump_clock();
1454
1455        note_observation!(&conn,
1456            url "https://example.com/2",
1457            view_time Some(10),
1458            search_term None,
1459            document_type Some(DocumentType::Regular),
1460            referrer_url None,
1461            title None
1462        );
1463
1464        bump_clock();
1465
1466        note_observation!(&conn,
1467            url "https://example.com/3",
1468            view_time Some(10),
1469            search_term None,
1470            document_type Some(DocumentType::Regular),
1471            referrer_url None,
1472            title None
1473        );
1474
1475        // Limiting to -1 should return all entries properly ordered.
1476        let most_recents = get_most_recent(&conn, -1).expect("query ok");
1477        assert_eq!(most_recents.len(), 3);
1478        assert_eq!(most_recents[0].url, "https://example.com/3");
1479        assert_eq!(most_recents[1].url, "https://example.com/2");
1480        assert_eq!(most_recents[2].url, "https://example.com/1");
1481    }
1482
1483    #[test]
1484    fn test_get_highlights() {
1485        let conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).expect("memory db");
1486
1487        // Empty database is fine.
1488        assert_eq!(
1489            0,
1490            get_highlights(
1491                &conn,
1492                HistoryHighlightWeights {
1493                    view_time: 1.0,
1494                    frequency: 1.0
1495                },
1496                10
1497            )
1498            .unwrap()
1499            .len()
1500        );
1501
1502        // Database with "normal" history but no metadata observations is fine.
1503        apply_observation(
1504            &conn,
1505            VisitObservation::new(
1506                Url::parse("https://www.reddit.com/r/climbing").expect("Should parse URL"),
1507            )
1508            .with_visit_type(VisitType::Link)
1509            .with_at(Timestamp::now()),
1510        )
1511        .expect("Should apply observation");
1512        assert_eq!(
1513            0,
1514            get_highlights(
1515                &conn,
1516                HistoryHighlightWeights {
1517                    view_time: 1.0,
1518                    frequency: 1.0
1519                },
1520                10
1521            )
1522            .unwrap()
1523            .len()
1524        );
1525
1526        // three observation to url1, each recording a second of view time.
1527        note_observation!(&conn,
1528            url "http://mozilla.com/1",
1529            view_time Some(1000),
1530            search_term None,
1531            document_type Some(DocumentType::Regular),
1532            referrer_url Some("https://news.website/tech"),
1533            title None
1534        );
1535
1536        note_observation!(&conn,
1537            url "http://mozilla.com/1",
1538            view_time Some(1000),
1539            search_term None,
1540            document_type Some(DocumentType::Regular),
1541            referrer_url Some("https://news.website/tech"),
1542            title None
1543        );
1544
1545        note_observation!(&conn,
1546            url "http://mozilla.com/1",
1547            view_time Some(1000),
1548            search_term None,
1549            document_type Some(DocumentType::Regular),
1550            referrer_url Some("https://news.website/tech"),
1551            title None
1552        );
1553
1554        // one observation to url2 for 3.5s of view time.
1555        note_observation!(&conn,
1556            url "http://mozilla.com/2",
1557            view_time Some(3500),
1558            search_term None,
1559            document_type Some(DocumentType::Regular),
1560            referrer_url Some("https://news.website/tech"),
1561            title None
1562        );
1563
1564        // The three visits to /2 got "debounced" into a single metadata entry (since they were made in quick succession).
1565        // We'll calculate the scoring as follows:
1566        // - for /1: 1.0 * 1/2 + 1.0 * 3000/6500 = 0.9615...
1567        // - for /2: 1.0 * 1/2 + 1.0 * 3500/6500 = 1.0384...
1568        // (above, 1/2 means 1 entry out of 2 entries total).
1569
1570        let even_weights = HistoryHighlightWeights {
1571            view_time: 1.0,
1572            frequency: 1.0,
1573        };
1574        let highlights1 = get_highlights(&conn, even_weights.clone(), 10).unwrap();
1575        assert_eq!(2, highlights1.len());
1576        assert_eq!("http://mozilla.com/2", highlights1[0].url);
1577
1578        // Since we have an equal amount of metadata entries, providing a very high view_time weight won't change the ranking.
1579        let frequency_heavy_weights = HistoryHighlightWeights {
1580            view_time: 1.0,
1581            frequency: 100.0,
1582        };
1583        let highlights2 = get_highlights(&conn, frequency_heavy_weights, 10).unwrap();
1584        assert_eq!(2, highlights2.len());
1585        assert_eq!("http://mozilla.com/2", highlights2[0].url);
1586
1587        // Now, make an observation for url /1, but with a different metadata key.
1588        // It won't debounce, producing an additional entry for /1.
1589        // Total view time for /1 is now 3100 (vs 3500 for /2).
1590        note_observation!(&conn,
1591            url "http://mozilla.com/1",
1592            view_time Some(100),
1593            search_term Some("test search"),
1594            document_type Some(DocumentType::Regular),
1595            referrer_url Some("https://news.website/tech"),
1596            title None
1597        );
1598
1599        // Since we now have 2 metadata entries for /1, it ranks higher with even weights.
1600        let highlights3 = get_highlights(&conn, even_weights, 10).unwrap();
1601        assert_eq!(2, highlights3.len());
1602        assert_eq!("http://mozilla.com/1", highlights3[0].url);
1603
1604        // With a high-enough weight for view_time, we can flip this order.
1605        // Even though we had 2x entries for /1, it now ranks second due to its lower total view time (3100 vs 3500).
1606        let view_time_heavy_weights = HistoryHighlightWeights {
1607            view_time: 6.0,
1608            frequency: 1.0,
1609        };
1610        let highlights4 = get_highlights(&conn, view_time_heavy_weights, 10).unwrap();
1611        assert_eq!(2, highlights4.len());
1612        assert_eq!("http://mozilla.com/2", highlights4[0].url);
1613    }
1614
1615    #[test]
1616    fn test_get_highlights_no_viewtime() {
1617        let conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).expect("memory db");
1618
1619        // Make sure we work if the only observations for a URL have a view time of zero.
1620        note_observation!(&conn,
1621            url "http://mozilla.com/1",
1622            view_time Some(0),
1623            search_term None,
1624            document_type Some(DocumentType::Regular),
1625            referrer_url Some("https://news.website/tech"),
1626            title None
1627        );
1628        let highlights = get_highlights(
1629            &conn,
1630            HistoryHighlightWeights {
1631                view_time: 1.0,
1632                frequency: 1.0,
1633            },
1634            2,
1635        )
1636        .unwrap();
1637        assert_eq!(highlights.len(), 1);
1638        assert_eq!(highlights[0].score, 0.0);
1639    }
1640
1641    #[test]
1642    fn test_query() {
1643        let conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).expect("memory db");
1644        let now = Timestamp::now();
1645
1646        // need a history observation to get a title query working.
1647        let observation1 = VisitObservation::new(Url::parse("https://www.cbc.ca/news/politics/federal-budget-2021-freeland-zimonjic-1.5991021").unwrap())
1648                .with_at(now)
1649                .with_title(Some(String::from("Budget vows to build &#x27;for the long term&#x27; as it promises child care cash, projects massive deficits | CBC News")))
1650                .with_preview_image_url(Some(Url::parse("https://i.cbc.ca/1.5993583.1618861792!/cpImage/httpImage/image.jpg_gen/derivatives/16x9_620/fedbudget-20210419.jpg").unwrap()))
1651                .with_is_remote(false)
1652                .with_visit_type(VisitType::Link);
1653        apply_observation(&conn, observation1).unwrap();
1654
1655        note_observation!(
1656            &conn,
1657            url "https://www.cbc.ca/news/politics/federal-budget-2021-freeland-zimonjic-1.5991021",
1658            view_time Some(20000),
1659            search_term Some("cbc federal budget 2021"),
1660            document_type Some(DocumentType::Regular),
1661            referrer_url Some("https://yandex.ru/search/?text=cbc%20federal%20budget%202021&lr=21512"),
1662            title None
1663        );
1664
1665        note_observation!(
1666            &conn,
1667            url "https://stackoverflow.com/questions/37777675/how-to-create-a-formatted-string-out-of-a-literal-in-rust",
1668            view_time Some(20000),
1669            search_term Some("rust string format"),
1670            document_type Some(DocumentType::Regular),
1671            referrer_url Some("https://yandex.ru/search/?lr=21512&text=rust%20string%20format"),
1672            title None
1673        );
1674
1675        note_observation!(
1676            &conn,
1677            url "https://www.sqlite.org/lang_corefunc.html#instr",
1678            view_time Some(20000),
1679            search_term Some("sqlite like"),
1680            document_type Some(DocumentType::Regular),
1681            referrer_url Some("https://www.google.com/search?client=firefox-b-d&q=sqlite+like"),
1682            title None
1683        );
1684
1685        note_observation!(
1686            &conn,
1687            url "https://www.youtube.com/watch?v=tpiyEe_CqB4",
1688            view_time Some(100000),
1689            search_term Some("cute cat"),
1690            document_type Some(DocumentType::Media),
1691            referrer_url Some("https://www.youtube.com/results?search_query=cute+cat"),
1692            title None
1693        );
1694
1695        // query by title
1696        let meta = query(&conn, "child care", 10).expect("query should work");
1697        assert_eq!(1, meta.len(), "expected exactly one result");
1698        assert_history_metadata_record!(meta[0],
1699            url "https://www.cbc.ca/news/politics/federal-budget-2021-freeland-zimonjic-1.5991021",
1700            total_time 20000,
1701            search_term Some("cbc federal budget 2021"),
1702            document_type DocumentType::Regular,
1703            referrer_url Some("https://yandex.ru/search/?text=cbc%20federal%20budget%202021&lr=21512"),
1704            title Some("Budget vows to build &#x27;for the long term&#x27; as it promises child care cash, projects massive deficits | CBC News"),
1705            preview_image_url Some("https://i.cbc.ca/1.5993583.1618861792!/cpImage/httpImage/image.jpg_gen/derivatives/16x9_620/fedbudget-20210419.jpg")
1706        );
1707
1708        // query by search term
1709        let meta = query(&conn, "string format", 10).expect("query should work");
1710        assert_eq!(1, meta.len(), "expected exactly one result");
1711        assert_history_metadata_record!(meta[0],
1712            url "https://stackoverflow.com/questions/37777675/how-to-create-a-formatted-string-out-of-a-literal-in-rust",
1713            total_time 20000,
1714            search_term Some("rust string format"),
1715            document_type DocumentType::Regular,
1716            referrer_url Some("https://yandex.ru/search/?lr=21512&text=rust%20string%20format"),
1717            title None,
1718            preview_image_url None
1719        );
1720
1721        // query by url
1722        let meta = query(&conn, "instr", 10).expect("query should work");
1723        assert_history_metadata_record!(meta[0],
1724            url "https://www.sqlite.org/lang_corefunc.html#instr",
1725            total_time 20000,
1726            search_term Some("sqlite like"),
1727            document_type DocumentType::Regular,
1728            referrer_url Some("https://www.google.com/search?client=firefox-b-d&q=sqlite+like"),
1729            title None,
1730            preview_image_url None
1731        );
1732
1733        // by url, referrer domain is different
1734        let meta = query(&conn, "youtube", 10).expect("query should work");
1735        assert_history_metadata_record!(meta[0],
1736            url "https://www.youtube.com/watch?v=tpiyEe_CqB4",
1737            total_time 100000,
1738            search_term Some("cute cat"),
1739            document_type DocumentType::Media,
1740            referrer_url Some("https://www.youtube.com/results?search_query=cute+cat"),
1741            title None,
1742            preview_image_url None
1743        );
1744    }
1745
1746    #[test]
1747    fn test_delete_metadata() {
1748        let conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).expect("memory db");
1749
1750        // url  |   search_term |   referrer
1751        // 1    |    1          |   1
1752        // 1    |    1          |   0
1753        // 1    |    0          |   1
1754        // 1    |    0          |   0
1755
1756        note_observation!(&conn,
1757            url "http://mozilla.com/1",
1758            view_time Some(20000),
1759            search_term Some("1 with search"),
1760            document_type Some(DocumentType::Regular),
1761            referrer_url Some("http://mozilla.com/"),
1762            title None
1763        );
1764
1765        note_observation!(&conn,
1766            url "http://mozilla.com/1",
1767            view_time Some(20000),
1768            search_term Some("1 with search"),
1769            document_type Some(DocumentType::Regular),
1770            referrer_url None,
1771            title None
1772        );
1773
1774        note_observation!(&conn,
1775            url "http://mozilla.com/1",
1776            view_time Some(20000),
1777            search_term None,
1778            document_type Some(DocumentType::Regular),
1779            referrer_url Some("http://mozilla.com/"),
1780            title None
1781        );
1782
1783        note_observation!(&conn,
1784            url "http://mozilla.com/1",
1785            view_time Some(20000),
1786            search_term None,
1787            document_type Some(DocumentType::Regular),
1788            referrer_url None,
1789            title None
1790        );
1791
1792        note_observation!(&conn,
1793            url "http://mozilla.com/2",
1794            view_time Some(20000),
1795            search_term None,
1796            document_type Some(DocumentType::Regular),
1797            referrer_url None,
1798            title None
1799        );
1800
1801        note_observation!(&conn,
1802            url "http://mozilla.com/2",
1803            view_time Some(20000),
1804            search_term None,
1805            document_type Some(DocumentType::Regular),
1806            referrer_url Some("http://mozilla.com/"),
1807            title None
1808        );
1809
1810        bump_clock();
1811        // same observation a bit later:
1812        note_observation!(&conn,
1813            url "http://mozilla.com/2",
1814            view_time Some(20000),
1815            search_term None,
1816            document_type Some(DocumentType::Regular),
1817            referrer_url Some("http://mozilla.com/"),
1818            title None
1819        );
1820
1821        assert_eq!(6, get_since(&conn, 0).expect("get worked").len());
1822        delete_metadata(
1823            &conn,
1824            &Url::parse("http://mozilla.com/1").unwrap(),
1825            None,
1826            None,
1827        )
1828        .expect("delete metadata");
1829        assert_eq!(5, get_since(&conn, 0).expect("get worked").len());
1830
1831        delete_metadata(
1832            &conn,
1833            &Url::parse("http://mozilla.com/1").unwrap(),
1834            Some(&Url::parse("http://mozilla.com/").unwrap()),
1835            None,
1836        )
1837        .expect("delete metadata");
1838        assert_eq!(4, get_since(&conn, 0).expect("get worked").len());
1839
1840        delete_metadata(
1841            &conn,
1842            &Url::parse("http://mozilla.com/1").unwrap(),
1843            Some(&Url::parse("http://mozilla.com/").unwrap()),
1844            Some("1 with search"),
1845        )
1846        .expect("delete metadata");
1847        assert_eq!(3, get_since(&conn, 0).expect("get worked").len());
1848
1849        delete_metadata(
1850            &conn,
1851            &Url::parse("http://mozilla.com/1").unwrap(),
1852            None,
1853            Some("1 with search"),
1854        )
1855        .expect("delete metadata");
1856        assert_eq!(2, get_since(&conn, 0).expect("get worked").len());
1857
1858        // key doesn't match, do nothing
1859        delete_metadata(
1860            &conn,
1861            &Url::parse("http://mozilla.com/2").unwrap(),
1862            Some(&Url::parse("http://wrong-referrer.com").unwrap()),
1863            Some("2 with search"),
1864        )
1865        .expect("delete metadata");
1866        assert_eq!(2, get_since(&conn, 0).expect("get worked").len());
1867    }
1868
1869    #[test]
1870    fn test_delete_older_than() {
1871        let conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).expect("memory db");
1872
1873        let beginning = Timestamp::now().as_millis() as i64;
1874
1875        note_observation!(&conn,
1876            url "http://mozilla.com/1",
1877            view_time Some(20000),
1878            search_term None,
1879            document_type Some(DocumentType::Regular),
1880            referrer_url None,
1881            title None
1882        );
1883        let after_meta1 = Timestamp::now().as_millis() as i64;
1884
1885        bump_clock();
1886
1887        note_observation!(&conn,
1888            url "http://mozilla.com/2",
1889            view_time Some(20000),
1890            search_term None,
1891            document_type Some(DocumentType::Regular),
1892            referrer_url None,
1893            title None
1894        );
1895
1896        bump_clock();
1897
1898        note_observation!(&conn,
1899            url "http://mozilla.com/3",
1900            view_time Some(20000),
1901            search_term None,
1902            document_type Some(DocumentType::Regular),
1903            referrer_url None,
1904            title None
1905        );
1906        let after_meta3 = Timestamp::now().as_millis() as i64;
1907
1908        // deleting nothing.
1909        delete_older_than(&conn, beginning).expect("delete worked");
1910        assert_eq!(3, get_since(&conn, beginning).expect("get worked").len());
1911
1912        // boundary condition, should only delete the first one.
1913        delete_older_than(&conn, after_meta1).expect("delete worked");
1914        assert_eq!(2, get_since(&conn, beginning).expect("get worked").len());
1915        assert_eq!(
1916            None,
1917            get_latest_for_url(&conn, &Url::parse("http://mozilla.com/1").expect("url"))
1918                .expect("get")
1919        );
1920
1921        // delete everything now.
1922        delete_older_than(&conn, after_meta3).expect("delete worked");
1923        assert_eq!(0, get_since(&conn, beginning).expect("get worked").len());
1924    }
1925
1926    #[test]
1927    fn test_delete_between() {
1928        let conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).expect("memory db");
1929
1930        let beginning = Timestamp::now().as_millis() as i64;
1931        bump_clock();
1932
1933        note_observation!(&conn,
1934            url "http://mozilla.com/1",
1935            view_time Some(20000),
1936            search_term None,
1937            document_type Some(DocumentType::Regular),
1938            referrer_url None,
1939            title None
1940        );
1941
1942        bump_clock();
1943
1944        note_observation!(&conn,
1945            url "http://mozilla.com/2",
1946            view_time Some(20000),
1947            search_term None,
1948            document_type Some(DocumentType::Regular),
1949            referrer_url None,
1950            title None
1951        );
1952        let after_meta2 = Timestamp::now().as_millis() as i64;
1953
1954        bump_clock();
1955
1956        note_observation!(&conn,
1957            url "http://mozilla.com/3",
1958            view_time Some(20000),
1959            search_term None,
1960            document_type Some(DocumentType::Regular),
1961            referrer_url None,
1962            title None
1963        );
1964        let after_meta3 = Timestamp::now().as_millis() as i64;
1965
1966        // deleting meta 3
1967        delete_between(&conn, after_meta2, after_meta3).expect("delete worked");
1968        assert_eq!(2, get_since(&conn, beginning).expect("get worked").len());
1969        assert_eq!(
1970            None,
1971            get_latest_for_url(&conn, &Url::parse("http://mozilla.com/3").expect("url"))
1972                .expect("get")
1973        );
1974    }
1975
1976    #[test]
1977    fn test_metadata_deletes_do_not_affect_places() {
1978        let conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).expect("memory db");
1979
1980        note_observation!(
1981            &conn,
1982            url "https://www.mozilla.org/first/",
1983            view_time Some(20000),
1984            search_term None,
1985            document_type Some(DocumentType::Regular),
1986            referrer_url Some("https://www.google.com/search?client=firefox-b-d&q=mozilla+firefox"),
1987            title None
1988        );
1989
1990        note_observation!(
1991            &conn,
1992            url "https://www.mozilla.org/",
1993            view_time Some(20000),
1994            search_term None,
1995            document_type Some(DocumentType::Regular),
1996            referrer_url Some("https://www.google.com/search?client=firefox-b-d&q=mozilla+firefox"),
1997            title None
1998        );
1999        let after_meta_added = Timestamp::now().as_millis() as i64;
2000
2001        // Delete all metadata.
2002        delete_older_than(&conn, after_meta_added).expect("delete older than worked");
2003
2004        // Query places. Records there should not have been affected by the delete above.
2005        // 2 for metadata entries + 1 for referrer url.
2006        assert_table_size!(&conn, "moz_places", 3);
2007    }
2008
2009    #[test]
2010    fn test_delete_history_also_deletes_metadata_bookmarked() {
2011        let conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).expect("memory db");
2012        // Item 1 - bookmarked with regular visits and history metadata
2013        let url = Url::parse("https://www.mozilla.org/bookmarked").unwrap();
2014        let bm_guid: SyncGuid = "bookmarkAAAA".into();
2015        let bm = InsertableBookmark {
2016            parent_guid: BookmarkRootGuid::Unfiled.into(),
2017            position: BookmarkPosition::Append,
2018            date_added: None,
2019            last_modified: None,
2020            guid: Some(bm_guid.clone()),
2021            url: url.clone(),
2022            title: Some("bookmarked page".to_string()),
2023        };
2024        insert_bookmark(&conn, InsertableItem::Bookmark { b: bm }).expect("bookmark should insert");
2025        let obs = VisitObservation::new(url.clone()).with_visit_type(VisitType::Link);
2026        apply_observation(&conn, obs).expect("Should apply visit");
2027        note_observation!(
2028            &conn,
2029            url url.to_string(),
2030            view_time Some(20000),
2031            search_term None,
2032            document_type Some(DocumentType::Regular),
2033            referrer_url Some("https://www.google.com/search?client=firefox-b-d&q=mozilla+firefox"),
2034            title None
2035        );
2036
2037        // Check the DB is what we expect before deleting.
2038        assert_eq!(
2039            get_visit_count(&conn, VisitTransitionSet::empty()).unwrap(),
2040            1
2041        );
2042        let place_guid = url_to_guid(&conn, &url)
2043            .expect("is valid")
2044            .expect("should exist");
2045
2046        delete_visits_for(&conn, &place_guid).expect("should work");
2047        // bookmark must still exist.
2048        assert!(get_raw_bookmark(&conn, &bm_guid).unwrap().is_some());
2049        // place exists but has no visits.
2050        let pi = fetch_page_info(&conn, &url)
2051            .expect("should work")
2052            .expect("should exist");
2053        assert!(pi.last_visit_id.is_none());
2054        // and no metadata observations.
2055        assert!(get_latest_for_url(&conn, &url)
2056            .expect("should work")
2057            .is_none());
2058    }
2059
2060    #[test]
2061    fn test_delete_history_also_deletes_metadata_not_bookmarked() {
2062        let conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).expect("memory db");
2063        // Item is not bookmarked, but has regular visit and a metadata observation.
2064        let url = Url::parse("https://www.mozilla.org/not-bookmarked").unwrap();
2065        let obs = VisitObservation::new(url.clone()).with_visit_type(VisitType::Link);
2066        apply_observation(&conn, obs).expect("Should apply visit");
2067        note_observation!(
2068            &conn,
2069            url url.to_string(),
2070            view_time Some(20000),
2071            search_term None,
2072            document_type Some(DocumentType::Regular),
2073            referrer_url Some("https://www.google.com/search?client=firefox-b-d&q=mozilla+firefox"),
2074            title None
2075        );
2076
2077        // Check the DB is what we expect before deleting.
2078        assert_eq!(
2079            get_visit_count(&conn, VisitTransitionSet::empty()).unwrap(),
2080            1
2081        );
2082        let place_guid = url_to_guid(&conn, &url)
2083            .expect("is valid")
2084            .expect("should exist");
2085
2086        delete_visits_for(&conn, &place_guid).expect("should work");
2087        // place no longer exists.
2088        assert!(fetch_page_info(&conn, &url).expect("should work").is_none());
2089        assert!(get_latest_for_url(&conn, &url)
2090            .expect("should work")
2091            .is_none());
2092    }
2093
2094    #[test]
2095    fn test_delete_history_also_deletes_metadata_no_visits() {
2096        let conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).expect("memory db");
2097        // Item is not bookmarked, no regular visits but a metadata observation.
2098        let url = Url::parse("https://www.mozilla.org/no-visits").unwrap();
2099        note_observation!(
2100            &conn,
2101            url url.to_string(),
2102            view_time Some(20000),
2103            search_term None,
2104            document_type Some(DocumentType::Regular),
2105            referrer_url Some("https://www.google.com/search?client=firefox-b-d&q=mozilla+firefox"),
2106            title None
2107        );
2108
2109        // Check the DB is what we expect before deleting.
2110        assert_eq!(
2111            get_visit_count(&conn, VisitTransitionSet::empty()).unwrap(),
2112            0
2113        );
2114        let place_guid = url_to_guid(&conn, &url)
2115            .expect("is valid")
2116            .expect("should exist");
2117
2118        delete_visits_for(&conn, &place_guid).expect("should work");
2119        // place no longer exists.
2120        assert!(fetch_page_info(&conn, &url).expect("should work").is_none());
2121        assert!(get_latest_for_url(&conn, &url)
2122            .expect("should work")
2123            .is_none());
2124    }
2125
2126    #[test]
2127    fn test_delete_between_also_deletes_metadata() -> Result<()> {
2128        let conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).expect("memory db");
2129
2130        let now = Timestamp::now();
2131        let url = Url::parse("https://www.mozilla.org/").unwrap();
2132        let other_url =
2133            Url::parse("https://www.google.com/search?client=firefox-b-d&q=mozilla+firefox")
2134                .unwrap();
2135        let start_timestamp = Timestamp(now.as_millis() - 1000_u64);
2136        let end_timestamp = Timestamp(now.as_millis() + 1000_u64);
2137        let observation1 = VisitObservation::new(url.clone())
2138            .with_at(start_timestamp)
2139            .with_title(Some(String::from("Test page 0")))
2140            .with_is_remote(false)
2141            .with_visit_type(VisitType::Link);
2142
2143        let observation2 = VisitObservation::new(other_url)
2144            .with_at(end_timestamp)
2145            .with_title(Some(String::from("Test page 1")))
2146            .with_is_remote(false)
2147            .with_visit_type(VisitType::Link);
2148
2149        apply_observation(&conn, observation1).expect("Should apply visit");
2150        apply_observation(&conn, observation2).expect("Should apply visit");
2151
2152        note_observation!(
2153            &conn,
2154            url "https://www.mozilla.org/",
2155            view_time Some(20000),
2156            search_term Some("mozilla firefox"),
2157            document_type Some(DocumentType::Regular),
2158            referrer_url Some("https://www.google.com/search?client=firefox-b-d&q=mozilla+firefox"),
2159            title None
2160        );
2161        assert_eq!(
2162            "https://www.mozilla.org/",
2163            get_latest_for_url(&conn, &url)?.unwrap().url
2164        );
2165        delete_visits_between(&conn, start_timestamp, end_timestamp)?;
2166        assert_eq!(None, get_latest_for_url(&conn, &url)?);
2167        Ok(())
2168    }
2169
2170    #[test]
2171    fn test_places_delete_triggers_with_bookmarks() {
2172        // The cleanup functionality lives as a TRIGGER in `create_shared_triggers`.
2173        let conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).expect("memory db");
2174
2175        let now = Timestamp::now();
2176        let url = Url::parse("https://www.mozilla.org/").unwrap();
2177        let parent_url =
2178            Url::parse("https://www.google.com/search?client=firefox-b-d&q=mozilla+firefox")
2179                .unwrap();
2180
2181        let observation1 = VisitObservation::new(url.clone())
2182            .with_at(now)
2183            .with_title(Some(String::from("Test page 0")))
2184            .with_is_remote(false)
2185            .with_visit_type(VisitType::Link);
2186
2187        let observation2 = VisitObservation::new(parent_url.clone())
2188            .with_at(now)
2189            .with_title(Some(String::from("Test page 1")))
2190            .with_is_remote(false)
2191            .with_visit_type(VisitType::Link);
2192
2193        apply_observation(&conn, observation1).expect("Should apply visit");
2194        apply_observation(&conn, observation2).expect("Should apply visit");
2195
2196        assert_table_size!(&conn, "moz_bookmarks", 5);
2197
2198        // add bookmark for the page we have a metadata entry
2199        insert_bookmark(
2200            &conn,
2201            InsertableItem::Bookmark {
2202                b: InsertableBookmark {
2203                    parent_guid: BookmarkRootGuid::Unfiled.into(),
2204                    position: BookmarkPosition::Append,
2205                    date_added: None,
2206                    last_modified: None,
2207                    guid: Some(SyncGuid::from("cccccccccccc")),
2208                    url,
2209                    title: None,
2210                },
2211            },
2212        )
2213        .expect("bookmark insert worked");
2214
2215        // add another bookmark to the "parent" of our metadata entry
2216        insert_bookmark(
2217            &conn,
2218            InsertableItem::Bookmark {
2219                b: InsertableBookmark {
2220                    parent_guid: BookmarkRootGuid::Unfiled.into(),
2221                    position: BookmarkPosition::Append,
2222                    date_added: None,
2223                    last_modified: None,
2224                    guid: Some(SyncGuid::from("ccccccccccca")),
2225                    url: parent_url,
2226                    title: None,
2227                },
2228            },
2229        )
2230        .expect("bookmark insert worked");
2231
2232        assert_table_size!(&conn, "moz_bookmarks", 7);
2233        assert_table_size!(&conn, "moz_origins", 2);
2234
2235        note_observation!(
2236            &conn,
2237            url "https://www.mozilla.org/",
2238            view_time Some(20000),
2239            search_term Some("mozilla firefox"),
2240            document_type Some(DocumentType::Regular),
2241            referrer_url Some("https://www.google.com/search?client=firefox-b-d&q=mozilla+firefox"),
2242            title None
2243        );
2244
2245        assert_table_size!(&conn, "moz_origins", 2);
2246
2247        // this somehow deletes 1 origin record, and our metadata
2248        delete_everything(&conn).expect("places wipe succeeds");
2249
2250        assert_table_size!(&conn, "moz_places_metadata", 0);
2251        assert_table_size!(&conn, "moz_places_metadata_search_queries", 0);
2252    }
2253
2254    #[test]
2255    fn test_places_delete_triggers() {
2256        // The cleanup functionality lives as a TRIGGER in `create_shared_triggers`.
2257        let conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).expect("memory db");
2258
2259        let now = Timestamp::now();
2260        let observation1 = VisitObservation::new(Url::parse("https://www.mozilla.org/").unwrap())
2261            .with_at(now)
2262            .with_title(Some(String::from("Test page 1")))
2263            .with_is_remote(false)
2264            .with_visit_type(VisitType::Link);
2265        let observation2 =
2266            VisitObservation::new(Url::parse("https://www.mozilla.org/another/").unwrap())
2267                .with_at(Timestamp(now.as_millis() + 10000))
2268                .with_title(Some(String::from("Test page 3")))
2269                .with_is_remote(false)
2270                .with_visit_type(VisitType::Link);
2271        let observation3 =
2272            VisitObservation::new(Url::parse("https://www.mozilla.org/first/").unwrap())
2273                .with_at(Timestamp(now.as_millis() - 10000))
2274                .with_title(Some(String::from("Test page 0")))
2275                .with_is_remote(true)
2276                .with_visit_type(VisitType::Link);
2277        apply_observation(&conn, observation1).expect("Should apply visit");
2278        apply_observation(&conn, observation2).expect("Should apply visit");
2279        apply_observation(&conn, observation3).expect("Should apply visit");
2280
2281        note_observation!(
2282            &conn,
2283            url "https://www.mozilla.org/first/",
2284            view_time Some(20000),
2285            search_term None,
2286            document_type Some(DocumentType::Regular),
2287            referrer_url Some("https://www.google.com/search?client=firefox-b-d&q=mozilla+firefox"),
2288            title None
2289        );
2290
2291        note_observation!(
2292            &conn,
2293            url "https://www.mozilla.org/",
2294            view_time Some(20000),
2295            search_term None,
2296            document_type Some(DocumentType::Regular),
2297            referrer_url Some("https://www.google.com/search?client=firefox-b-d&q=mozilla+firefox"),
2298            title None
2299        );
2300
2301        note_observation!(
2302            &conn,
2303            url "https://www.mozilla.org/",
2304            view_time Some(20000),
2305            search_term Some("mozilla"),
2306            document_type Some(DocumentType::Regular),
2307            referrer_url Some("https://www.google.com/search?client=firefox-b-d&q=mozilla+firefox"),
2308            title None
2309        );
2310
2311        note_observation!(
2312            &conn,
2313            url "https://www.mozilla.org/",
2314            view_time Some(25000),
2315            search_term Some("firefox"),
2316            document_type Some(DocumentType::Media),
2317            referrer_url Some("https://www.google.com/search?client=firefox-b-d&q=mozilla+firefox"),
2318            title None
2319        );
2320
2321        note_observation!(
2322            &conn,
2323            url "https://www.mozilla.org/another/",
2324            view_time Some(20000),
2325            search_term Some("mozilla"),
2326            document_type Some(DocumentType::Regular),
2327            referrer_url Some("https://www.google.com/search?client=firefox-b-d&q=mozilla+firefox"),
2328            title None
2329        );
2330
2331        // double-check that we have the 'firefox' search query entry.
2332        assert!(conn
2333            .try_query_one::<i64, _>(
2334                "SELECT id FROM moz_places_metadata_search_queries WHERE term = :term",
2335                rusqlite::named_params! { ":term": "firefox" },
2336                true
2337            )
2338            .expect("select works")
2339            .is_some());
2340
2341        // Delete our first page & its visits. Note that /another/ page will remain in place.
2342        delete_visits_between(
2343            &conn,
2344            Timestamp(now.as_millis() - 1000),
2345            Timestamp(now.as_millis() + 1000),
2346        )
2347        .expect("delete worked");
2348
2349        let meta1 =
2350            get_latest_for_url(&conn, &Url::parse("https://www.mozilla.org/").expect("url"))
2351                .expect("get worked");
2352        let meta2 = get_latest_for_url(
2353            &conn,
2354            &Url::parse("https://www.mozilla.org/another/").expect("url"),
2355        )
2356        .expect("get worked");
2357
2358        assert!(meta1.is_none(), "expected metadata to have been deleted");
2359        // Verify that if a history metadata entry was entered **after** the visit
2360        // then we delete the range of the metadata, and not the visit. The metadata
2361        // is still deleted
2362        assert!(meta2.is_none(), "expected metadata to been deleted");
2363
2364        // The 'mozilla' search query entry is deleted since the delete cascades.
2365        assert!(
2366            conn.try_query_one::<i64, _>(
2367                "SELECT id FROM moz_places_metadata_search_queries WHERE term = :term",
2368                rusqlite::named_params! { ":term": "mozilla" },
2369                true
2370            )
2371            .expect("select works")
2372            .is_none(),
2373            "search_query records with related metadata should have been deleted"
2374        );
2375
2376        // don't have the 'firefox' search query entry either, nothing points to it.
2377        assert!(
2378            conn.try_query_one::<i64, _>(
2379                "SELECT id FROM moz_places_metadata_search_queries WHERE term = :term",
2380                rusqlite::named_params! { ":term": "firefox" },
2381                true
2382            )
2383            .expect("select works")
2384            .is_none(),
2385            "search_query records without related metadata should have been deleted"
2386        );
2387
2388        // now, let's wipe places, and make sure none of the metadata stuff remains.
2389        delete_everything(&conn).expect("places wipe succeeds");
2390
2391        assert_table_size!(&conn, "moz_places_metadata", 0);
2392        assert_table_size!(&conn, "moz_places_metadata_search_queries", 0);
2393    }
2394
2395    #[test]
2396    fn test_if_page_missing_behavior() {
2397        let conn = PlacesDb::open_in_memory(ConnectionType::ReadWrite).expect("memory db");
2398
2399        note_observation!(
2400            &conn,
2401            NoteHistoryMetadataObservationOptions::new()
2402                .if_page_missing(HistoryMetadataPageMissingBehavior::IgnoreObservation),
2403            url "https://www.example.com/",
2404            view_time None,
2405            search_term None,
2406            document_type Some(DocumentType::Regular),
2407            referrer_url None,
2408            title None
2409        );
2410
2411        let observations = get_since(&conn, 0).expect("should get all metadata observations");
2412        assert_eq!(observations, &[]);
2413
2414        let visit_observation =
2415            VisitObservation::new(Url::parse("https://www.example.com/").unwrap())
2416                .with_at(Timestamp::now());
2417        apply_observation(&conn, visit_observation).expect("should apply visit observation");
2418
2419        note_observation!(
2420            &conn,
2421            NoteHistoryMetadataObservationOptions::new()
2422                .if_page_missing(HistoryMetadataPageMissingBehavior::IgnoreObservation),
2423            url "https://www.example.com/",
2424            view_time None,
2425            search_term None,
2426            document_type Some(DocumentType::Regular),
2427            referrer_url None,
2428            title None
2429        );
2430
2431        let observations = get_since(&conn, 0).expect("should get all metadata observations");
2432        assert_eq!(
2433            observations
2434                .into_iter()
2435                .map(|m| m.url)
2436                .collect::<Vec<String>>(),
2437            &["https://www.example.com/"]
2438        );
2439
2440        note_observation!(
2441            &conn,
2442            NoteHistoryMetadataObservationOptions::new()
2443                .if_page_missing(HistoryMetadataPageMissingBehavior::InsertPage),
2444            url "https://www.example.org/",
2445            view_time None,
2446            search_term None,
2447            document_type Some(DocumentType::Regular),
2448            referrer_url None,
2449            title None
2450        );
2451
2452        let observations = get_since(&conn, 0).expect("should get all metadata observations");
2453        assert_eq!(
2454            observations
2455                .into_iter()
2456                .map(|m| m.url)
2457                .collect::<Vec<String>>(),
2458            &[
2459                "https://www.example.org/", // Newest first.
2460                "https://www.example.com/",
2461            ],
2462        );
2463    }
2464}