1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

use super::{fetch_page_info, TAG_LENGTH_MAX};
use crate::db::PlacesDb;
use crate::error::{InvalidPlaceInfo, Result};
use sql_support::ConnExt;
use url::Url;

/// The validity of a tag.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ValidatedTag<'a> {
    /// The tag is invalid.
    Invalid(&'a str),

    /// The tag is valid, but normalized to remove leading and trailing
    /// whitespace.
    Normalized(&'a str),

    /// The original tag is valid.
    Original(&'a str),
}

impl<'a> ValidatedTag<'a> {
    /// Returns `true` if the original tag is valid; `false` if it's invalid or
    /// normalized.
    pub fn is_original(&self) -> bool {
        matches!(&self, ValidatedTag::Original(_))
    }

    /// Returns the tag string if the tag is valid or normalized, or an error
    /// if the tag is invalid.
    pub fn ensure_valid(&self) -> Result<&'a str> {
        match self {
            ValidatedTag::Invalid(_) => Err(InvalidPlaceInfo::InvalidTag.into()),
            ValidatedTag::Normalized(t) | ValidatedTag::Original(t) => Ok(t),
        }
    }
}

/// Checks the validity of the specified tag.
pub fn validate_tag(tag: &str) -> ValidatedTag<'_> {
    // Drop empty and oversized tags.
    let t = tag.trim();
    if t.is_empty() || t.len() > TAG_LENGTH_MAX {
        ValidatedTag::Invalid(tag)
    } else if t.len() != tag.len() {
        ValidatedTag::Normalized(t)
    } else {
        ValidatedTag::Original(t)
    }
}

/// Tags the specified URL.
///
/// # Arguments
///
/// * `conn` - A database connection on which to operate.
///
/// * `url` - The URL to tag.
///
/// * `tag` - The tag to add for the URL.
///
/// # Returns
///
/// There is no success return value.
pub fn tag_url(db: &PlacesDb, url: &Url, tag: &str) -> Result<()> {
    let tag = validate_tag(&tag).ensure_valid()?;
    let tx = db.begin_transaction()?;

    // This function will not create a new place.
    // Fetch the place id, so we (a) avoid creating a new tag when we aren't
    // going to reference it and (b) to avoid a sub-query.
    let place_id = match fetch_page_info(db, url)? {
        Some(info) => info.page.row_id,
        None => return Err(InvalidPlaceInfo::NoSuchUrl.into()),
    };

    db.execute_named_cached(
        "INSERT OR IGNORE INTO moz_tags(tag, lastModified)
         VALUES(:tag, now())",
        &[(":tag", &tag)],
    )?;

    db.execute_named_cached(
        "INSERT OR IGNORE INTO moz_tags_relation(tag_id, place_id)
         VALUES((SELECT id FROM moz_tags WHERE tag = :tag), :place_id)",
        &[(":tag", &tag), (":place_id", &place_id)],
    )?;
    tx.commit()?;
    Ok(())
}

/// Remove the specified tag from the specified URL.
///
/// # Arguments
///
/// * `conn` - A database connection on which to operate.
///
/// * `url` - The URL from which the tag should be removed.
///
/// * `tag` - The tag to remove from the URL.
///
/// # Returns
///
/// There is no success return value - the operation is ignored if the URL
/// does not have the tag.
pub fn untag_url(db: &PlacesDb, url: &Url, tag: &str) -> Result<()> {
    let tag = validate_tag(&tag).ensure_valid()?;
    db.execute_named_cached(
        "DELETE FROM moz_tags_relation
         WHERE tag_id = (SELECT id FROM moz_tags
                         WHERE tag = :tag)
         AND place_id = (SELECT id FROM moz_places
                         WHERE url_hash = hash(:url)
                         AND url = :url)",
        &[(":tag", &tag), (":url", &url.as_str())],
    )?;
    Ok(())
}

/// Remove all tags from the specified URL.
///
/// # Arguments
///
/// * `conn` - A database connection on which to operate.
///
/// * `url` - The URL for which all tags should be removed.
///
/// # Returns
///
/// There is no success return value.
pub fn remove_all_tags_from_url(db: &PlacesDb, url: &Url) -> Result<()> {
    db.execute_named_cached(
        "DELETE FROM moz_tags_relation
         WHERE
         place_id = (SELECT id FROM moz_places
                     WHERE url_hash = hash(:url)
                     AND url = :url)",
        &[(":url", &url.as_str())],
    )?;
    Ok(())
}

/// Remove the specified tag from all URLs.
///
/// # Arguments
///
/// * `conn` - A database connection on which to operate.
///
/// * `tag` - The tag to remove.
///
/// # Returns
///
/// There is no success return value.
pub fn remove_tag(db: &PlacesDb, tag: &str) -> Result<()> {
    db.execute_named_cached(
        "DELETE FROM moz_tags
         WHERE tag = :tag",
        &[(":tag", &tag)],
    )?;
    Ok(())
}

/// Retrieves a list of URLs which have the specified tag.
///
/// # Arguments
///
/// * `conn` - A database connection on which to operate.
///
/// * `tag` - The tag to query.
///
/// # Returns
///
/// * A Vec<Url> with all URLs which have the tag, ordered by the frecency of
/// the URLs.
pub fn get_urls_with_tag(db: &PlacesDb, tag: &str) -> Result<Vec<Url>> {
    let tag = validate_tag(&tag).ensure_valid()?;

    let mut stmt = db.prepare(
        "SELECT p.url FROM moz_places p
         JOIN moz_tags_relation r ON r.place_id = p.id
         JOIN moz_tags t ON t.id = r.tag_id
         WHERE t.tag = :tag
         ORDER BY p.frecency",
    )?;

    let rows = stmt.query_and_then_named(&[(":tag", &tag)], |row| row.get::<_, String>("url"))?;
    let mut urls = Vec::new();
    for row in rows {
        urls.push(Url::parse(&row?)?);
    }
    Ok(urls)
}

/// Retrieves a list of tags for the specified URL.
///
/// # Arguments
///
/// * `conn` - A database connection on which to operate.
///
/// * `url` - The URL to query.
///
/// # Returns
///
/// * A Vec<String> with all tags for the URL, sorted by the last modified
///   date of the tag (latest to oldest)
pub fn get_tags_for_url(db: &PlacesDb, url: &Url) -> Result<Vec<String>> {
    let mut stmt = db.prepare(
        "SELECT t.tag
         FROM moz_tags t
         JOIN moz_tags_relation r ON r.tag_id = t.id
         JOIN moz_places h ON h.id = r.place_id
         WHERE url_hash = hash(:url) AND url = :url
         ORDER BY t.lastModified DESC",
    )?;
    let rows = stmt.query_and_then_named(&[(":url", &url.as_str())], |row| {
        row.get::<_, String>("tag")
    })?;
    let mut tags = Vec::new();
    for row in rows {
        tags.push(row?);
    }
    Ok(tags)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::api::places_api::test::new_mem_connection;
    use crate::storage::new_page_info;

    fn check_tags_for_url(db: &PlacesDb, url: &Url, mut expected: Vec<String>) {
        let mut tags = get_tags_for_url(&db, &url).expect("should work");
        tags.sort();
        expected.sort();
        assert_eq!(tags, expected);
    }

    fn check_urls_with_tag(db: &PlacesDb, tag: &str, mut expected: Vec<Url>) {
        let mut with_tag = get_urls_with_tag(db, tag).expect("should work");
        with_tag.sort();
        expected.sort();
        assert_eq!(with_tag, expected);
    }

    fn get_foreign_count(db: &PlacesDb, url: &Url) -> i32 {
        let count: Result<Option<i32>> = db.try_query_row(
            "SELECT foreign_count
             FROM moz_places
             WHERE url = :url",
            &[(":url", &url.as_str())],
            |row| Ok(row.get::<_, i32>(0)?),
            false,
        );
        count.expect("should work").expect("should get a value")
    }

    #[test]
    fn test_validate_tag() {
        let v = validate_tag("foo");
        assert_eq!(v, ValidatedTag::Original("foo"));
        assert!(v.is_original());
        assert_eq!(v.ensure_valid().expect("should work"), "foo");

        let v = validate_tag(" foo ");
        assert_eq!(v, ValidatedTag::Normalized("foo"));
        assert!(!v.is_original());
        assert_eq!(v.ensure_valid().expect("should work"), "foo");

        let v = validate_tag("");
        assert_eq!(v, ValidatedTag::Invalid(""));
        assert!(!v.is_original());
        assert!(v.ensure_valid().is_err());

        assert_eq!(validate_tag("foo bar"), ValidatedTag::Original("foo bar"));
        assert_eq!(
            validate_tag(" foo bar "),
            ValidatedTag::Normalized("foo bar")
        );

        assert!(validate_tag(&"f".repeat(101)).ensure_valid().is_err());
    }

    #[test]
    fn test_tags() {
        let conn = new_mem_connection();
        let url1 = Url::parse("http://example.com").expect("valid url");
        let url2 = Url::parse("http://example2.com").expect("valid url");

        new_page_info(&conn, &url1, None).expect("should create the page");
        new_page_info(&conn, &url2, None).expect("should create the page");
        check_tags_for_url(&conn, &url1, vec![]);
        check_tags_for_url(&conn, &url2, vec![]);
        assert_eq!(get_foreign_count(&conn, &url1), 0);
        assert_eq!(get_foreign_count(&conn, &url2), 0);

        tag_url(&conn, &url1, "common").expect("should work");
        assert_eq!(get_foreign_count(&conn, &url1), 1);
        tag_url(&conn, &url1, "tag-1").expect("should work");
        assert_eq!(get_foreign_count(&conn, &url1), 2);
        tag_url(&conn, &url2, "common").expect("should work");
        assert_eq!(get_foreign_count(&conn, &url2), 1);
        tag_url(&conn, &url2, "tag-2").expect("should work");
        assert_eq!(get_foreign_count(&conn, &url2), 2);

        check_tags_for_url(
            &conn,
            &url1,
            vec!["common".to_string(), "tag-1".to_string()],
        );
        check_tags_for_url(
            &conn,
            &url2,
            vec!["common".to_string(), "tag-2".to_string()],
        );

        check_urls_with_tag(&conn, "common", vec![url1.clone(), url2.clone()]);
        check_urls_with_tag(&conn, "tag-1", vec![url1.clone()]);
        check_urls_with_tag(&conn, "tag-2", vec![url2.clone()]);

        untag_url(&conn, &url1, "common").expect("should work");
        assert_eq!(get_foreign_count(&conn, &url1), 1);

        check_urls_with_tag(&conn, "common", vec![url2.clone()]);

        remove_tag(&conn, "common").expect("should work");
        check_urls_with_tag(&conn, "common", vec![]);
        assert_eq!(get_foreign_count(&conn, &url2), 1);

        remove_tag(&conn, "tag-1").expect("should work");
        check_urls_with_tag(&conn, "tag-1", vec![]);
        assert_eq!(get_foreign_count(&conn, &url1), 0);

        remove_tag(&conn, "tag-2").expect("should work");
        check_urls_with_tag(&conn, "tag-2", vec![]);
        assert_eq!(get_foreign_count(&conn, &url2), 0);

        // should be no tags rows left.
        let count: Result<Option<u32>> = conn.try_query_row(
            "SELECT COUNT(*) from moz_tags",
            &[],
            |row| Ok(row.get::<_, u32>(0)?),
            true,
        );
        assert_eq!(count.unwrap().unwrap(), 0);
        let count: Result<Option<u32>> = conn.try_query_row(
            "SELECT COUNT(*) from moz_tags_relation",
            &[],
            |row| Ok(row.get::<_, u32>(0)?),
            true,
        );
        assert_eq!(count.unwrap().unwrap(), 0);

        // places should still exist.
        fetch_page_info(&conn, &url1)
            .expect("should work")
            .expect("should exist");
        fetch_page_info(&conn, &url2)
            .expect("should work")
            .expect("should exist");
    }
}