[folks] core: Implement base classes for searching
- From: Philip Withnall <pwithnall src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [folks] core: Implement base classes for searching
- Date: Fri, 13 Feb 2015 10:06:41 +0000 (UTC)
commit 8a07a8c29f473826264d53615cce821129cc6ae2
Author: Travis Reitter <travis reitter collabora co uk>
Date: Thu Jan 22 16:26:10 2015 +0000
core: Implement base classes for searching
This bumps our GLib dependency to 2.40.0 for use of
g_str_tokenize_and_fold() to perform the fuzzy search matching.
This adds the following new API allowing for dynamic search views over
an IndividualAggregator:
• Query interface
• SimpleQuery class
• SearchView class
It includes a fairly comprehensive test suite.
https://bugzilla.gnome.org/show_bug.cgi?id=646808
NEWS | 6 +
configure.ac | 2 +-
folks/Makefile.am | 3 +
folks/query.vala | 140 ++++++++
folks/search-view.vala | 538 +++++++++++++++++++++++++++++
folks/simple-query.vala | 522 ++++++++++++++++++++++++++++
tests/dummy/Makefile.am | 5 +
tests/dummy/search-view.vala | 778 ++++++++++++++++++++++++++++++++++++++++++
8 files changed, 1993 insertions(+), 1 deletions(-)
---
diff --git a/NEWS b/NEWS
index a2f18df..eb08da5 100644
--- a/NEWS
+++ b/NEWS
@@ -4,22 +4,28 @@ Overview of changes from libfolks 0.10.1 to libfolks 0.11.0
Dependencies:
• telepathy-glib ≥ 0.19.9
• evolution-data-server ≥ 3.13.90
+ • GLib ≥ 2.40.0
Major changes:
• The --enable-tests configure option has been renamed to
--enable-modular-tests
• Installed tests are now supported using --enable-installed-tests
+ • Add search-based retrieval of Individuals
Bugs fixed:
• Bug 641211 — Add arbitrary-field interface for applications to store trivial
per-person data
• Bug 743398 — Add support for installed-tests
• Bug 743934 — FTBFS after EDS commit 884fb8d8
+ • Bug 646808 — Add search-based retrieval
API changes:
• Add ExtendedInfo interface
• Add ExtendedFieldDetails class
• Implement ExtendedInfo in Individual and Edsf.Persona
+ • Add Query as an abstract class for searches
+ • Add SimpleQuery implementation of Query
+ • Add SearchView as a view on Individuals which match a given Query
Overview of changes from libfolks 0.10.0 to libfolks 0.10.1
===========================================================
diff --git a/configure.ac b/configure.ac
index e4086e0..f52db72 100644
--- a/configure.ac
+++ b/configure.ac
@@ -258,7 +258,7 @@ AM_CONDITIONAL([ENABLE_LIBSOCIALWEB],
# Dependencies
# -----------------------------------------------------------
-GLIB_REQUIRED=2.38.2
+GLIB_REQUIRED=2.40.0
VALA_REQUIRED=0.22.0.28-9090
VALADOC_REQUIRED=0.3.1
TRACKER_SPARQL_REQUIRED=0.15.2
diff --git a/folks/Makefile.am b/folks/Makefile.am
index 86e91ab..8a9c12b 100644
--- a/folks/Makefile.am
+++ b/folks/Makefile.am
@@ -106,6 +106,9 @@ libfolks_la_SOURCES = \
avatar-cache.vala \
object-cache.vala \
anti-linkable.vala \
+ query.vala \
+ search-view.vala \
+ simple-query.vala \
$(NULL)
if ENABLE_EDS
diff --git a/folks/query.vala b/folks/query.vala
new file mode 100644
index 0000000..7ed8d64
--- /dev/null
+++ b/folks/query.vala
@@ -0,0 +1,140 @@
+/*
+ * Copyright (C) 2011, 2015 Collabora Ltd.
+ *
+ * This library is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this library. If not, see <http://www.gnu.org/licenses/>.
+ *
+ * Authors:
+ * Travis Reitter <travis reitter collabora co uk>
+ * Philip Withnall <philip withnall collabora co uk>
+ */
+
+using Gee;
+using GLib;
+
+/**
+ * A contact query.
+ *
+ * If any properties of the query change such that matches may change, the
+ * { link GLib.Object.notify} signal will be emitted, potentially without a
+ * detail string. Views which are using this query should re-evaluate their
+ * matches on receiving this signal.
+ *
+ * @see SearchView
+ * @since UNRELEASED
+ */
+public abstract class Folks.Query : Object
+{
+ /* FIXME: make PersonaStore._PERSONA_DETAIL internal and use it here once
+ * bgo#663886 is fixed */
+ /**
+ * Set of name match fields.
+ *
+ * These are ordered approximately by descending match likeliness to speed up
+ * calls to { link is_match} when used as-is.
+ *
+ * @since UNRELEASED
+ */
+ public static const string MATCH_FIELDS_NAMES[] =
+ {
+ "alias",
+ "full-name",
+ "nickname",
+ "structured-name"
+ };
+
+ /* FIXME: make PersonaStore._PERSONA_DETAIL internal and use it here once
+ * bgo#663886 is fixed */
+ /**
+ * Set of address (email, IM, postal, phone number, etc.) match fields.
+ *
+ * These are ordered approximately by descending match likeliness to speed up
+ * calls to { link is_match} when used as-is.
+ *
+ * @since UNRELEASED
+ */
+ public static const string MATCH_FIELDS_ADDRESSES[] =
+ {
+ "email-addresses",
+ "im-addresses",
+ "phone-numbers",
+ "postal-addresses",
+ "web-service-addresses",
+ "urls"
+ };
+
+ /* FIXME: make PersonaStore._PERSONA_DETAIL internal and use it here once
+ * bgo#663886 is fixed */
+ /**
+ * Set of miscellaneous match fields.
+ *
+ * These are ordered approximately by descending match likeliness to speed up
+ * calls to { link is_match} when used as-is.
+ *
+ * @since UNRELEASED
+ */
+ public static const string MATCH_FIELDS_MISC[] =
+ {
+ "groups",
+ "roles",
+ "notes"
+ };
+
+ private string[] _match_fields = MATCH_FIELDS_NAMES;
+ /**
+ * The names of the fields to match within
+ *
+ * The names of valid fields are available via
+ * { link PersonaStore.detail_key}.
+ *
+ * The ordering of the fields determines the order they are checked for
+ * matches, which can have performance implications (these should ideally be
+ * ordered from most- to least-likely to match).
+ *
+ * Also note that more fields (particularly rarely-matched fields) will
+ * negatively impact performance, so only include important fields.
+ *
+ * Default value is { link Query.MATCH_FIELDS_NAMES}.
+ *
+ * @since UNRELEASED
+ * @see PersonaDetail
+ * @see PersonaStore.detail_key
+ * @see Query.MATCH_FIELDS_NAMES
+ * @see Query.MATCH_FIELDS_ADDRESSES
+ * @see Query.MATCH_FIELDS_MISC
+ */
+ public virtual string[] match_fields
+ {
+ get { return this._match_fields; }
+ protected construct { this._match_fields = value; }
+ }
+
+ /**
+ * Determines whether a given { link Individual} matches this query.
+ *
+ * This returns a match strength, which is on an arbitrary scale which is not
+ * part of libfolks’ public API. These strengths should not be stored by user
+ * applications, or examined numerically — they should only be used for
+ * pairwise strength comparisons.
+ *
+ * This function is intended to be used in the { link SearchView}
+ * implementation only. Use { link SearchView.individuals} to retrieve search
+ * results.
+ *
+ * @param individual an { link Individual} to match against
+ * @return a positive integer if the individual matches this query, or zero
+ * if they do not match; higher numbers indicate a better match
+ * @since UNRELEASED
+ */
+ public abstract uint is_match (Individual individual);
+}
diff --git a/folks/search-view.vala b/folks/search-view.vala
new file mode 100644
index 0000000..6755e17
--- /dev/null
+++ b/folks/search-view.vala
@@ -0,0 +1,538 @@
+/*
+ * Copyright (C) 2011, 2015 Collabora Ltd.
+ *
+ * This library is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this library. If not, see <http://www.gnu.org/licenses/>.
+ *
+ * Authors:
+ * Travis Reitter <travis reitter collabora co uk>
+ * Philip Withnall <philip withnall collabora co uk>
+ */
+
+using Gee;
+using GLib;
+
+/**
+ * A view of { link Individual}s which match a given { link Query}.
+ *
+ * The search view supports ‘live’ and ‘snapshot’ search results. Live results
+ * will continue to update over a long period of time as persona stores go
+ * online and offline or individuals are edited so they start or stop matching
+ * the { link Query}.
+ *
+ * For a shell search provider, for example, snapshot results are appropriate.
+ * For a search in a contacts UI, live results are more appropriate as they will
+ * update over time as other edits are made in the application.
+ *
+ * In both cases, { link SearchView.individuals} is guaranteed to be correct
+ * after { link SearchView.prepare} finishes.
+ *
+ * For live results, continue listening to the
+ * { link SearchView.individuals_changed_detailed} signal.
+ *
+ * @since UNRELEASED
+ */
+public class Folks.SearchView : Object
+{
+ private bool _prepare_pending = false;
+
+ private IndividualAggregator _aggregator;
+ /**
+ * The { link IndividualAggregator} that this view is based upon.
+ *
+ * @since UNRELEASED
+ */
+ public IndividualAggregator aggregator
+ {
+ get { return this._aggregator; }
+ }
+
+ private Query _query;
+ /**
+ * The { link Query} that this view is based upon.
+ *
+ * If this { link SearchView} has already been prepared, setting this will
+ * force a re-evaluation of all { link Individual}s in the
+ * { link IndividualAggregator} which can be an expensive operation.
+ *
+ * This re-evaluates the query immediately, so most clients should implement
+ * de-bouncing to ensure re-evaluation only happens when (for example) the
+ * user has stopped typing a new query.
+ *
+ * @since UNRELEASED
+ */
+ public Query query
+ {
+ get { return this._query; }
+ set
+ {
+ if (this._query == value)
+ return;
+
+ if (this._query != null)
+ {
+ debug ("SearchView's query replaced, forcing re-evaluation of " +
+ "all Individuals.");
+ }
+
+ this._query.notify.disconnect (this._query_notify_cb);
+ this._query = value;
+ this._query.notify.connect (this._query_notify_cb);
+
+ /* Re-evaluate all Individuals (only if necessary) */
+ this.refresh.begin ();
+ }
+ }
+
+ private SortedSet<Individual> _individuals;
+ private SortedSet<Individual> _individuals_ro;
+ /**
+ * A sorted set of { link Individual}s which match the search query.
+ *
+ * This is the canonical set of { link Individual}s provided by this
+ * view. It is sorted by match strength, with the individual who is the best
+ * match to the search query as the { link Gee.SortedSet.first} element of
+ * the set.
+ *
+ * Match strengths are not publicly exposed, as they are on an arbitrary
+ * scale. To compare two matching individuals for match strength, check for
+ * membership of one of them in the { link Gee.SortedSet.head_set} of the
+ * other.
+ *
+ * For clients who only wish to have a snapshot of search results, this
+ * property is valid once { link SearchView.prepare} is finished and this
+ * { link SearchView} may be unreferenced and ignored afterward.
+ *
+ * @since UNRELEASED
+ */
+ public SortedSet<Individual> individuals
+ {
+ get { return this._individuals_ro; }
+ }
+
+ private bool _is_prepared = false;
+ /**
+ * Whether { link IndividualAggregator.prepare} has successfully completed for
+ * this view's aggregator.
+ *
+ * @since UNRELEASED
+ */
+ public bool is_prepared
+ {
+ get { return this._is_prepared; }
+ }
+
+ /**
+ * Whether the search view has reached a quiescent state. This will happen at
+ * some point after { link IndividualAggregator.prepare} has successfully
+ * completed for its aggregator.
+ *
+ * It's guaranteed that this property's value will only ever change after
+ * { link SearchView.is_prepared} has changed to ``true``.
+ *
+ * @since UNRELEASED
+ */
+ public bool is_quiescent
+ {
+ /* Just proxy the aggregator’s quiescence. If we implement anything fancy
+ * and async in our matching in future, this can change. */
+ get { return this.aggregator.is_quiescent; }
+ }
+
+ private void _aggregator_is_quiescent_cb ()
+ {
+ this.notify_property ("is-quiescent");
+ }
+
+ /**
+ * Emitted when one or more { link Individual}s are added to or removed from
+ * the view.
+ *
+ * The sets of `added` and `removed` individuals are sorted by descending
+ * match strength. Using the { link Gee.SortedSet.lower} and
+ * { link Gee.SortedSet.higher} APIs with { link SearchView.individuals}, the
+ * `added` individuals can be inserted at the correct positions in a UI
+ * representation of the search view.
+ *
+ * The match strengths are on the same scale as in
+ * { link SearchView.individuals}, so orderings between the two sorted sets
+ * are valid. See { link SearchView.individuals} for more information about
+ * match strengths.
+ *
+ * @param added a set of { link Individual}s added to the search view
+ * @param removed a set of { link Individual}s removed from the search view
+ *
+ * @see IndividualAggregator.individuals_changed_detailed
+ * @since UNRELEASED
+ */
+ public signal void individuals_changed_detailed (SortedSet<Individual> added,
+ SortedSet<Individual> removed);
+
+ /**
+ * Create a new view of Individuals matching a given query.
+ *
+ * This view will be kept up-to-date as individuals change (which may change
+ * their membership in the results).
+ *
+ * @param query query to match upon
+ * @param aggregator the { link IndividualAggregator} to match within
+ *
+ * @since UNRELEASED
+ */
+ public SearchView (IndividualAggregator aggregator, Query query)
+ {
+ debug ("Constructing SearchView %p", this);
+
+ this._aggregator = aggregator;
+ this._aggregator.notify["is-quiescent"].connect (
+ this._aggregator_is_quiescent_cb);
+ this._individuals = this._create_empty_sorted_set ();
+ this._individuals_ro = this._individuals.read_only_view;
+ this._is_prepared = false;
+ this._prepare_pending = false;
+ this._query = query;
+ }
+
+ ~SearchView ()
+ {
+ debug ("Destroying SearchView %p", this);
+
+ this._aggregator.notify["is-quiescent"].disconnect (
+ this._aggregator_is_quiescent_cb);
+ }
+
+ /**
+ * Prepare the view for use.
+ *
+ * This calls { link IndividualAggregator.prepare} as necessary to start
+ * aggregating all { link Individual}s.
+ *
+ * This function is guaranteed to be idempotent, so multiple search views may
+ * share a single aggregator; { link SearchView.prepare} must be called on all
+ * of the views.
+ *
+ * For any clients only interested in a snapshot of search results,
+ * { link SearchView.individuals} is valid once this async function is
+ * finished.
+ *
+ * @throws GLib.Error if preparation failed
+ *
+ * @since UNRELEASED
+ */
+ public async void prepare () throws GLib.Error
+ {
+ if (!this._is_prepared && !this._prepare_pending)
+ {
+ this._prepare_pending = true;
+ this._aggregator.individuals_changed_detailed.connect (
+ this._aggregator_individuals_changed_detailed_cb);
+ try
+ {
+ yield this._aggregator.prepare ();
+ }
+ catch (GLib.Error e)
+ {
+ this._prepare_pending = false;
+ this._aggregator.individuals_changed_detailed.disconnect (
+ this._aggregator_individuals_changed_detailed_cb);
+
+ throw e;
+ }
+
+ this._is_prepared = true;
+ this._prepare_pending = false;
+ this.notify_property ("is-prepared");
+
+ yield this.refresh ();
+ }
+ }
+
+ /**
+ * Clean up and release resources used by the search view.
+ *
+ * This will disconnect the aggregator cleanly from any resources it is using.
+ * It is recommended to call this method before finalising the search view,
+ * but calling it is not required.
+ *
+ * Note that this will not unprepare the underlying aggregator: call
+ * { link IndividualAggregator.unprepare} to do that. This allows multiple
+ * search views to use a single aggregator and unprepare at different times.
+ *
+ * Concurrent calls to this function from different threads will block until
+ * preparation has completed. However, concurrent calls to this function from
+ * a single thread might not, i.e. the first call will block but subsequent
+ * calls might return before the first one. (Though they will be safe in every
+ * other respect.)
+ *
+ * @since UNRELEASED
+ * @throws GLib.Error if unpreparing the backend-specific services failed —
+ * this will be a backend-specific error
+ */
+ public async void unprepare () throws GLib.Error
+ {
+ if (!this._is_prepared || this._prepare_pending)
+ {
+ return;
+ }
+
+ this._prepare_pending = false;
+ }
+
+ /**
+ * Refresh the view’s results.
+ *
+ * Explicitly re-match all the view’s results to ensure matches are up to
+ * date. For a normal { link IndividualAggregator}, this is explicitly not
+ * necessary, as the view will watch signal emissions from the aggregator and
+ * keep itself up to date.
+ *
+ * However, for search-only persona stores, which do not support notification
+ * of changes to personas, this method is the only way to update the set of
+ * matches against the store.
+ *
+ * This method should be called whenever an explicit update is needed to the
+ * search results, e.g. if the user requests a refresh.
+ *
+ * @throws GLib.Error if matching failed
+ * @since UNRELEASED
+ */
+ public async void refresh () throws GLib.Error
+ {
+ if (this._is_prepared)
+ this._evaluate_all_aggregator_individuals ();
+ }
+
+ private void _aggregator_individuals_changed_detailed_cb (
+ MultiMap<Individual?, Individual?> changes)
+ {
+ this._evaluate_individuals (changes, null);
+ }
+
+ private string _build_match_strength_key ()
+ {
+ /* FIXME: This is a pretty big hack. Ideally, we would use a custom
+ * SortedSmallSet implementation, written in C and using a GPtrArray,
+ * instead of TreeSet and this GObject data hackery.
+ *
+ * However, since we’re dealing with small result sets, this is good
+ * enough for a first implementation. */
+ return "folks-match-strength-%p".printf (this);
+ }
+
+ private int _compare_individual_matches (Individual a, Individual b)
+ {
+ /* Zero must only be returned if the individuals are equal, not in terms
+ * of their match strength, but in terms of their content. */
+ if (a == b)
+ return 0;
+
+ var key = this._build_match_strength_key ();
+
+ /* If either of these are unset, they will be zero, meaning they don’t
+ * match the query. Normal match strengths are positive, so that works out
+ * fine. */
+ var match_strength_a = a.get_data<uint> (key);
+ var match_strength_b = b.get_data<uint> (key);
+
+ if (match_strength_a != match_strength_b)
+ return ((int) match_strength_b - (int) match_strength_a);
+
+ /* Break match strength ties by display name. */
+ var display_name = a.display_name.collate (b.display_name);
+ if (display_name != 0)
+ return display_name;
+
+ /* Break display name ties by ID (which will be stable). */
+ return a.id.collate (b.id);
+ }
+
+ private SortedSet<Individual> _create_empty_sorted_set ()
+ {
+ return new TreeSet<Individual> (this._compare_individual_matches);
+ }
+
+ private void _evaluate_individuals (
+ MultiMap<Individual?, Individual?>? changes,
+ Set<Individual?>? evaluates)
+ {
+ var view_added = this._create_empty_sorted_set ();
+ var view_removed = this._create_empty_sorted_set ();
+
+ /* Determine whether each evaluate should be added or removed (note that
+ * pure adds from 'changes' may only be added, never removed) */
+ if (evaluates != null)
+ {
+ foreach (var evaluate in evaluates)
+ {
+ if (evaluate == null)
+ continue;
+
+ if (this._check_match (evaluate))
+ view_added.add (evaluate);
+ else
+ view_removed.add (evaluate);
+ }
+ }
+
+ /* Determine which adds and removals make sense for the given query */
+ if (changes != null)
+ {
+ /* Determine whether given adds should actually be added (they mostly
+ * come from the Aggregator, so we need to filter out non-matches) */
+ var iter = changes.map_iterator ();
+
+ while (iter.next ())
+ {
+ var individual_old = iter.get_key ();
+ var individual_new = iter.get_value ();
+
+ if (individual_new != null && this._check_match (individual_new))
+ {
+ /* @individual_new is being added (if @individual_old is
+ * `null`) or replacing @individual_old. */
+ view_added.add (individual_new);
+ }
+
+ if (individual_old != null)
+ {
+ /* If @individual_new doesn’t match, or if @individual_old is
+ * simply being removed, ensure there’s an entry in the change
+ * set to remove @individual_old. */
+ view_removed.add (individual_old);
+ }
+ }
+ }
+
+ /* Perform all removals. Update the @view_removed set if we haven’t ever
+ * exposed the individual in { link SearchView.individuals}. */
+ var iter = view_removed.iterator ();
+
+ while (iter.next ())
+ {
+ var individual_old = iter.get ();
+
+ if (individual_old != null &&
+ !this._remove_individual (individual_old))
+ {
+ iter.remove ();
+ }
+ }
+
+ /* Perform all additions. Update the @view_added set if we haven’t ever
+ * exposed the individual in { link SearchView.individuals}. */
+ iter = view_added.iterator ();
+
+ while (iter.next ())
+ {
+ var individual_new = iter.get ();
+
+ if (individual_new != null && !this._add_individual (individual_new))
+ {
+ iter.remove ();
+ }
+ }
+
+ /* Notify of changes. */
+ if (view_added.size > 0 || view_removed.size > 0)
+ this.individuals_changed_detailed (view_added, view_removed);
+ }
+
+ private inline bool _add_individual (Individual individual)
+ {
+ if (this._individuals.add (individual))
+ {
+ individual.notify.connect (this._individual_notify_cb);
+ return true;
+ }
+
+ return false;
+ }
+
+ private inline bool _remove_individual (Individual individual)
+ {
+ if (this._individuals.remove (individual))
+ {
+ individual.notify.disconnect (this._individual_notify_cb);
+ return true;
+ }
+
+ return false;
+ }
+
+ private void _evaluate_all_aggregator_individuals ()
+ {
+ var individuals = new HashSet<Individual?> ();
+ individuals.add_all (this._aggregator.individuals.values);
+ this._evaluate_individuals (null, individuals);
+ }
+
+ /* Returns whether the individual matches the current query. */
+ private bool _check_match (Individual individual)
+ {
+ uint match_score = this._query.is_match (individual);
+
+ var key = this._build_match_strength_key ();
+ individual.set_data (key, match_score);
+
+ return (match_score != 0);
+ }
+
+ /* Returns true if individual matches (regardless of whether we already knew
+ * about it) */
+ private bool _evaluate_match (Individual individual)
+ {
+ var match = this._check_match (individual);
+
+ if (match)
+ {
+ this._add_individual (individual);
+ }
+ else
+ {
+ this._remove_individual (individual);
+ }
+
+ return match;
+ }
+
+ private void _individual_notify_cb (Object obj, ParamSpec ps)
+ {
+ var individual = obj as Individual;
+
+ if (individual == null)
+ return;
+
+ var had_individual = this._individuals.contains (individual);
+ var have_individual = this._evaluate_match (individual);
+
+ var added = (!had_individual && have_individual);
+ var removed = (had_individual && !have_individual);
+ var view_added = this._create_empty_sorted_set ();
+ var view_removed = this._create_empty_sorted_set ();
+
+ if (added)
+ view_added.add (individual);
+ else if (removed)
+ view_removed.add (individual);
+
+ if (view_added.size > 0 || view_removed.size > 0)
+ this.individuals_changed_detailed (view_added, view_removed);
+ }
+
+ private void _query_notify_cb (Object obj, ParamSpec ps)
+ {
+ debug ("SearchView's Query changed, forcing re-evaluation of all " +
+ "Individuals");
+ this.refresh.begin ();
+ }
+}
diff --git a/folks/simple-query.vala b/folks/simple-query.vala
new file mode 100644
index 0000000..eccfe3e
--- /dev/null
+++ b/folks/simple-query.vala
@@ -0,0 +1,522 @@
+/*
+ * Copyright (C) 2011, 2015 Collabora Ltd.
+ *
+ * This library is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this library. If not, see <http://www.gnu.org/licenses/>.
+ *
+ * Authors:
+ * Travis Reitter <travis reitter collabora co uk>
+ * Philip Withnall <philip withnall collabora co uk>
+ */
+
+using Gee;
+using GLib;
+
+/**
+ * A simple text-based contact query.
+ *
+ * This is a generic implementation of the { link Query} interface which
+ * supports general UI-style search use cases. It implements case-insensitive
+ * prefix matching, with transliteration of accents and other non-ASCII
+ * characters to improve matching against accented characters. It also
+ * normalises phone numbers to make matches invariant to hyphenation and spacing
+ * in phone numbers.
+ *
+ * @see SearchView
+ * @since UNRELEASED
+ */
+public class Folks.SimpleQuery : Folks.Query
+{
+ /* These are guaranteed to be non-null */
+ private string _query_string;
+ private string[] _query_tokens;
+ /**
+ * The text query string.
+ *
+ * This re-evaluates the query immediately, so most clients should implement
+ * de-bouncing to ensure re-evaluation only happens when (for example) the
+ * user has stopped typing a new query.
+ *
+ * @since UNRELEASED
+ */
+ public string query_string
+ {
+ get { return this._query_string; }
+ set
+ {
+ if (value == null)
+ value = "";
+
+ if (this._query_string == value)
+ return;
+
+ this._update_query_string (value, this._query_locale);
+ }
+ }
+
+ private string? _query_locale = null;
+ /**
+ * Locale to interpret the { link SimpleQuery.query_string} in.
+ *
+ * If possible, locale-specific query string transliteration is done to
+ * increase the number of matches. Set this property to a POSIX locale name
+ * (e.g. ‘en’, ‘de_DE’, ‘de_DE euro’ or ‘C’) to potentially improve the
+ * transliteration performed.
+ *
+ * This may be `null` if the locale is unknown, in which case the current
+ * locale will be used. To perform transliteration for no specific locale,
+ * use `C`.
+ *
+ * @since UNRELEASED
+ */
+ public string? query_locale
+ {
+ get { return this._query_locale; }
+ set
+ {
+ if (this._query_locale == value)
+ return;
+
+ this._update_query_string (this._query_string, value);
+ }
+ }
+
+ private void _update_query_string (string query_string,
+ string? query_locale)
+ {
+ this._query_string = query_string;
+ this._query_locale = query_locale;
+ this._query_tokens =
+ this._query_string.tokenize_and_fold (this.query_locale, null);
+
+ debug ("Created simple query with tokens:");
+ foreach (var token in this._query_tokens)
+ debug ("\t%s", token);
+
+ /* Notify of the need to re-evaluate matches. */
+ this.freeze_notify ();
+ this.notify_property ("query-string");
+ this.notify_property ("query-locale");
+ this.thaw_notify ();
+ }
+
+ /**
+ * Create a simple text query.
+ *
+ * @param query_string text to match contacts against. Results will match all
+ * tokens within the whitespace-delimited string (logical-ANDing the tokens).
+ * A value of "" will match all contacts. However, it is recommended to not
+ * use a query at all if filtering is not required.
+ * @param match_fields the field names to apply this query to. See
+ * { link Query.match_fields} for more details. An empty array will match all
+ * contacts. However, it is recommended to use the
+ * { link IndividualAggregator} directly if filtering is not required.
+ * { link PersonaDetail} and { link PersonaStore.detail_key} for pre-defined
+ * field names.
+ *
+ * @since UNRELEASED
+ */
+ public SimpleQuery (
+ string query_string,
+ string[] match_fields)
+ {
+ /* Elements of match_fields should be unique, but it's up to the caller
+ * to not repeat themselves */
+
+ /* The given match_fields isn't null-terminated by default in
+ * code that uses our predefined match_fields vectors (like
+ * Query.MATCH_FIELDS_NAMES), so we need to create a twin array that is;
+ * see bgo#659305 */
+ var match_fields_safe = match_fields;
+
+ Object (query_string: query_string,
+ match_fields: match_fields_safe,
+ query_locale: Intl.setlocale (LocaleCategory.ALL, null));
+ }
+
+ /**
+ * { inheritDoc}
+ *
+ * @since UNRELEASED
+ */
+ public override uint is_match (Individual individual)
+ {
+ /* Treat an empty query string or empty set of fields as "match all" */
+ if (this._query_tokens.length < 1 || this.match_fields.length < 1)
+ return 1;
+
+ /* Only check for matches in tokens not yet found to minimize our work */
+ var tokens_remaining = new HashSet<string> ();
+
+ foreach (var t in this._query_tokens)
+ tokens_remaining.add (t);
+
+ /* FIXME: In the future, we should find a way to know this Individual’s
+ * locale, and hence hook up translit_locale to improve matches. */
+ string? individual_translit_locale = null;
+
+ /* Check for all tokens within a given field before moving on to the next
+ * field on the assumption that the vast majority of searches will have
+ * all tokens within the same field (eg, both tokens in "Jane Doe" will
+ * match in one of the name fields).
+ *
+ * Track the match score as we go. */
+ uint match_score = 0;
+
+ foreach (var prop_name in this.match_fields)
+ {
+ unowned ObjectClass iclass = individual.get_class ();
+ var prop_spec = iclass.find_property (prop_name);
+ if (prop_spec == null)
+ {
+ warning ("Folks.Individual does not contain property '%s'",
+ prop_name);
+ }
+ else
+ {
+ var iter = tokens_remaining.iterator ();
+ while (iter.next ())
+ {
+ var token = iter.get ();
+ var inc = this._prop_contains_token (individual,
+ individual_translit_locale, prop_name, prop_spec, token);
+ match_score += inc;
+
+ if (inc > 0)
+ {
+ iter.remove ();
+ if (tokens_remaining.size == 0)
+ return match_score;
+ }
+ }
+ }
+ }
+
+ /* Not all of the tokens matched. We do a boolean-and match, so fail. */
+ assert (tokens_remaining.size > 0);
+ return 0;
+ }
+
+ /* Return a match score: a positive integer on a match, zero on no match.
+ *
+ * The match score weightings in this function are fairly arbitrary and can
+ * be tweaked. They were generally chosen to prefer names. */
+ private uint _prop_contains_token (
+ Individual individual,
+ string? individual_translit_locale,
+ string prop_name,
+ ParamSpec prop_spec,
+ string token)
+ {
+ /* It's safe to assume that this._query_tokens.length >= 1 */
+
+ /* All properties ordered from most-likely-match to least-likely-match to
+ * return as early as possible */
+ if (false) {}
+ else if (prop_spec.value_type == typeof (string))
+ {
+ string prop_value;
+ individual.get (prop_name, out prop_value);
+
+ if (prop_value == null || prop_value == "")
+ return 0;
+
+ var score = this._string_matches_token (prop_value, token,
+ individual_translit_locale);
+ if (score > 0)
+ {
+ /* Weight names more highly. */
+ if (prop_name == "full-name" || prop_name == "nickname")
+ return score * 10;
+ else
+ return score * 2;
+ }
+ }
+ else if (prop_spec.value_type == typeof (StructuredName))
+ {
+ StructuredName prop_value;
+ individual.get (prop_name, out prop_value);
+
+ if (prop_value == null)
+ return 0;
+
+ var score = this._string_matches_token (prop_value.given_name, token,
+ individual_translit_locale);
+ if (score > 0)
+ return score * 10;
+
+ score = this._string_matches_token (prop_value.family_name, token,
+ individual_translit_locale);
+ if (score > 0)
+ return score * 10;
+
+ score = this._string_matches_token (prop_value.additional_names,
+ token, individual_translit_locale);
+ if (score > 0)
+ return score * 5;
+
+ /* Skip prefixes and suffixes because CPUs have better things to do */
+ }
+ else if (prop_spec.value_type == typeof (Gee.Set))
+ {
+ Gee.Set prop_value_set;
+ individual.get (prop_name, out prop_value_set);
+
+ if (prop_value_set == null || prop_value_set.is_empty)
+ return 0;
+
+ if (prop_value_set.element_type.is_a (typeof (AbstractFieldDetails)))
+ {
+ var prop_value_afd = prop_value_set
+ as Gee.Set<AbstractFieldDetails>;
+ foreach (var val in prop_value_afd)
+ {
+ if (val.value_type == typeof (string))
+ {
+ /* E-mail addresses, phone numbers, URLs, notes. */
+ var score = this._prop_contains_token_fd_string (
+ individual, individual_translit_locale, prop_name,
+ prop_spec, val, token);
+ if (score > 0)
+ {
+ if (prop_name == "email-addresses")
+ return score * 4;
+ else
+ return score * 2;
+ }
+ }
+ else if (val.value_type == typeof (Role))
+ {
+ /* Roles. */
+ var score = this._prop_contains_token_fd_role (individual,
+ individual_translit_locale, prop_name, prop_spec, val,
+ token);
+ if (score > 0)
+ {
+ return score * 1;
+ }
+ }
+ else if (val.value_type == typeof (PostalAddress))
+ {
+ /* Postal addresses. */
+ var score = this._prop_contains_token_fd_postal_address (
+ individual, individual_translit_locale, prop_name,
+ prop_spec, val, token);
+ if (score > 0)
+ {
+ return score * 3;
+ }
+ }
+ else
+ {
+ warning ("Cannot check for match in detail type " +
+ "Gee.Set<AbstractFieldDetails<%s>>",
+ val.value_type.name ());
+ return 0;
+ }
+ }
+ }
+ else if (prop_value_set.element_type == typeof (string))
+ {
+ /* Groups and local IDs. */
+ var prop_value_string = prop_value_set as Gee.Set<string>;
+ foreach (var val in prop_value_string)
+ {
+ if (val == null || val == "")
+ continue;
+
+ var score = this._string_matches_token (val, token,
+ individual_translit_locale);
+ if (score > 0)
+ return score * 1;
+ }
+ }
+ else
+ {
+ warning ("Cannot check for match in property ‘%s’, detail type " +
+ "Gee.Set<%s>", prop_name,
+ prop_value_set.element_type.name ());
+ return 0;
+ }
+
+ }
+ else if (prop_spec.value_type == typeof (Gee.MultiMap))
+ {
+ Gee.MultiMap prop_value_multi_map;
+ individual.get (prop_name, out prop_value_multi_map);
+
+ if (prop_value_multi_map == null || prop_value_multi_map.size < 1)
+ return 0;
+
+ var key_type = prop_value_multi_map.key_type;
+ var value_type = prop_value_multi_map.value_type;
+
+ if (key_type.is_a (typeof (string)) &&
+ value_type.is_a (typeof (AbstractFieldDetails)))
+ {
+ var prop_value_multi_map_afd = prop_value_multi_map
+ as Gee.MultiMap<string, AbstractFieldDetails>;
+ var iter = prop_value_multi_map_afd.map_iterator ();
+
+ while (iter.next ())
+ {
+ var val = iter.get_value ();
+
+ /* IM addresses, web service addresses. */
+ if (val.value_type == typeof (string))
+ {
+ var score = this._prop_contains_token_fd_string (
+ individual, individual_translit_locale, prop_name,
+ prop_spec, val, token);
+ if (score > 0)
+ {
+ return score * 2;
+ }
+ }
+ }
+ }
+ else
+ {
+ warning ("Cannot check for match in detail type " +
+ "Gee.MultiMap<%s, %s>",
+ key_type.name (), value_type.name ());
+ return 0;
+ }
+ }
+ else
+ {
+ warning ("Cannot check for match in detail type %s",
+ prop_spec.value_type.name ());
+ }
+
+ return 0;
+ }
+
+ private uint _prop_contains_token_fd_string (
+ Individual individual,
+ string? individual_translit_locale,
+ string prop_name,
+ ParamSpec prop_spec,
+ AbstractFieldDetails<string> val,
+ string token)
+ {
+ if (val.get_type () == typeof (PhoneFieldDetails))
+ {
+ /* If this doesn’t match, fall through and try and normal string
+ * match. This allows for the case of, e.g. matching query ‘123-4567’
+ * against ‘+01234567890’. The query string is tokenised to ‘123’ and
+ * ‘4567’, neither of which would normally match against the full
+ * phone number. */
+ if (val.values_equal (new PhoneFieldDetails (token)))
+ return 2;
+ }
+
+ return this._string_matches_token (val.value, token,
+ individual_translit_locale);
+
+ /* Intentionally ignore the params; they're not interesting */
+ }
+
+ private uint _prop_contains_token_fd_postal_address (
+ Individual individual,
+ string? individual_translit_locale,
+ string prop_name,
+ ParamSpec prop_spec,
+ AbstractFieldDetails<PostalAddress> val,
+ string token)
+ {
+ var score = this._string_matches_token (val.value.street, token,
+ individual_translit_locale);
+ if (score > 0)
+ return score;
+
+ score = this._string_matches_token (val.value.locality, token,
+ individual_translit_locale);
+ if (score > 0)
+ return score;
+
+ score = this._string_matches_token (val.value.region, token,
+ individual_translit_locale);
+ if (score > 0)
+ return score;
+
+ score = this._string_matches_token (val.value.country, token,
+ individual_translit_locale);
+ if (score > 0)
+ return score;
+
+ /* All other fields intentionally ignored due to general irrelevance */
+ return 0;
+ }
+
+ private uint _prop_contains_token_fd_role (
+ Individual individual,
+ string? individual_translit_locale,
+ string prop_name,
+ ParamSpec prop_spec,
+ AbstractFieldDetails<Role> val,
+ string token)
+ {
+ var score = this._string_matches_token (val.value.organisation_name,
+ token, individual_translit_locale);
+ if (score > 0)
+ return score;
+
+ score = this._string_matches_token (val.value.title, token,
+ individual_translit_locale);
+ if (score > 0)
+ return score;
+
+ score = this._string_matches_token (val.value.role, token,
+ individual_translit_locale);
+ if (score > 0)
+ return score;
+
+ /* Intentionally ignore the params; they're not interesting */
+ return 0;
+ }
+
+ private inline uint _string_matches_token (string str, string token,
+ string? str_translit_locale = null)
+ {
+ debug ("Matching string ‘%s’ against token ‘%s’.", str, token);
+
+ string[] alternates;
+ var str_tokens =
+ str.tokenize_and_fold (str_translit_locale, out alternates);
+
+ /* FIXME: We have to use for() rather than foreach() because of:
+ * https://bugzilla.gnome.org/show_bug.cgi?id=743877 */
+ for (var i = 0; str_tokens[i] != null; i++)
+ {
+ var str_token = str_tokens[i];
+
+ if (str_token == token)
+ return 3;
+ else if (str_token.has_prefix (token))
+ return 2;
+ }
+
+ for (var i = 0; alternates[i] != null; i++)
+ {
+ var str_token = alternates[i];
+
+ if (str_token == token)
+ return 2;
+ else if (str_token.has_prefix (token))
+ return 1;
+ }
+
+ return 0;
+ }
+}
diff --git a/tests/dummy/Makefile.am b/tests/dummy/Makefile.am
index e6f05c3..b5b4f8b 100644
--- a/tests/dummy/Makefile.am
+++ b/tests/dummy/Makefile.am
@@ -25,6 +25,7 @@ all_test_programs = \
individual-retrieval \
add-persona \
linkable-properties \
+ search-view \
$(NULL)
installed_tests_subdirectory = dummy
@@ -40,6 +41,10 @@ linkable_properties_SOURCES = \
linkable-properties.vala \
$(NULL)
+search_view_SOURCES = \
+ search-view.vala \
+ $(NULL)
+
-include $(top_srcdir)/git.mk
-include $(top_srcdir)/valgrind.mk
-include $(top_srcdir)/check.mk
diff --git a/tests/dummy/search-view.vala b/tests/dummy/search-view.vala
new file mode 100644
index 0000000..fb10153
--- /dev/null
+++ b/tests/dummy/search-view.vala
@@ -0,0 +1,778 @@
+/*
+ * Copyright (C) 2011, 2015 Collabora Ltd.
+ *
+ * This library is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this library. If not, see <http://www.gnu.org/licenses/>.
+ *
+ * Authors: Travis Reitter <travis reitter collabora co uk>
+ * Philip Withnall <philip withnall collabora co uk>
+ */
+
+using Gee;
+using Folks;
+using FolksDummy;
+
+public class SearchViewTests : DummyTest.TestCase
+{
+ /* NOTE: The contents of these variables needs to match their names */
+ private const string _FULL_NAME = "Sterling Mallory Archer";
+ private const string _FULL_NAME_TOKEN = "Archer";
+ private const string _FULL_NAME_TOKEN_LC = "archer";
+ private const string _FULL_NAME_SUBTOKEN = "cher";
+ private const string _FULL_NAME_PREFIX = "arch";
+ private const string _NON_MATCHING_PARTIAL_NAME = "Stimpson";
+ private const string _PHONE_NUMBER = "+1-800-867-5309";
+ private const string _EQUIVALENT_PHONE_NUMBER = "867-5309";
+
+ public SearchViewTests ()
+ {
+ base ("SearchView");
+
+ this.add_test ("simple search results", this.test_simple_search_results);
+ this.add_test ("search before and after", this.test_search_before_after);
+ this.add_test ("match each type of field", this.test_match_each_field);
+ this.add_test ("individual changes", this.test_individual_changes);
+ this.add_test ("query changes", this.test_query_changes);
+ }
+
+ public override void configure_primary_store ()
+ {
+ base.configure_primary_store ();
+ this.dummy_persona_store.reach_quiescence ();
+ }
+
+ struct SimpleTestVector
+ {
+ public unowned string query;
+ public unowned string expected_individuals; /* comma separated, ordered */
+ }
+
+ public void test_simple_search_results ()
+ {
+ /* Test vectors. */
+ const SimpleTestVector[] vectors =
+ {
+ { "Ali", "persona1" },
+ { "Ali Avo", "persona1" },
+ { "Arachnid", "persona2" },
+ { "unmatched", "" },
+ { "archer", "persona0" },
+ { "arch", "persona0" },
+ /* Non-prefix match. */
+ { "cher", "" },
+ /* Phone numbers. */
+ { "867-5309", "persona0" },
+ { "+1-800-867-5309", "persona0" },
+ /* Test transliteration only applies to the individual’s tokens. */
+ { "Al", "persona1,persona3" },
+ { "Ál", "persona3" },
+ /* Test different Unicode normalisations and transliterations. */
+ { "Pan", "persona3" },
+ { "Pa\xf1", "persona3" },
+ { "Pa\x6e\x303", "persona3" },
+ /* Sort stability. */
+ { "A", "persona1,persona2,persona0,persona3" },
+ { "Al", "persona1,persona3" },
+ { "Ali", "persona1" },
+ };
+
+ /* Set up a dummy persona store. */
+ var persona0 = this._generate_main_persona ();
+ var persona1 = new FullPersona (this.dummy_persona_store, "persona1");
+ var persona2 = new FullPersona (this.dummy_persona_store, "persona2");
+ var persona3 = new FullPersona (this.dummy_persona_store, "persona3");
+
+ persona1.update_full_name ("Alice Avogadro");
+ persona2.update_full_name ("Artemis Arachnid");
+ persona3.update_full_name ("Álvaro Pañuelo");
+
+ var personas = new HashSet<Folks.Persona> ();
+ personas.add (persona0);
+ personas.add (persona1);
+ personas.add (persona2);
+ personas.add (persona3);
+
+ this.dummy_persona_store.register_personas (personas);
+
+ /* Set up the aggregator. */
+ var aggregator = IndividualAggregator.dup ();
+
+ foreach (var vector in vectors)
+ {
+ /* Create a set of the individuals we expect. */
+ var inds = vector.expected_individuals.split (",");
+
+ this._test_simple_search_results_single (aggregator, vector.query,
+ inds);
+ }
+
+ aggregator = null;
+ }
+
+ /* May modify @expected_individuals. */
+ private void _test_simple_search_results_single (
+ IndividualAggregator aggregator,
+ string query,
+ string[] expected_individuals)
+ {
+ var main_loop = new GLib.MainLoop (null, false);
+
+ /* Set up the query and search view. */
+ var fields = Query.MATCH_FIELDS_NAMES;
+ foreach (var field in Query.MATCH_FIELDS_ADDRESSES)
+ fields += field;
+ var simple_query = new SimpleQuery (query, fields);
+ var search_view = new SearchView (aggregator, simple_query);
+
+ search_view.prepare.begin ((s, r) =>
+ {
+ try
+ {
+ search_view.prepare.end (r);
+ main_loop.quit ();
+ }
+ catch (GLib.Error e1)
+ {
+ error ("Failed to prepare search view: %s", e1.message);
+ }
+ });
+
+ /* Run the test for a few seconds and fail if the timeout is exceeded. */
+ TestUtils.loop_run_with_timeout (main_loop);
+
+ /* Check the individuals, in order. */
+ var iter = search_view.individuals.iterator ();
+ foreach (var expected_persona_id in expected_individuals)
+ {
+ assert (iter.next ());
+ var ind = iter.get ();
+
+ assert (ind.personas.size == 1);
+ foreach (var persona in ind.personas)
+ {
+ assert (expected_persona_id == persona.display_id);
+ }
+ }
+
+ assert (!iter.has_next ());
+
+ search_view.unprepare.begin ((s, r) =>
+ {
+ try
+ {
+ search_view.unprepare.end (r);
+ main_loop.quit ();
+ }
+ catch (GLib.Error e1)
+ {
+ error ("Failed to unprepare search view: %s", e1.message);
+ }
+ });
+ TestUtils.loop_run_with_timeout (main_loop);
+
+ search_view.aggregator.unprepare.begin ((s, r) =>
+ {
+ try
+ {
+ search_view.aggregator.unprepare.end (r);
+ main_loop.quit ();
+ }
+ catch (GLib.Error e2)
+ {
+ error ("Failed to unprepare aggregator: %s", e2.message);
+ }
+ });
+ TestUtils.loop_run_with_timeout (main_loop);
+
+ search_view = null;
+ }
+
+ public void test_search_before_after ()
+ {
+ var main_loop = new GLib.MainLoop (null, false);
+ var expected_matches = new HashSet<string> ();
+ var expected_non_matches = new HashSet<string> ();
+ var unexpected_matches = new HashSet<string> ();
+
+ /* Add a first persona who will be matched. */
+ var personas = new HashSet<Folks.Persona> ();
+ personas.add (this._generate_main_persona ());
+
+ /* Add a second persona, not expected to match the query. */
+ var persona1 = new FullPersona (this.dummy_persona_store, "persona1");
+
+ persona1.update_full_name ("Lana Kane");
+
+ var email_addresses = new HashSet<EmailFieldDetails> ();
+ email_addresses.add (new EmailFieldDetails ("lana isis secret"));
+ persona1.update_email_addresses (email_addresses);
+
+ personas.add (persona1);
+
+ /* Perform a single-token search which will match two Individuals (one at
+ * first, then another added after preparing the SearchView/Aggregator) */
+ expected_matches.add ("persona0");
+ this.dummy_persona_store.register_personas (personas);
+
+ var store = BackendStore.dup ();
+ store.prepare.begin ((s, r) =>
+ {
+ store.prepare.end (r);
+ main_loop.quit ();
+ });
+
+ TestUtils.loop_run_with_timeout (main_loop);
+
+ var aggregator = IndividualAggregator.dup ();
+
+ var fields = Query.MATCH_FIELDS_NAMES;
+ foreach (var field in Query.MATCH_FIELDS_ADDRESSES)
+ fields += field;
+ var query = new SimpleQuery ("Mallory", fields);
+ var search_view = new SearchView (aggregator, query);
+
+ var handler_id = search_view.individuals_changed_detailed.connect ((added, removed) =>
+ {
+ this._individuals_added (added, main_loop,
+ expected_matches, expected_non_matches, unexpected_matches);
+ this._individuals_removed (removed, main_loop,
+ expected_matches, expected_non_matches, unexpected_matches);
+ });
+
+ search_view.prepare.begin ((s, r) =>
+ {
+ try
+ {
+ search_view.prepare.end (r);
+ main_loop.quit ();
+ }
+ catch (GLib.Error e)
+ {
+ GLib.error ("Error when calling prepare: %s", e.message);
+ }
+ });
+
+ TestUtils.loop_run_with_timeout (main_loop);
+
+ /* Add a match after the initial set to ensure we handle both existing and
+ * added Individuals in the SearchView */
+ var persona2 = new FullPersona (this.dummy_persona_store, "persona2");
+
+ persona2.update_full_name ("Mallory Archer");
+
+ email_addresses = new HashSet<EmailFieldDetails> ();
+ email_addresses.add (new EmailFieldDetails ("mallory isis secret"));
+ persona2.update_email_addresses (email_addresses);
+
+ personas = new HashSet<Folks.Persona> ();
+ personas.add (persona2);
+ expected_matches.add ("persona2");
+
+ this.dummy_persona_store.register_personas (personas);
+
+ assert (expected_matches.size == 0);
+ foreach (var unexpected_match in unexpected_matches)
+ assert (!(unexpected_match in expected_non_matches));
+
+ search_view.disconnect (handler_id);
+
+ /* Perform a multi-token search which will match fewer Individual(s) */
+ fields = Query.MATCH_FIELDS_NAMES;
+ foreach (var field in Query.MATCH_FIELDS_ADDRESSES)
+ fields += field;
+
+ /* the query string tokens are intentionally out-of-order, in different
+ * case, and contain extra spaces */
+ query = new SimpleQuery (" mallorY sterling ", fields);
+ search_view = new SearchView (aggregator, query);
+ this._test_search_with_view_async ("persona0", null, search_view);
+ }
+
+ public void test_match_each_field ()
+ {
+ var main_loop = new GLib.MainLoop (null, false);
+
+ /* Add contacts with a known value for each property; then, perform a
+ * search that only matches that value and confirm. */
+
+ /* NOTE: each contact added here will only count if it has a test below */
+ var personas = new HashSet<Folks.Persona> ();
+ personas.add (this._generate_test_contact ("full-name",
+ (p) => { p.update_full_name ("full_name"); }));
+ personas.add (this._generate_test_contact ("nickname",
+ (p) => { p.update_nickname ("nickname"); }));
+ /* This fills in our generated value into ContactName.family, but that's
+ * fine for our purposes */
+ personas.add (this._generate_test_contact ("structured-name", (p) =>
+ {
+ p.update_structured_name (new StructuredName ("structured_name", null,
+ null, null, null));
+ }));
+ personas.add (this._generate_test_contact ("email-addresses", (p) =>
+ {
+ var email_addresses = new HashSet<EmailFieldDetails> ();
+ email_addresses.add (new EmailFieldDetails ("email_addresses"));
+ p.update_email_addresses (email_addresses);
+ }));
+ personas.add (this._generate_test_contact ("im-addresses", (p) =>
+ {
+ var im_addresses = new HashMultiMap<string, ImFieldDetails> ();
+ im_addresses.set ("jabber", new ImFieldDetails ("im_addresses"));
+ p.update_im_addresses (im_addresses);
+ }));
+ personas.add (this._generate_test_contact ("phone-numbers", (p) =>
+ {
+ var phone_numbers = new HashSet<PhoneFieldDetails> ();
+ phone_numbers.add (new PhoneFieldDetails ("phone_numbers"));
+ p.update_phone_numbers (phone_numbers);
+ }));
+ personas.add (this._generate_test_contact ("postal-addresses", (p) =>
+ {
+ var postal_addresses = new HashSet<PostalAddressFieldDetails> ();
+ var pa = new PostalAddress (null, null, "postal_addresses",
+ null, null, null, null, null, null);
+ postal_addresses.add (new PostalAddressFieldDetails (pa));
+ p.update_postal_addresses (postal_addresses);
+ }));
+ personas.add (this._generate_test_contact ("web-service-addresses", (p) =>
+ {
+ var wsa = new HashMultiMap<string, WebServiceFieldDetails> (
+ null, null,
+ AbstractFieldDetails.hash_static,
+ AbstractFieldDetails.equal_static);
+ wsa.set ("twitter",
+ new WebServiceFieldDetails ("web_service_addresses"));
+ p.update_web_service_addresses (wsa);
+ }));
+ personas.add (this._generate_test_contact ("urls", (p) =>
+ {
+ var urls = new HashSet<UrlFieldDetails> ();
+ urls.add (new UrlFieldDetails ("urls"));
+ p.update_urls (urls);
+ }));
+ personas.add (this._generate_test_contact ("groups", (p) =>
+ {
+ var groups = new HashSet<string> ();
+ groups.add ("groups");
+ p.update_groups (groups);
+ }));
+ personas.add (this._generate_test_contact ("notes", (p) =>
+ {
+ var notes = new HashSet<NoteFieldDetails> ();
+ notes.add (new NoteFieldDetails ("notes"));
+ p.update_notes (notes);
+ }));
+ personas.add (this._generate_test_contact ("roles", (p) =>
+ {
+ var roles = new HashSet<RoleFieldDetails> ();
+ var role = new Role ("roles");
+ roles.add (new RoleFieldDetails (role));
+ p.update_roles (roles);
+ }));
+
+ /* Prepare a backend store. */
+ var backend_store = BackendStore.dup ();
+
+ backend_store.prepare.begin ((s, r) =>
+ {
+ backend_store.prepare.end (r);
+ main_loop.quit ();
+ });
+
+ TestUtils.loop_run_with_timeout (main_loop);
+
+ this.dummy_persona_store.register_personas (personas);
+
+ /* Prepare the aggregator. */
+ var aggregator = IndividualAggregator.dup ();
+
+ aggregator.prepare.begin ((s, r) =>
+ {
+ try
+ {
+ aggregator.prepare.end (r);
+ main_loop.quit ();
+ }
+ catch (GLib.Error e)
+ {
+ error ("Failed to prepare aggregator: %s", e.message);
+ }
+ });
+
+ TestUtils.loop_run_with_timeout (main_loop);
+
+ /* NOTE: every test here requires an added persona above. */
+ this._test_match_each_field_search_for_prop_name (main_loop, aggregator,
+ "full-name", "full_name");
+ this._test_match_each_field_search_for_prop_name (main_loop, aggregator,
+ "nickname", "nickname");
+ this._test_match_each_field_search_for_prop_name (main_loop, aggregator,
+ "structured-name", "structured_name");
+ this._test_match_each_field_search_for_prop_name (main_loop, aggregator,
+ "email-addresses", "email_addresses");
+ this._test_match_each_field_search_for_prop_name (main_loop, aggregator,
+ "im-addresses", "im_addresses");
+ this._test_match_each_field_search_for_prop_name (main_loop, aggregator,
+ "phone-numbers", "phone_numbers");
+ this._test_match_each_field_search_for_prop_name (main_loop, aggregator,
+ "postal-addresses", "postal_addresses");
+ this._test_match_each_field_search_for_prop_name (main_loop, aggregator,
+ "web-service-addresses", "web_service_addresses");
+ this._test_match_each_field_search_for_prop_name (main_loop, aggregator,
+ "urls", "urls");
+ this._test_match_each_field_search_for_prop_name (main_loop, aggregator,
+ "notes", "notes");
+ this._test_match_each_field_search_for_prop_name (main_loop, aggregator,
+ "roles", "roles");
+ }
+
+ private delegate void GeneratePersonaFunc (FullPersona persona);
+
+ private FullPersona _generate_test_contact (string contact_id,
+ GeneratePersonaFunc generate_persona)
+ {
+ var persona = new FullPersona (this.dummy_persona_store, contact_id);
+
+ persona.update_full_name (contact_id);
+ generate_persona (persona);
+
+ return persona;
+ }
+
+ private void _test_match_each_field_search_for_prop_name (MainLoop main_loop,
+ IndividualAggregator aggregator, string prop_name, string query)
+ {
+ var expected_matches = new HashSet<string> ();
+ var expected_non_matches = new HashSet<string> ();
+ var unexpected_matches = new HashSet<string> ();
+
+ expected_matches.add (prop_name);
+
+ string[] fields = { prop_name };
+ var simple_query = new SimpleQuery (query, fields);
+ var search_view = new SearchView (aggregator, simple_query);
+
+ var handler_id = search_view.individuals_changed_detailed.connect ((added, removed) =>
+ {
+ this._individuals_added (added, main_loop,
+ expected_matches, expected_non_matches, unexpected_matches);
+ this._individuals_removed (removed, main_loop,
+ expected_matches, expected_non_matches, unexpected_matches);
+ });
+
+ search_view.prepare.begin ((s, r) =>
+ {
+ try
+ {
+ search_view.prepare.end (r);
+ }
+ catch (GLib.Error e)
+ {
+ error ("Error when calling prepare: %s", e.message);
+ }
+ });
+
+ TestUtils.loop_run_with_timeout (main_loop);
+ assert (expected_matches.size == 0);
+
+ search_view.disconnect (handler_id);
+ }
+
+ public void test_individual_changes ()
+ {
+ var main_loop = new GLib.MainLoop (null, false);
+
+ var personas = new HashSet<Folks.Persona> ();
+ personas.add (this._generate_main_persona ());
+
+ this.dummy_persona_store.register_personas (personas);
+
+ var backend_store = BackendStore.dup ();
+ backend_store.prepare.begin ((s, r) =>
+ {
+ backend_store.prepare.end (r);
+ main_loop.quit ();
+ });
+
+ TestUtils.loop_run_with_timeout (main_loop);
+
+ var aggregator = IndividualAggregator.dup ();
+
+ /*
+ * Match original full name
+ */
+ var query = new SimpleQuery (_FULL_NAME_TOKEN,
+ Query.MATCH_FIELDS_NAMES);
+ var search_view = new SearchView (aggregator, query);
+ this._test_search_with_view_async ("persona0", null, search_view);
+
+ /*
+ * Remove match by changing matching field
+ */
+ var new_non_matching_name = new StructuredName (
+ "Cat", "Stimpson", "J.", null, null);
+ this._change_user_names.begin (_FULL_NAME,
+ new_non_matching_name, aggregator, (s, r) =>
+ {
+ this._change_user_names.end (r);
+ main_loop.quit ();
+ });
+
+ TestUtils.loop_run_with_timeout (main_loop);
+
+ query = new SimpleQuery (_FULL_NAME_TOKEN,
+ Query.MATCH_FIELDS_NAMES);
+ search_view = new SearchView (aggregator, query);
+ this._test_search_with_view_async (null, "persona0", search_view);
+ }
+
+ public void test_query_changes ()
+ {
+ var main_loop = new GLib.MainLoop (null, false);
+
+ var personas = new HashSet<Folks.Persona> ();
+ personas.add (this._generate_main_persona ());
+
+ this.dummy_persona_store.register_personas (personas);
+
+ var backend_store = BackendStore.dup ();
+ backend_store.prepare.begin ((s, r) =>
+ {
+ backend_store.prepare.end (r);
+ main_loop.quit ();
+ });
+
+ TestUtils.loop_run_with_timeout (main_loop);
+
+ var aggregator = IndividualAggregator.dup ();
+
+ /*
+ * Match original full name
+ */
+ var query = new SimpleQuery (_FULL_NAME_TOKEN,
+ Query.MATCH_FIELDS_NAMES);
+ var search_view = new SearchView (aggregator, query);
+ this._test_search_with_view_async ("persona0", null, search_view);
+
+ /*
+ * Remove match by changing the query
+ */
+ search_view.query = new SimpleQuery (
+ _NON_MATCHING_PARTIAL_NAME, Query.MATCH_FIELDS_NAMES);
+ this._test_search_with_view_async (null, "persona0", search_view);
+
+ /*
+ * Re-add match by changing the query (to different query than the
+ * original but nonetheless also matching our target Individual)
+ */
+ search_view.query = new SimpleQuery (
+ _FULL_NAME_PREFIX, Query.MATCH_FIELDS_NAMES);
+ this._test_search_with_view_async ("persona0", null, search_view);
+
+ /*
+ * Remove match by changing the query's string
+ */
+ ((SimpleQuery) search_view.query).query_string =
+ _NON_MATCHING_PARTIAL_NAME;
+ this._test_search_with_view_async (null, "persona0", search_view);
+
+ /*
+ * Re-add match by changing the query's string
+ */
+ ((SimpleQuery) search_view.query).query_string =
+ _FULL_NAME_PREFIX;
+ this._test_search_with_view_async ("persona0", null, search_view);
+ }
+
+ private void _individuals_added (Collection<Individual> added,
+ MainLoop main_loop, Set<string> expected_matches,
+ Set<string> expected_non_matches, Set<string> unexpected_matches)
+ {
+ foreach (Individual i in added)
+ {
+ assert (i.personas.size == 1);
+
+ /* Using the display ID is a little hacky, since we strictly shouldn't
+ * assume anything about…but for the dummy backend, we know it's
+ * equal to the contact ID. */
+ foreach (var persona in i.personas)
+ {
+ var removed_expected = false;
+
+ if (persona.display_id in expected_matches)
+ {
+ removed_expected =
+ expected_matches.remove (persona.display_id);
+ }
+ else
+ {
+ unexpected_matches.add (persona.display_id);
+ }
+
+ if (removed_expected && expected_matches.size == 0)
+ main_loop.quit ();
+ }
+ }
+ }
+
+ private void _individuals_removed (Collection<Individual> removed,
+ MainLoop main_loop, Set<string> expected_matches,
+ Set<string> expected_non_matches, Set<string> unexpected_matches)
+ {
+ foreach (Individual i in removed)
+ {
+ assert (i.personas.size == 1);
+
+ /* Using the display ID is a little hacky, since we strictly shouldn't
+ * assume anything about…but for the dummy backend, we know it's
+ * equal to the contact ID. */
+ foreach (var persona in i.personas)
+ {
+ /* Note this is asymmetrical to _individuals_added; we don't
+ * attempt to re-add entries to expected_matches */
+
+ /* In case our search view removes an Individual later because
+ * we've purposely changed it to disqualify it from the query, we
+ * shouldn't count the initially "unexpected" match against our
+ * test */
+ unexpected_matches.remove (persona.display_id);
+ }
+ }
+ }
+
+ /* Generate a single dummy persona. */
+ private FullPersona _generate_main_persona ()
+ {
+ /* Set up a dummy persona store. */
+ var persona = new FullPersona (this.dummy_persona_store, "persona0");
+
+ /* NOTE: the full_names of these contacts must be unique */
+ persona.update_full_name (_FULL_NAME);
+ persona.update_nickname ("Duchess");
+
+ var email_addresses = new HashSet<EmailFieldDetails> ();
+ email_addresses.add (new EmailFieldDetails ("sterling isis secret"));
+ persona.update_email_addresses (email_addresses);
+
+ var phone_numbers = new HashSet<PhoneFieldDetails> ();
+ phone_numbers.add (new PhoneFieldDetails (_PHONE_NUMBER));
+ persona.update_phone_numbers (phone_numbers);
+
+ return persona;
+ }
+
+ private async void _change_user_names (
+ string expected_full_name,
+ StructuredName non_matching_structured_name,
+ IndividualAggregator aggregator)
+ {
+ var non_matching_full_name = "%s %s %s".printf (
+ non_matching_structured_name.given_name,
+ non_matching_structured_name.additional_names,
+ non_matching_structured_name.family_name);
+
+ foreach (var individual in aggregator.individuals.values)
+ {
+ if (individual.full_name != expected_full_name)
+ continue;
+
+ /* There should be exactly one Persona on this Individual */
+ foreach (var persona in individual.personas)
+ {
+ var name_details = persona as NameDetails;
+ assert (name_details != null);
+
+ try
+ {
+ yield name_details.change_full_name (
+ non_matching_full_name);
+ yield name_details.change_structured_name (
+ non_matching_structured_name);
+ return;
+ }
+ catch (PropertyError e)
+ {
+ error (e.message);
+ }
+ }
+ }
+
+ assert_not_reached ();
+ }
+
+ private void _test_search_with_view_async (
+ string? expected_match_display_id,
+ string? expected_non_match_display_id,
+ SearchView search_view)
+ {
+ var main_loop = new MainLoop (null, false);
+ var expected_matches = new HashSet<string> ();
+ var expected_non_matches = new HashSet<string> ();
+ var unexpected_matches = new HashSet<string> ();
+
+ if (expected_match_display_id != null)
+ expected_matches.add (expected_match_display_id);
+ if (expected_non_match_display_id != null)
+ expected_non_matches.add (expected_non_match_display_id);
+
+ var handler_id = search_view.individuals_changed_detailed.connect ((added, removed) =>
+ {
+ this._individuals_added (added, main_loop,
+ expected_matches, expected_non_matches, unexpected_matches);
+ this._individuals_removed (removed, main_loop,
+ expected_matches, expected_non_matches, unexpected_matches);
+ });
+
+ /* If there are any matches already, handle those. */
+ this._individuals_added (search_view.individuals, main_loop,
+ expected_matches, expected_non_matches, unexpected_matches);
+
+ var is_prepared = false;
+ search_view.prepare.begin ((s, r) =>
+ {
+ try
+ {
+ search_view.prepare.end (r);
+ is_prepared = true;
+
+ if (expected_matches.size == 0)
+ main_loop.quit ();
+ }
+ catch (GLib.Error e)
+ {
+ error ("Error when calling prepare: %s", e.message);
+ }
+ });
+
+ if (expected_matches.size > 0 || !is_prepared)
+ TestUtils.loop_run_with_timeout (main_loop);
+
+ assert (expected_matches.size == 0);
+ foreach (var unexpected_match in unexpected_matches)
+ assert (!(unexpected_match in expected_non_matches));
+
+ search_view.disconnect (handler_id);
+ }
+}
+
+public int main (string[] args)
+{
+ Test.init (ref args);
+
+ var tests = new SearchViewTests ();
+ tests.register ();
+ Test.run ();
+ tests.final_tear_down ();
+
+ return 0;
+}
[
Date Prev][
Date Next] [
Thread Prev][
Thread Next]
[
Thread Index]
[
Date Index]
[
Author Index]