places/storage/
history_metadata.rs

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