Add Geary.ContactStore.search method, impementation and tests
This commit is contained in:
parent
66a664f98d
commit
71cb7fcdfe
7 changed files with 239 additions and 48 deletions
|
|
@ -20,6 +20,13 @@ public interface Geary.ContactStore : GLib.Object {
|
||||||
GLib.Cancellable? cancellable)
|
GLib.Cancellable? cancellable)
|
||||||
throws GLib.Error;
|
throws GLib.Error;
|
||||||
|
|
||||||
|
/** Searches for contacts based on a specific string */
|
||||||
|
public abstract async Gee.Collection<Contact> 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 */
|
/** Updates (or adds) a set of contacts in the underlying store */
|
||||||
public abstract async void update_contacts(Gee.Collection<Contact> updated,
|
public abstract async void update_contacts(Gee.Collection<Contact> updated,
|
||||||
GLib.Cancellable? cancellable)
|
GLib.Cancellable? cancellable)
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ internal class Geary.ContactStoreImpl : BaseObject, Geary.ContactStore {
|
||||||
this.backing = backing;
|
this.backing = backing;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the contact matching the given email address, if any */
|
|
||||||
public async Contact? get_by_rfc822(Geary.RFC822.MailboxAddress mailbox,
|
public async Contact? get_by_rfc822(Geary.RFC822.MailboxAddress mailbox,
|
||||||
GLib.Cancellable? cancellable)
|
GLib.Cancellable? cancellable)
|
||||||
throws GLib.Error {
|
throws GLib.Error {
|
||||||
|
|
@ -35,6 +34,24 @@ internal class Geary.ContactStoreImpl : BaseObject, Geary.ContactStore {
|
||||||
return contact;
|
return contact;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Gee.Collection<Contact> search(string query,
|
||||||
|
uint min_importance,
|
||||||
|
uint limit,
|
||||||
|
GLib.Cancellable? cancellable)
|
||||||
|
throws GLib.Error {
|
||||||
|
Gee.Collection<Contact>? 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<Contact> updated,
|
public async void update_contacts(Gee.Collection<Contact> updated,
|
||||||
GLib.Cancellable? cancellable)
|
GLib.Cancellable? cancellable)
|
||||||
throws GLib.Error {
|
throws GLib.Error {
|
||||||
|
|
@ -49,6 +66,75 @@ internal class Geary.ContactStoreImpl : BaseObject, Geary.ContactStore {
|
||||||
cancellable);
|
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<Contact> do_search_contact(Db.Connection cx,
|
||||||
|
string query,
|
||||||
|
uint min_importance,
|
||||||
|
uint limit,
|
||||||
|
GLib.Cancellable? cancellable)
|
||||||
|
throws GLib.Error {
|
||||||
|
Gee.Collection<Contact> contacts = new Gee.LinkedList<Contact>();
|
||||||
|
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,
|
private void do_update_contact(Db.Connection cx,
|
||||||
Contact updated,
|
Contact updated,
|
||||||
GLib.Cancellable? cancellable)
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,22 @@
|
||||||
* (version 2.1 or later). See the COPYING file in this distribution.
|
* (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);
|
extern int sqlite3_unicodesn_register_tokenizer(Sqlite.Database db);
|
||||||
|
|
||||||
private class Geary.ImapDB.Database : Geary.Db.VersionedDatabase {
|
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;
|
internal GLib.File attachments_path;
|
||||||
|
|
||||||
private const int OPEN_PUMP_EVENT_LOOP_MSEC = 100;
|
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_recursive_triggers(true);
|
||||||
cx.set_synchronous(Db.SynchronousMode.NORMAL);
|
cx.set_synchronous(Db.SynchronousMode.NORMAL);
|
||||||
sqlite3_unicodesn_register_tokenizer(cx.db);
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<Contact> contacts,
|
|
||||||
GLib.Cancellable? cancellable)
|
|
||||||
throws GLib.Error {
|
|
||||||
throw new EngineError.UNSUPPORTED("Mock method");
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public class MockClientService : ClientService {
|
public class MockClientService : ClientService {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,26 @@ internal class Geary.ContactStoreMock : ContactStore, MockObject, GLib.Object {
|
||||||
public async Contact? get_by_rfc822(Geary.RFC822.MailboxAddress address,
|
public async Contact? get_by_rfc822(Geary.RFC822.MailboxAddress address,
|
||||||
GLib.Cancellable? cancellable)
|
GLib.Cancellable? cancellable)
|
||||||
throws GLib.Error {
|
throws GLib.Error {
|
||||||
return object_call<Contact?>("get_by_rfc822", { address }, null);
|
return object_call<Contact?>(
|
||||||
|
"get_by_rfc822", { address, cancellable }, null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Gee.Collection<Contact> search(string query,
|
||||||
|
uint min_importance,
|
||||||
|
uint limit,
|
||||||
|
GLib.Cancellable? cancellable)
|
||||||
|
throws GLib.Error {
|
||||||
|
return object_call<Gee.Collection<Contact>>(
|
||||||
|
"search",
|
||||||
|
{
|
||||||
|
box_arg(query),
|
||||||
|
uint_arg(min_importance),
|
||||||
|
uint_arg(limit),
|
||||||
|
cancellable
|
||||||
|
},
|
||||||
|
Gee.Collection.empty<Contact>()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void update_contacts(Gee.Collection<Contact> updated,
|
public async void update_contacts(Gee.Collection<Contact> updated,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ class Geary.ContactStoreImplTest : TestCase {
|
||||||
public ContactStoreImplTest() {
|
public ContactStoreImplTest() {
|
||||||
base("Geary.ContactStoreImplTest");
|
base("Geary.ContactStoreImplTest");
|
||||||
add_test("get_by_rfc822", get_by_rfc822);
|
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_new_contact", update_new_contact);
|
||||||
add_test("update_existing_contact", update_existing_contact);
|
add_test("update_existing_contact", update_existing_contact);
|
||||||
}
|
}
|
||||||
|
|
@ -51,7 +54,7 @@ INSERT INTO ContactTable (
|
||||||
) VALUES (
|
) VALUES (
|
||||||
1,
|
1,
|
||||||
'test@example.com',
|
'test@example.com',
|
||||||
'Test',
|
'Test Name',
|
||||||
'Test@example.com',
|
'Test@example.com',
|
||||||
50
|
50
|
||||||
);
|
);
|
||||||
|
|
@ -80,7 +83,7 @@ INSERT INTO ContactTable (
|
||||||
assert_non_null(existing, "Existing contact");
|
assert_non_null(existing, "Existing contact");
|
||||||
assert_string("Test@example.com", existing.email, "Existing email");
|
assert_string("Test@example.com", existing.email, "Existing email");
|
||||||
assert_string("test@example.com", existing.normalized_email, "Existing normalized_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_int(50, existing.highest_importance, "Existing highest_importance");
|
||||||
assert_false(existing.flags.always_load_remote_images(), "Existing flags");
|
assert_false(existing.flags.always_load_remote_images(), "Existing flags");
|
||||||
|
|
||||||
|
|
@ -93,6 +96,62 @@ INSERT INTO ContactTable (
|
||||||
assert_null(missing, "Missing contact");
|
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<Contact> 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<Contact> 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<Contact> 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 {
|
public void update_new_contact() throws GLib.Error {
|
||||||
Contact not_persisted = new Contact(
|
Contact not_persisted = new Contact(
|
||||||
"New@example.com",
|
"New@example.com",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ class Geary.ImapDB.DatabaseTest : TestCase {
|
||||||
base("Geary.ImapDb.DatabaseTest");
|
base("Geary.ImapDb.DatabaseTest");
|
||||||
add_test("open_new", open_new);
|
add_test("open_new", open_new);
|
||||||
add_test("upgrade_0_6", upgrade_0_6);
|
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 {
|
public override void set_up() throws GLib.Error {
|
||||||
|
|
@ -123,6 +125,46 @@ class Geary.ImapDB.DatabaseTest : TestCase {
|
||||||
db.close();
|
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)
|
private void unpack_archive(GLib.File archive, GLib.File dest)
|
||||||
throws Error {
|
throws Error {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue