[folks] core: Implement base classes for searching



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]