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