diff --git a/src/engine/api/geary-contact-store.vala b/src/engine/api/geary-contact-store.vala index 43266274..0424e7bd 100644 --- a/src/engine/api/geary-contact-store.vala +++ b/src/engine/api/geary-contact-store.vala @@ -20,6 +20,13 @@ public interface Geary.ContactStore : GLib.Object { GLib.Cancellable? cancellable) throws GLib.Error; + /** Searches for contacts based on a specific string */ + public abstract async Gee.Collection search(string query, + uint min_importance, + uint limit, + GLib.Cancellable? cancellable) + throws GLib.Error; + /** Updates (or adds) a set of contacts in the underlying store */ public abstract async void update_contacts(Gee.Collection updated, GLib.Cancellable? cancellable) diff --git a/src/engine/common/common-contact-store-impl.vala b/src/engine/common/common-contact-store-impl.vala index 550a3110..e8ae147d 100644 --- a/src/engine/common/common-contact-store-impl.vala +++ b/src/engine/common/common-contact-store-impl.vala @@ -20,7 +20,6 @@ internal class Geary.ContactStoreImpl : BaseObject, Geary.ContactStore { this.backing = backing; } - /** Returns the contact matching the given email address, if any */ public async Contact? get_by_rfc822(Geary.RFC822.MailboxAddress mailbox, GLib.Cancellable? cancellable) throws GLib.Error { @@ -35,6 +34,24 @@ internal class Geary.ContactStoreImpl : BaseObject, Geary.ContactStore { return contact; } + public async Gee.Collection search(string query, + uint min_importance, + uint limit, + GLib.Cancellable? cancellable) + throws GLib.Error { + Gee.Collection? contacts = null; + yield this.backing.exec_transaction_async( + Db.TransactionType.RO, + (cx, cancellable) => { + contacts = do_search_contact( + cx, query, min_importance, limit, cancellable + ); + return Db.TransactionOutcome.COMMIT; + }, + cancellable); + return contacts; + } + public async void update_contacts(Gee.Collection updated, GLib.Cancellable? cancellable) throws GLib.Error { @@ -49,6 +66,75 @@ internal class Geary.ContactStoreImpl : BaseObject, Geary.ContactStore { cancellable); } + private Contact? do_fetch_contact(Db.Connection cx, + string email, + GLib.Cancellable? cancellable) + throws GLib.Error { + Db.Statement stmt = cx.prepare( + "SELECT real_name, highest_importance, normalized_email, flags FROM ContactTable " + + "WHERE email=?"); + stmt.bind_string(0, email); + + Db.Result result = stmt.exec(cancellable); + + Contact? contact = null; + if (!result.finished) { + contact = new Contact( + email, + result.string_at(0), + result.int_at(1), + result.string_at(2) + ); + contact.flags.deserialize(result.string_at(3)); + } + return contact; + } + + private Gee.Collection do_search_contact(Db.Connection cx, + string query, + uint min_importance, + uint limit, + GLib.Cancellable? cancellable) + throws GLib.Error { + Gee.Collection contacts = new Gee.LinkedList(); + string normalised_query = query.make_valid().normalize(); + if (!String.is_empty(normalised_query)) { + normalised_query = "%%%s%%".printf(normalised_query); + Db.Statement stmt = cx.prepare(""" + SELECT * FROM ContactTable + WHERE highest_importance >= ? AND ( + real_name LIKE ? COLLATE UTF8ICASE OR + normalized_email LIKE ? COLLATE UTF8ICASE + ) + ORDER BY highest_importance DESC, + real_name IS NULL, + real_name COLLATE UTF8ICASE, + email COLLATE UTF8ICASE + LIMIT ? + """); + stmt.bind_uint(0, min_importance); + stmt.bind_string(1, normalised_query); + stmt.bind_string(2, normalised_query); + stmt.bind_uint(3, limit); + + Db.Result result = stmt.exec(cancellable); + + while (!result.finished) { + Contact contact = new Contact( + result.string_for("email"), + result.string_for("real_name"), + result.int_for("highest_importance"), + result.string_for("normalized_email") + ); + contact.flags.deserialize(result.string_for("flags")); + contacts.add(contact); + + result.next(cancellable); + } + } + return contacts; + } + private void do_update_contact(Db.Connection cx, Contact updated, GLib.Cancellable? cancellable) @@ -100,28 +186,4 @@ internal class Geary.ContactStoreImpl : BaseObject, Geary.ContactStore { } } - private Contact? do_fetch_contact(Db.Connection cx, - string email, - GLib.Cancellable? cancellable) - throws GLib.Error { - Db.Statement stmt = cx.prepare( - "SELECT real_name, highest_importance, normalized_email, flags FROM ContactTable " - + "WHERE email=?"); - stmt.bind_string(0, email); - - Db.Result result = stmt.exec(cancellable); - - Contact? contact = null; - if (!result.finished) { - contact = new Contact( - email, - result.string_at(0), - result.int_at(1), - result.string_at(2) - ); - contact.flags.deserialize(result.string_at(3)); - } - return contact; - } - } diff --git a/src/engine/imap-db/imap-db-database.vala b/src/engine/imap-db/imap-db-database.vala index f8a4b588..cbf65d65 100644 --- a/src/engine/imap-db/imap-db-database.vala +++ b/src/engine/imap-db/imap-db-database.vala @@ -5,10 +5,22 @@ * (version 2.1 or later). See the COPYING file in this distribution. */ +[CCode (cname = "g_utf8_casefold")] +extern string utf8_casefold(string data, ssize_t len); extern int sqlite3_unicodesn_register_tokenizer(Sqlite.Database db); private class Geary.ImapDB.Database : Geary.Db.VersionedDatabase { + /** Name of UTF-8 case-sensitive SQLite collation function name. */ + public const string UTF8_CASE_INSENSITIVE_COLLATION = "UTF8ICASE"; + + private static int case_insensitive_collation(int a_len, void* a_bytes, + int b_len, void* b_bytes) { + string a_str = utf8_casefold((string) a_bytes, a_len).collate_key(); + string b_str = utf8_casefold((string) b_bytes, b_len).collate_key(); + return strcmp(a_str, b_str); + } + internal GLib.File attachments_path; private const int OPEN_PUMP_EVENT_LOOP_MSEC = 100; @@ -586,6 +598,16 @@ private class Geary.ImapDB.Database : Geary.Db.VersionedDatabase { cx.set_recursive_triggers(true); cx.set_synchronous(Db.SynchronousMode.NORMAL); sqlite3_unicodesn_register_tokenizer(cx.db); + if (cx.db.create_collation( + UTF8_CASE_INSENSITIVE_COLLATION, + Sqlite.UTF8, + Database.case_insensitive_collation + ) != Sqlite.OK) { + throw new DatabaseError.GENERAL( + "Failed to register collation function %s", + UTF8_CASE_INSENSITIVE_COLLATION + ); + } } } diff --git a/test/engine/api/geary-account-mock.vala b/test/engine/api/geary-account-mock.vala index 7fb35b34..2159df5a 100644 --- a/test/engine/api/geary-account-mock.vala +++ b/test/engine/api/geary-account-mock.vala @@ -16,26 +16,6 @@ public class Geary.MockAccount : Account, MockObject { } - public class MockContactStore : GLib.Object, ContactStore { - - internal MockContactStore() { - - } - - public async Contact? get_by_rfc822(Geary.RFC822.MailboxAddress address, - GLib.Cancellable? cancellable) - throws GLib.Error { - throw new EngineError.UNSUPPORTED("Mock method"); - } - - public async void update_contacts(Gee.Collection contacts, - GLib.Cancellable? cancellable) - throws GLib.Error { - throw new EngineError.UNSUPPORTED("Mock method"); - } - - } - public class MockClientService : ClientService { diff --git a/test/engine/api/geary-contact-store-mock.vala b/test/engine/api/geary-contact-store-mock.vala index dfffcd7e..63143006 100644 --- a/test/engine/api/geary-contact-store-mock.vala +++ b/test/engine/api/geary-contact-store-mock.vala @@ -14,7 +14,26 @@ internal class Geary.ContactStoreMock : ContactStore, MockObject, GLib.Object { public async Contact? get_by_rfc822(Geary.RFC822.MailboxAddress address, GLib.Cancellable? cancellable) throws GLib.Error { - return object_call("get_by_rfc822", { address }, null); + return object_call( + "get_by_rfc822", { address, cancellable }, null + ); + } + + public async Gee.Collection search(string query, + uint min_importance, + uint limit, + GLib.Cancellable? cancellable) + throws GLib.Error { + return object_call>( + "search", + { + box_arg(query), + uint_arg(min_importance), + uint_arg(limit), + cancellable + }, + Gee.Collection.empty() + ); } public async void update_contacts(Gee.Collection updated, diff --git a/test/engine/common/common-contact-store-impl-test.vala b/test/engine/common/common-contact-store-impl-test.vala index e57d3e3f..1c864922 100644 --- a/test/engine/common/common-contact-store-impl-test.vala +++ b/test/engine/common/common-contact-store-impl-test.vala @@ -17,6 +17,9 @@ class Geary.ContactStoreImplTest : TestCase { public ContactStoreImplTest() { base("Geary.ContactStoreImplTest"); add_test("get_by_rfc822", get_by_rfc822); + add_test("search_no_match", search_no_match); + add_test("search_email_match", search_email_match); + add_test("search_name_match", search_name_match); add_test("update_new_contact", update_new_contact); add_test("update_existing_contact", update_existing_contact); } @@ -51,7 +54,7 @@ INSERT INTO ContactTable ( ) VALUES ( 1, 'test@example.com', - 'Test', + 'Test Name', 'Test@example.com', 50 ); @@ -80,7 +83,7 @@ INSERT INTO ContactTable ( assert_non_null(existing, "Existing contact"); assert_string("Test@example.com", existing.email, "Existing email"); assert_string("test@example.com", existing.normalized_email, "Existing normalized_email"); - assert_string("Test", existing.real_name, "Existing real_name"); + assert_string("Test Name", existing.real_name, "Existing real_name"); assert_int(50, existing.highest_importance, "Existing highest_importance"); assert_false(existing.flags.always_load_remote_images(), "Existing flags"); @@ -93,6 +96,62 @@ INSERT INTO ContactTable ( assert_null(missing, "Missing contact"); } + public void search_no_match() throws GLib.Error { + test_article.search.begin( + "blarg", + 0, + 10, + null, + (obj, ret) => { async_complete(ret); } + ); + Gee.Collection results = test_article.search.end( + async_result() + ); + assert_int(0, results.size); + } + + public void search_email_match() throws GLib.Error { + test_article.search.begin( + "example.com", + 0, + 10, + null, + (obj, ret) => { async_complete(ret); } + ); + Gee.Collection results = test_article.search.end( + async_result() + ); + assert_int(1, results.size, "results.size"); + + Contact search_hit = Collection.get_first(results); + assert_string("Test@example.com", search_hit.email, "Existing email"); + assert_string("test@example.com", search_hit.normalized_email, "Existing normalized_email"); + assert_string("Test Name", search_hit.real_name, "Existing real_name"); + assert_int(50, search_hit.highest_importance, "Existing highest_importance"); + assert_false(search_hit.flags.always_load_remote_images(), "Existing flags"); + } + + public void search_name_match() throws GLib.Error { + test_article.search.begin( + "Test Name", + 0, + 10, + null, + (obj, ret) => { async_complete(ret); } + ); + Gee.Collection results = test_article.search.end( + async_result() + ); + assert_int(1, results.size, "results.size"); + + Contact search_hit = Collection.get_first(results); + assert_string("Test@example.com", search_hit.email, "Existing email"); + assert_string("test@example.com", search_hit.normalized_email, "Existing normalized_email"); + assert_string("Test Name", search_hit.real_name, "Existing real_name"); + assert_int(50, search_hit.highest_importance, "Existing highest_importance"); + assert_false(search_hit.flags.always_load_remote_images(), "Existing flags"); + } + public void update_new_contact() throws GLib.Error { Contact not_persisted = new Contact( "New@example.com", diff --git a/test/engine/imap-db/imap-db-database-test.vala b/test/engine/imap-db/imap-db-database-test.vala index 9e1b6e00..f937456e 100644 --- a/test/engine/imap-db/imap-db-database-test.vala +++ b/test/engine/imap-db/imap-db-database-test.vala @@ -16,6 +16,8 @@ class Geary.ImapDB.DatabaseTest : TestCase { base("Geary.ImapDb.DatabaseTest"); add_test("open_new", open_new); add_test("upgrade_0_6", upgrade_0_6); + add_test("utf8_case_insensitive_collation", + utf8_case_insensitive_collation); } public override void set_up() throws GLib.Error { @@ -123,6 +125,46 @@ class Geary.ImapDB.DatabaseTest : TestCase { db.close(); } + public void utf8_case_insensitive_collation() throws GLib.Error { + Database db = new Database( + this.tmp_dir.get_child("test.db"), + GLib.File.new_for_path(_SOURCE_ROOT_DIR).get_child("sql"), + this.tmp_dir.get_child("attachments"), + new Geary.SimpleProgressMonitor(Geary.ProgressType.DB_UPGRADE), + new Geary.SimpleProgressMonitor(Geary.ProgressType.DB_VACUUM) + ); + + db.open.begin( + Geary.Db.DatabaseFlags.CREATE_FILE, null, + (obj, ret) => { async_complete(ret); } + ); + db.open.end(async_result()); + + db.exec(""" + CREATE TABLE Test (id INTEGER PRIMARY KEY, test_str TEXT); + INSERT INTO Test (test_str) VALUES ('a'); + INSERT INTO Test (test_str) VALUES ('B'); + INSERT INTO Test (test_str) VALUES ('BB'); + INSERT INTO Test (test_str) VALUES ('🤯'); + """); + string[] expected = { "🤯", "BB", "B", "a" }; + + Db.Result result = db.query( + "SELECT test_str FROM Test ORDER BY test_str COLLATE UTF8ICASE DESC" + ); + + int i = 0; + while (!result.finished) { + assert_true(i < expected.length, "Too many rows"); + assert_string(expected[i], result.string_at(0)); + i++; + result.next(); + } + assert_true(i == expected.length, "Not enough rows"); + + // Need to close it again to stop the GC process running + db.close(); + } private void unpack_archive(GLib.File archive, GLib.File dest) throws Error {